diff --git a/.claude/skills/clone-website/SKILL.md b/.claude/skills/clone-website/SKILL.md index d070bd969..4b526132b 100644 --- a/.claude/skills/clone-website/SKILL.md +++ b/.claude/skills/clone-website/SKILL.md @@ -204,7 +204,7 @@ Then run the site once locally to produce `instance/.db`, copy it to ```bash ./scripts/build.sh webharbor:dev docker run -d --rm --name wh-test \ - -p 8201:8101 -p 41000-41028:40000-40028 webharbor:dev + -p 8201:8101 -p 41000-41029:40000-40029 webharbor:dev # your new site is on port 41000 + its index curl -so /dev/null -w "%{http_code}\n" http://localhost:41000NN/ @@ -228,7 +228,7 @@ After Phase 1, you should have: - `sites//static/` with real CSS/JS/icons (and images under HF assets) - `sites//instance_seed/.db` with seeded data - Site registered in `websyn_start.sh`, `control_server.py`, `Dockerfile` -- All 29 sites still return 200 on the alt-port container +- All 30 sites still return 200 on the alt-port container - Byte-identical reset passes ## Next step diff --git a/.claude/skills/evolve-env/SKILL.md b/.claude/skills/evolve-env/SKILL.md index 5b9ba2ebb..f2233da08 100644 --- a/.claude/skills/evolve-env/SKILL.md +++ b/.claude/skills/evolve-env/SKILL.md @@ -65,7 +65,7 @@ through `BASE_DIR = os.path.dirname(os.path.abspath(__file__))`. After any DB-affecting change: ```bash -# 1. Stop test container (don't touch user's working container on :40000-40028) +# 1. Stop test container (don't touch user's working container on :40000-40029) docker stop wh-test 2>/dev/null || true # 2. Rebuild @@ -73,7 +73,7 @@ docker stop wh-test 2>/dev/null || true # 3. Run on alt ports docker run -d --rm --name wh-test \ - -p 8201:8101 -p 41000-41028:40000-40028 webharbor:dev + -p 8201:8101 -p 41000-41029:40000-40029 webharbor:dev # 4. Reset your site and confirm byte-identity curl -X POST http://localhost:8201/reset/ @@ -149,7 +149,7 @@ After Phase 3: - Every task in `tasks.jsonl` is hand-verified to work end-to-end - The mirror has no obvious leaks, broken forms, or empty pages - Byte-identical reset passes -- All 29 sites still return 200 +- All 30 sites still return 200 ## Next step diff --git a/.claude/skills/harden-env/SKILL.md b/.claude/skills/harden-env/SKILL.md index f0cbeaa1b..31155506f 100644 --- a/.claude/skills/harden-env/SKILL.md +++ b/.claude/skills/harden-env/SKILL.md @@ -130,7 +130,7 @@ Hardening changes seed data. After any DB change: ```bash ./scripts/build.sh webharbor:dev docker run -d --rm --name wh-test \ - -p 8201:8101 -p 41000-41028:40000-40028 webharbor:dev + -p 8201:8101 -p 41000-41029:40000-40029 webharbor:dev curl -X POST http://localhost:8201/reset/ docker exec wh-test md5sum \ /opt/WebSyn//instance/.db \ diff --git a/.claude/skills/review-env/SKILL.md b/.claude/skills/review-env/SKILL.md index 54cc7b5e3..0d86dae8b 100644 --- a/.claude/skills/review-env/SKILL.md +++ b/.claude/skills/review-env/SKILL.md @@ -33,18 +33,18 @@ gh pr checkout ./scripts/fetch_assets.sh # pull the pinned HF revision ./scripts/build.sh webharbor:dev docker run -d --rm --name wh-review \ - -p 8201:8101 -p 41000-41028:40000-40028 webharbor:dev + -p 8201:8101 -p 41000-41029:40000-40029 webharbor:dev ``` -Confirm the new/changed site is on the expected port (40000 + index). Note: the image now runs 29 sites (40000-40028). +Confirm the new/changed site is on the expected port (40000 + index). Note: the image now runs 30 sites (40000-40029). ### Step 2: The mechanical checks (5 minutes) Run the same Pre-PR checks the contributor was supposed to run. ```bash -# 1. all 29 sites return 200 -for p in $(seq 41000 41028); do +# 1. all 30 sites return 200 +for p in $(seq 41000 41029); do curl -so /dev/null -w "$p:%{http_code}\n" http://localhost:$p/ done @@ -231,7 +231,7 @@ Leave a structured comment on the PR: ## Review: ### Mechanical checks: PASS / FAIL -- [x] All 29 sites return 200 +- [x] All 30 sites return 200 - [x] Control plane healthy - [x] Byte-identical reset (md5 match) - [x] Parallel reset <10s diff --git a/.claude/skills/seed-database/SKILL.md b/.claude/skills/seed-database/SKILL.md index f19c070d6..8de12d699 100644 --- a/.claude/skills/seed-database/SKILL.md +++ b/.claude/skills/seed-database/SKILL.md @@ -169,10 +169,10 @@ gh pr create ./scripts/check_assets.sh # every site has instance_seed/ ./scripts/build.sh webharbor:dev # docker build succeeds docker run -d --rm --name wh-test \ - -p 8201:8101 -p 41000-41028:40000-40028 webharbor:dev + -p 8201:8101 -p 41000-41029:40000-40029 webharbor:dev -# all 29 sites return 200 -for p in $(seq 41000 41028); do +# all 30 sites return 200 +for p in $(seq 41000 41029); do curl -so /dev/null -w "$p:%{http_code}\n" http://localhost:$p/ done diff --git a/AGENTS.md b/AGENTS.md index e595698bc..ed62c98ba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ A coding agent (Claude Code, Cursor, Aider, Codex, ...) is reading this. Read on ## What it is -29 Flask mirror websites (Amazon, GitHub, BBC News, ...) packaged into one Docker image, plus a control plane on `:8101` for resetting per-site state. Used as a deterministic offline environment for web-agent benchmarks. ~3 GB image. +30 Flask mirror websites (Amazon, GitHub, BBC News, ...) packaged into one Docker image, plus a control plane on `:8101` for resetting per-site state. Used as a deterministic offline environment for web-agent benchmarks. ~3 GB image. Two repos: - **code** (this one) — Flask apps, control plane, scripts. @@ -48,17 +48,17 @@ Inside the image, sites live at `/opt/WebSyn//`. The path predates the ren # fresh clone ./scripts/fetch_assets.sh # pulls assets from HF ./scripts/build.sh # docker build -t webharbor:dev . -docker run -d -p 8101:8101 -p 40000-40028:40000-40028 webharbor:dev +docker run -d -p 8101:8101 -p 40000-40029:40000-40029 webharbor:dev ``` Or use the published image directly: ```bash -docker run -d -p 8101:8101 -p 40000-40028:40000-40028 \ +docker run -d -p 8101:8101 -p 40000-40029:40000-40029 \ battalion7244/webharbor:latest ``` -Sites are on `40000`-`40028` in the order declared by `SITES=( ... )` in `websyn_start.sh`. Control plane: +Sites are on `40000`-`40029` in the order declared by `SITES=( ... )` in `websyn_start.sh`. Control plane: | Method | Path | Purpose | |--------|---------------------|-------------------------------------------| @@ -136,13 +136,13 @@ python3 -m py_compile sites//app.py # 3. run on alt ports (don't collide with anything you already have running) docker run -d --rm --name wh-test \ - -p 8201:8101 -p 41000-41028:40000-40028 webharbor:dev + -p 8201:8101 -p 41000-41029:40000-40029 webharbor:dev # 4. control plane healthy, all sites alive curl -s http://localhost:8201/health | python3 -m json.tool | head # 5. every site renders 200 -for p in $(seq 41000 41028); do +for p in $(seq 41000 41029); do curl -so /dev/null -w "$p:%{http_code}\n" http://localhost:$p/ done diff --git a/CLAUDE.md b/CLAUDE.md index 5e84cb62b..cf9ded2f3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,4 +16,4 @@ The full agent guide is loaded above via `@AGENTS.md`. The notes below apply onl ## Existing containers -If a container is already running on `:8101` / `:40000-40028`, treat it as the user's working environment — don't `docker stop` or `docker rm` it without explicit confirmation. Spin up your test container under a different name on alt ports (`:8201`, `:41000-41028`). +If a container is already running on `:8101` / `:40000-40029`, treat it as the user's working environment — don't `docker stop` or `docker rm` it without explicit confirmation. Spin up your test container under a different name on alt ports (`:8201`, `:41000-41029`). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 22e4c1c25..936c7ba5a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,7 +24,7 @@ git clone https://github.com//webharbor && cd webharbor ./scripts/fetch_assets.sh # pull current assets ./scripts/new_site.py mywebsite # OR edit an existing site ./scripts/build.sh && docker run -d --rm \ - -p 8101:8101 -p 40000-40028:40000-40028 webharbor:dev + -p 8101:8101 -p 40000-40029:40000-40029 webharbor:dev # iterate locally... ./scripts/extract_assets.sh ../webharbor-static-pr/ # split assets out diff --git a/Dockerfile b/Dockerfile index c5a2cbab7..b06eae72d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -92,6 +92,6 @@ os.makedirs('instance_seed', exist_ok=True); \ shutil.copy2('instance/rotten_tomatoes.db', 'instance_seed/rotten_tomatoes.db'); \ print('Rotten Tomatoes seed DB generated at build time.')" && rm -rf /opt/WebSyn/rotten_tomatoes/instance -EXPOSE 8101 40000-40028 +EXPOSE 8101 40000-40029 CMD ["/opt/websyn_start.sh"] diff --git a/README.md b/README.md index 7826d5926..0a44788bd 100644 --- a/README.md +++ b/README.md @@ -36,17 +36,17 @@ WebHarbor takes a different approach. We leverage coding agent (e.g., Claude Cod - **Deep features unlocked** — carts, checkouts, accounts, all fully testable - **Evolving** — harder tasks drive richer mirrors; the environment grows with agents - **RL-ready** — sub-second database resets between rollouts -- **Community-driven** — 29 sites today, scaling to 100+ together +- **Community-driven** — 30 sites today, scaling to 100+ together ## 🚀 Quickstart One command to run all web environments: ```bash -docker run -p 8101:8101 -p 40000-40028:40000-40028 battalion7244/webharbor:latest +docker run -p 8101:8101 -p 40000-40029:40000-40029 battalion7244/webharbor:latest ``` -Then point your agent at `http://localhost:40000` through `http://localhost:40028` to explore 29 local mirrors of WebVoyager sites: `Allrecipes, Amazon, Apple, ArXiv, BBC News, Booking, GitHub, Google Flights, Google Maps, Google Search, Hugging Face, Wolfram Alpha, Cambridge Dictionary, Coursera, ESPN, Merriam-Webster, IKEA, Phys.org, Target, TED, Ohio State University, Rotten Tomatoes, Compass, Walmart Careers, FedEx, WebMD Doctor, Healthline, Kaggle, and NVIDIA`. +Then point your agent at `http://localhost:40000` through `http://localhost:40029` to explore 30 local mirrors of WebVoyager sites: `Allrecipes, Amazon, Apple, ArXiv, BBC News, Booking, GitHub, Google Flights, Google Maps, Google Search, Hugging Face, Wolfram Alpha, Cambridge Dictionary, Coursera, ESPN, Merriam-Webster, IKEA, Phys.org, Target, TED, Ohio State University, Rotten Tomatoes, Compass, Walmart Careers, FedEx, WebMD Doctor, Healthline, Kaggle, NVIDIA, and Discogs`. For sub-second reset between rollouts, expose the control plane and call `/reset/`: @@ -63,19 +63,20 @@ git clone https://github.com/aiming-lab/WebHarbor && cd WebHarbor ./scripts/build.sh # docker build -t webharbor:dev . ``` -### Local NVIDIA review candidate +### Local NVIDIA and Discogs review candidates -This branch registers **29 sites**, the 29 entries listed above; NVIDIA is the last -entry, registry index 28, container port 40028 (local review host port 48028). The -published-image quickstart above is not a claim that this review candidate has been -published or accepted. +This branch registers **30 sites**, the 30 entries listed above. NVIDIA remains at +registry index 28 and Discogs is appended at index 29. The published-image +quickstart above is not a claim that either review candidate has been published or +accepted. | Site | Registry position | Container port | Local review host port | | --- | --- | --- | --- | | NVIDIA | 28 | 40028 | 48028 | +| Discogs | 29 | 40029 | 48029 | `websyn_start.sh`, `control_server.py`, the `Dockerfile` `EXPOSE` line and every -site's `tasks.jsonl` `web` URL agree on 29 sites and `40000-40028`; +site's `tasks.jsonl` `web` URL agree on 30 sites and `40000-40029`; `scripts/check_site_registry.py` (run by `scripts/check_assets.sh`) fails when they drift. @@ -83,7 +84,7 @@ After preparing the candidate assets and building `webharbor:dev`, the local review deployment uses: ```bash -docker run -p 127.0.0.1:48080:8101 -p 127.0.0.1:48000-48028:40000-40028 webharbor:dev +docker run -p 127.0.0.1:48080:8101 -p 127.0.0.1:48000-48029:40000-40029 webharbor:dev ``` NVIDIA inherits the site contribution from @KaKituken @@ -105,14 +106,15 @@ added the first reviewed NVIDIA bundle. Those PRs are merged, the pinned commit on `main`, and the earlier "PR not merged" and "archive rejected by the validator" blockers are cleared: -- the pinned revision carries 31 `*.tar.gz` (one per registered site plus - `bandcamp.tar.gz` and `drugs_com.tar.gz`, which `fetch_assets.sh` ignores for - sites this checkout does not register); +- the pinned revision carries 31 `*.tar.gz` (one per upstream NVIDIA candidate + site plus `bandcamp.tar.gz` and `drugs_com.tar.gz`); Discogs remains in open HF + PR #76 and therefore requires its immutable revision override until that PR is + merged; - `nvidia.tar.gz` at that revision has 37 file members and no directory members, so `scripts/validate_asset_archive.py nvidia.tar.gz nvidia` prints `[fetch] validated 37 managed members for nvidia` and exits 0; -- `./scripts/fetch_assets.sh` at this pin extracts all 29 registered sites - (`[fetch] done — 29 site(s) extracted into sites/`). +- `./scripts/fetch_assets.sh` at this pin extracts the 29 upstream sites. It does + not yet prepare Discogs from a clean checkout. | Artifact | Members | Bytes | SHA-256 | | --- | --- | --- | --- | @@ -204,7 +206,7 @@ itself cannot be edited from this repository. ## 🤝 Contribute -We have built 29 high-quality mirrors covering the [WebVoyager](https://github.com/MinorJerry/WebVoyager) benchmark. The next goal is **100+ sites**, covering everything in [Online-Mind2Web](https://huggingface.co/datasets/osunlp/Online-Mind2Web). We are inviting the community to build this together. +We have built 30 high-quality mirrors covering the [WebVoyager](https://github.com/MinorJerry/WebVoyager) benchmark. The next goal is **100+ sites**, covering everything in [Online-Mind2Web](https://huggingface.co/datasets/osunlp/Online-Mind2Web). We are inviting the community to build this together. There are two ways to join the author list: diff --git a/agent_demo/README.md b/agent_demo/README.md index 0570c745c..dbc66a0e8 100644 --- a/agent_demo/README.md +++ b/agent_demo/README.md @@ -19,7 +19,7 @@ export OPENAI_BASE_URL=https://api.openai.com/v1 # or your Azure / vLLM endpoi ## Run a task -WebHarbor must already be running locally (`docker run -p 8101:8101 -p 40000-40028:40000-40028 battalion7244/webharbor:latest`). +WebHarbor must already be running locally (`docker run -p 8101:8101 -p 40000-40029:40000-40029 battalion7244/webharbor:latest`). Run a single task from a site's `tasks.jsonl`: diff --git a/control_server.py b/control_server.py index 9e2ca7164..6250e02ed 100644 --- a/control_server.py +++ b/control_server.py @@ -29,8 +29,8 @@ 'cambridge_dictionary', 'coursera', 'espn', 'merriam_webster', 'ikea', 'phys_org', 'target', 'ted', 'osu', 'rotten_tomatoes', 'compass', 'walmart_careers', - 'fedex', 'webmd_doctor', 'healthline', 'kaggle', - 'nvidia', + 'fedex', 'webmd_doctor', 'healthline', 'kaggle', 'nvidia', + 'discogs', ] BASE_PORT = 40000 WEBSYN_DIR = '/opt/WebSyn' diff --git a/sites/discogs/_health.py b/sites/discogs/_health.py new file mode 100644 index 000000000..b206e9e7f --- /dev/null +++ b/sites/discogs/_health.py @@ -0,0 +1,3 @@ +"""Per-site health probe (optional, called by control_server).""" +def health(): + return {"ok": True, "site": "discogs"} diff --git a/sites/discogs/app.py b/sites/discogs/app.py new file mode 100644 index 000000000..213cdb1b7 --- /dev/null +++ b/sites/discogs/app.py @@ -0,0 +1,1828 @@ +"""Discogs mirror — Flask app for WebHarbor. + +Models the catalogue (Release/Master/Artist/Label/Genre/Style/Format/Track), +community (User/Rating/Review/Collection/Wantlist/List), marketplace listings, +and forum threads. Verified catalog data and synthetic benchmark community +state ship together in instance_seed/discogs.db. +""" +import os +import re +import math +import json +import random +from datetime import datetime, timedelta +from functools import wraps +from collections import defaultdict +from decimal import Decimal, InvalidOperation +from urllib.parse import unquote, urljoin, urlsplit + +from flask import (Flask, render_template, request, redirect, url_for, + flash, jsonify, session, abort, g, make_response, send_from_directory) +from flask_sqlalchemy import SQLAlchemy +from flask_login import (LoginManager, UserMixin, login_user, logout_user, + login_required, current_user) +from flask_wtf.csrf import CSRFProtect, generate_csrf +from flask_bcrypt import Bcrypt +from sqlalchemy import or_, and_, func, desc, asc, case + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +INSTANCE_DIR = os.environ.get("DISCOGS_INSTANCE_DIR", os.path.join(BASE_DIR, "instance")) +os.makedirs(INSTANCE_DIR, exist_ok=True) + +app = Flask(__name__, instance_path=INSTANCE_DIR) +app.config["SECRET_KEY"] = "discogs-webharbor-dev-secret" +app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite:///{os.path.join(INSTANCE_DIR, 'discogs.db')}" +app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False +app.config["WTF_CSRF_TIME_LIMIT"] = None +app.config["MAX_CONTENT_LENGTH"] = 4 * 1024 * 1024 + +db = SQLAlchemy(app) +bcrypt = Bcrypt(app) +csrf = CSRFProtect(app) +login_manager = LoginManager(app) +login_manager.login_view = "login" +login_manager.login_message = "Please sign in to continue." + + +# ────────────────────────────────────────────── +# Models +# ────────────────────────────────────────────── + +release_genres = db.Table( + "release_genres", + db.Column("release_id", db.Integer, db.ForeignKey("releases.id"), primary_key=True), + db.Column("genre_id", db.Integer, db.ForeignKey("genres.id"), primary_key=True), +) + +release_styles = db.Table( + "release_styles", + db.Column("release_id", db.Integer, db.ForeignKey("releases.id"), primary_key=True), + db.Column("style_id", db.Integer, db.ForeignKey("styles.id"), primary_key=True), +) + +release_labels = db.Table( + "release_labels", + db.Column("release_id", db.Integer, db.ForeignKey("releases.id"), primary_key=True), + db.Column("label_id", db.Integer, db.ForeignKey("labels.id"), primary_key=True), + db.Column("catno", db.String(80), default=""), +) + +release_formats = db.Table( + "release_formats", + db.Column("release_id", db.Integer, db.ForeignKey("releases.id"), primary_key=True), + db.Column("format_id", db.Integer, db.ForeignKey("formats.id"), primary_key=True), +) + +release_artists = db.Table( + "release_artists", + db.Column("release_id", db.Integer, db.ForeignKey("releases.id"), primary_key=True), + db.Column("artist_id", db.Integer, db.ForeignKey("artists.id"), primary_key=True), +) + + +class User(db.Model, UserMixin): + __tablename__ = "users" + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(40), unique=True, nullable=False, index=True) + email = db.Column(db.String(160), unique=True, nullable=False, index=True) + password_hash = db.Column(db.String(255), nullable=False) + location = db.Column(db.String(100), default="") + real_name = db.Column(db.String(120), default="") + bio = db.Column(db.Text, default="") + avatar_seed = db.Column(db.String(16), default="") + joined_at = db.Column(db.DateTime, default=datetime.utcnow) + is_seller = db.Column(db.Boolean, default=False) + seller_rating = db.Column(db.Float, default=0.0) + seller_feedback_count = db.Column(db.Integer, default=0) + + collection_items = db.relationship("CollectionItem", backref="user", lazy="dynamic", + cascade="all, delete-orphan") + wantlist_items = db.relationship("WantlistItem", backref="user", lazy="dynamic", + cascade="all, delete-orphan") + ratings = db.relationship("Rating", backref="user", lazy="dynamic", cascade="all, delete-orphan") + reviews = db.relationship("Review", backref="user", lazy="dynamic", cascade="all, delete-orphan") + lists = db.relationship("List", backref="user", lazy="dynamic", cascade="all, delete-orphan") + posts = db.relationship("Post", backref="user", lazy="dynamic", cascade="all, delete-orphan") + threads = db.relationship("Thread", backref="user", lazy="dynamic", cascade="all, delete-orphan") + listings = db.relationship("Listing", backref="user", lazy="dynamic", cascade="all, delete-orphan") + + @property + def collection_count(self): + return self.collection_items.count() + + @property + def wantlist_count(self): + return self.wantlist_items.count() + + @property + def avatar_color(self): + s = self.avatar_seed or self.username + h = sum(ord(c) * 31 for c in s) % 360 + return f"hsl({h}, 55%, 45%)" + + +class Artist(db.Model): + __tablename__ = "artists" + id = db.Column(db.Integer, primary_key=True) + discogs_id = db.Column(db.Integer, unique=True, index=True) + name = db.Column(db.String(200), nullable=False, index=True) + slug = db.Column(db.String(220), unique=True, nullable=False, index=True) + real_name = db.Column(db.String(200), default="") + profile = db.Column(db.Text, default="") + members = db.Column(db.Text, default="") + sites = db.Column(db.Text, default="") + image_path = db.Column(db.String(200), default="") + rating = db.Column(db.Float, default=0.0) + in_collection = db.Column(db.Integer, default=0) + + releases = db.relationship("Release", backref="artist", lazy="dynamic") + + +class Label(db.Model): + __tablename__ = "labels" + id = db.Column(db.Integer, primary_key=True) + discogs_id = db.Column(db.Integer, unique=True, index=True) + name = db.Column(db.String(200), nullable=False, index=True) + slug = db.Column(db.String(220), unique=True, nullable=False, index=True) + profile = db.Column(db.Text, default="") + contact_info = db.Column(db.Text, default="") + parent_label_id = db.Column(db.Integer, db.ForeignKey("labels.id")) + parent_label = db.relationship("Label", remote_side=[id]) + + +class Genre(db.Model): + __tablename__ = "genres" + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + slug = db.Column(db.String(80), unique=True, nullable=False, index=True) + + +class Style(db.Model): + __tablename__ = "styles" + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + slug = db.Column(db.String(80), unique=True, nullable=False, index=True) + + +class Format(db.Model): + __tablename__ = "formats" + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(40), unique=True, nullable=False) + slug = db.Column(db.String(40), unique=True, nullable=False) + + +class Master(db.Model): + __tablename__ = "masters" + id = db.Column(db.Integer, primary_key=True) + discogs_id = db.Column(db.Integer, unique=True, index=True) + title = db.Column(db.String(300), nullable=False) + artist_id = db.Column(db.Integer, db.ForeignKey("artists.id"), nullable=False, index=True) + year = db.Column(db.Integer) + main_release_id = db.Column(db.Integer, db.ForeignKey("releases.id")) + artist = db.relationship("Artist", backref="masters") + + +class Release(db.Model): + __tablename__ = "releases" + id = db.Column(db.Integer, primary_key=True) + discogs_id = db.Column(db.Integer, unique=True, index=True) + title = db.Column(db.String(300), nullable=False, index=True) + artist_credit = db.Column(db.Text, default="") + format_description = db.Column(db.Text, default="") + source_json = db.Column(db.Text, default="") + source_sha256 = db.Column(db.String(64), default="") + source_captured_at = db.Column(db.String(40), default="") + artist_id = db.Column(db.Integer, db.ForeignKey("artists.id"), nullable=False, index=True) + master_id = db.Column(db.Integer, db.ForeignKey("masters.id")) + year = db.Column(db.Integer, index=True) + released = db.Column(db.String(40), default="") + country = db.Column(db.String(80), default="") + notes = db.Column(db.Text, default="") + barcode = db.Column(db.String(80), default="") + catno = db.Column(db.String(80), default="") + data_quality = db.Column(db.String(40), default="") + image_path = db.Column(db.String(200), default="") + avg_rating = db.Column(db.Float, default=0.0) + rating_count = db.Column(db.Integer, default=0) + have_count = db.Column(db.Integer, default=0) + want_count = db.Column(db.Integer, default=0) + lowest_price = db.Column(db.Float) + num_for_sale = db.Column(db.Integer, default=0) + added_at = db.Column(db.DateTime, default=datetime.utcnow) + + genres = db.relationship("Genre", secondary=release_genres, backref="releases") + credited_artists = db.relationship("Artist", secondary=release_artists, backref="credited_releases") + styles = db.relationship("Style", secondary=release_styles, backref="releases") + formats = db.relationship("Format", secondary=release_formats, backref="releases") + labels = db.relationship("Label", secondary=release_labels, backref="releases") + tracks = db.relationship("Track", backref="release", lazy="dynamic", + cascade="all, delete-orphan", order_by="Track.id") + reviews = db.relationship("Review", backref="release", lazy="dynamic", + cascade="all, delete-orphan") + ratings = db.relationship("Rating", backref="release", lazy="dynamic", + cascade="all, delete-orphan") + listings = db.relationship("Listing", backref="release", lazy="dynamic") + master = db.relationship("Master", foreign_keys=[master_id], backref="versions") + + @property + def primary_format(self): + formats = self.source_data.get("formats") or [] + if formats: + return formats[0]["name"] + return self.formats[0].name if self.formats else "" + + @property + def source_data(self): + return json.loads(self.source_json) if self.source_json else {} + + @property + def display_artist(self): + return self.artist_credit or self.artist.name + + @property + def artist_entries(self): + artists_by_id = {artist.discogs_id: artist for artist in self.credited_artists + if artist.discogs_id is not None} + artists_by_name = {artist.name: artist for artist in self.credited_artists} + entries = [] + for source in self.source_data.get("artists", []): + artist = (artists_by_id.get(source.get("id")) if source.get("id") is not None + else artists_by_name.get(source.get("name"))) + if artist: + entries.append((artist, source.get("anv") or source["name"], source.get("join") or "")) + return entries or [(self.artist, self.artist.name, "")] + + @property + def label_entries(self): + labels_by_id = {label.discogs_id: label for label in self.labels + if label.discogs_id is not None} + labels_by_name = {label.name: label for label in self.labels} + entries = [] + for source in self.source_data.get("labels", []): + label = (labels_by_id.get(source.get("id")) if source.get("id") is not None + else labels_by_name.get(source.get("name"))) + if label: + entries.append((label, source.get("catno") or "")) + return entries + + @property + def label_str(self): + return ", ".join(l.name for l in self.labels[:3]) + + @property + def genre_str(self): + return ", ".join(g.name for g in self.genres) + + @property + def style_str(self): + return ", ".join(s.name for s in self.styles) + + @property + def cover_url(self): + path = self.image_path or "" + full = os.path.join(BASE_DIR, "static", path) + if path and os.path.isfile(full): + return url_for("static", filename=path) + return url_for("static", filename="icons/no-cover.svg") + + +class Track(db.Model): + __tablename__ = "tracks" + id = db.Column(db.Integer, primary_key=True) + release_id = db.Column(db.Integer, db.ForeignKey("releases.id"), nullable=False, index=True) + position = db.Column(db.String(10), default="") + title = db.Column(db.String(300), nullable=False) + duration = db.Column(db.String(10), default="") + artist_credit = db.Column(db.String(200), default="") + kind = db.Column(db.String(20), default="track") + depth = db.Column(db.Integer, default=0) + + +class Rating(db.Model): + __tablename__ = "ratings" + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + release_id = db.Column(db.Integer, db.ForeignKey("releases.id"), nullable=False) + value = db.Column(db.Integer, nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + __table_args__ = (db.UniqueConstraint("user_id", "release_id", name="uq_rating_user_release"),) + + +class Review(db.Model): + __tablename__ = "reviews" + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + release_id = db.Column(db.Integer, db.ForeignKey("releases.id"), nullable=False) + body = db.Column(db.Text, nullable=False) + rating = db.Column(db.Integer) + helpful = db.Column(db.Integer, default=0) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + +# "All" is the unfiltered view, not a folder. It must not appear here: the collection +# list treats folder == "All" as "do not filter", so a row actually stored with that +# folder could never be viewed as a folder again, and the template renders the tab row +# as ["All"] + COLLECTION_FOLDERS, which duplicated the tab. +COLLECTION_FOLDERS = ["Uncategorized", "Vinyl", "CD", "Wishlist Bought"] +UNFILTERED_FOLDER = "All" + + +class CollectionItem(db.Model): + __tablename__ = "collection_items" + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True) + release_id = db.Column(db.Integer, db.ForeignKey("releases.id"), nullable=False) + folder = db.Column(db.String(40), default="Uncategorized") + media_condition = db.Column(db.String(40), default="Near Mint (NM or M-)") + sleeve_condition = db.Column(db.String(40), default="Near Mint (NM or M-)") + notes = db.Column(db.String(280), default="") + added_at = db.Column(db.DateTime, default=datetime.utcnow) + release = db.relationship("Release") + __table_args__ = (db.UniqueConstraint("user_id", "release_id", name="uq_coll_user_release"),) + + +class WantlistItem(db.Model): + __tablename__ = "wantlist_items" + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True) + release_id = db.Column(db.Integer, db.ForeignKey("releases.id"), nullable=False) + min_grade = db.Column(db.String(40), default="Very Good Plus (VG+)") + notes = db.Column(db.String(280), default="") + added_at = db.Column(db.DateTime, default=datetime.utcnow) + release = db.relationship("Release") + __table_args__ = (db.UniqueConstraint("user_id", "release_id", name="uq_want_user_release"),) + + +class List(db.Model): + __tablename__ = "lists" + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + title = db.Column(db.String(200), nullable=False) + description = db.Column(db.Text, default="") + is_public = db.Column(db.Boolean, default=True) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + items = db.relationship("ListItem", backref="list", lazy="dynamic", + cascade="all, delete-orphan", order_by="ListItem.position") + + +class ListItem(db.Model): + __tablename__ = "list_items" + id = db.Column(db.Integer, primary_key=True) + list_id = db.Column(db.Integer, db.ForeignKey("lists.id"), nullable=False, index=True) + release_id = db.Column(db.Integer, db.ForeignKey("releases.id")) + artist_id = db.Column(db.Integer, db.ForeignKey("artists.id")) + label_id = db.Column(db.Integer, db.ForeignKey("labels.id")) + comment = db.Column(db.String(400), default="") + position = db.Column(db.Integer, default=0) + release = db.relationship("Release") + artist = db.relationship("Artist") + label = db.relationship("Label") + + +GRADES = ["Mint (M)", "Near Mint (NM or M-)", "Very Good Plus (VG+)", + "Very Good (VG)", "Good Plus (G+)", "Good (G)", "Fair (F)", "Poor (P)"] +CURRENCIES = ("USD", "EUR", "GBP", "JPY", "CAD") + + +class Listing(db.Model): + __tablename__ = "listings" + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + release_id = db.Column(db.Integer, db.ForeignKey("releases.id"), nullable=False, index=True) + media_condition = db.Column(db.String(40), default="Very Good Plus (VG+)") + sleeve_condition = db.Column(db.String(40), default="Very Good Plus (VG+)") + comments = db.Column(db.String(600), default="") + price = db.Column(db.Float, nullable=False) + currency = db.Column(db.String(8), default="USD") + shipping_from = db.Column(db.String(80), default="United States") + allow_offers = db.Column(db.Boolean, default=False) + status = db.Column(db.String(20), default="For Sale") + posted_at = db.Column(db.DateTime, default=datetime.utcnow) + + +class Forum(db.Model): + __tablename__ = "forums" + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(120), nullable=False, unique=True) + slug = db.Column(db.String(120), nullable=False, unique=True, index=True) + description = db.Column(db.String(280), default="") + threads = db.relationship("Thread", backref="forum", lazy="dynamic", + cascade="all, delete-orphan") + + +class Thread(db.Model): + __tablename__ = "threads" + id = db.Column(db.Integer, primary_key=True) + forum_id = db.Column(db.Integer, db.ForeignKey("forums.id"), nullable=False, index=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + title = db.Column(db.String(280), nullable=False) + pinned = db.Column(db.Boolean, default=False) + locked = db.Column(db.Boolean, default=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + posts = db.relationship("Post", backref="thread", lazy="dynamic", + cascade="all, delete-orphan", order_by="Post.created_at") + + +class Post(db.Model): + __tablename__ = "posts" + id = db.Column(db.Integer, primary_key=True) + thread_id = db.Column(db.Integer, db.ForeignKey("threads.id"), nullable=False, index=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + body = db.Column(db.Text, nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + +# ────────────────────────────────────────────── +# Helpers +# ────────────────────────────────────────────── + +def slugify(s): + s = (s or "").lower() + s = re.sub(r"[^a-z0-9]+", "-", s).strip("-") + return s or "x" + + +@login_manager.user_loader +def load_user(uid): + return User.query.get(int(uid)) + + +def nav_groups(): + """Header menu groups mirroring the live Discogs secondary navigation. + + Every destination is local. Live items without a data-backed equivalent + map to local informational pages instead of being dropped. + """ + def item(label, href): + return {"label": label, "href": href} + return [ + {"label": "Explore Discography", "align": "left", "items": [ + item("Explore All", url_for("search")), + item("Advanced Search", url_for("search_advanced")), + item("Most Collected", url_for("search", sort="have", type="release")), + item("Submit a Release", "/release/add"), + item("Submission Guidelines", "/guidelines/submissions"), + ]}, + {"label": "Shop Music", "align": "left", "items": [ + item("Shop My Wants", url_for("shop_my_wants")), + item("New & Upcoming", url_for("search", sort="newest")), + item("Vinyl", url_for("marketplace", format="Vinyl")), + item("CD", url_for("marketplace", format="CD")), + item("Cassette", url_for("marketplace", format="Cassette")), + item("All Formats", url_for("marketplace")), + ]}, + {"label": "Sell Music", "align": "left", "items": [ + item("List Item For Sale", url_for("sell")), + item("Start Selling", "/selling/resources#start"), + item("How To Grade", "/selling/resources#grade"), + item("How To Price", "/selling/resources#price"), + item("How To Pack & Ship", "/selling/resources#pack"), + item("More Seller Resources", "/selling/resources"), + item("Seller News & Updates", "/selling/resources#updates"), + ]}, + {"label": "Community", "align": "left", "items": [ + item("Forum", url_for("forum_index")), + item("Groups", url_for("groups")), + item("List Explorer", url_for("lists_index")), + item("Discography Contributors", url_for("contributors")), + item("Monthly Leaderboard", url_for("leaderboard")), + item("Community Guidelines", "/guidelines/community"), + ]}, + {"label": "Digs", "align": "right", "items": [ + item("Essentials", "/digs#essentials"), + item("Features", "/digs#features"), + item("Most Valuable", "/digs#most-valuable"), + item("Collecting", "/digs#collecting"), + item("Audio Gear", "/digs#audio-gear"), + ]}, + ] + + +def footer_columns(): + """Footer columns mirroring the live Discogs footer labels with local targets.""" + def item(label, href, dialog=None): + return {"label": label, "href": href, "dialog": dialog} + return [ + {"title": "About", "items": [ + item("Get Started", "/about/get-started"), + item("What is Discogs?", "/about/about"), + item("Discography", "/about/features/discography"), + item("Marketplace", "/about/features/music-marketplace"), + item("Collection", "/about/features/collection"), + item("Wantlist", "/about/features/wantlist"), + item("Statistics", "/about/features/sales-history"), + item("Careers", "/about/careers"), + ]}, + {"title": "Community", "items": [ + item("Community Guidelines", "/guidelines/community"), + item("Community Advisory", "/about/get-involved/community-advisory"), + item("Contributor List", url_for("contributors")), + item("Add Release", "/release/add"), + item("Developer API", "/developers"), + item("Help Translate", url_for("forum_view", slug="help")), + ]}, + {"title": "Help & Resources", "items": [ + item("Help Center", "/help"), + item("Seller Resource Center", "/selling/resources"), + item("Submission Guidelines", "/guidelines/submissions"), + item("Trust Center", "/about/trust"), + item("Forum", url_for("forum_index")), + item("System Status", "/status"), + item("Keyboard Shortcuts", "#", dialog="shortcuts"), + ]}, + ] + + +@app.context_processor +def inject_globals(): + return { + "csrf_token": generate_csrf, + "now": datetime.utcnow(), + "grades": GRADES, + "folders": COLLECTION_FOLDERS, + "nav_groups": nav_groups(), + "footer_columns": footer_columns(), + } + + +@app.template_filter("price") +def fmt_price(v, currency="USD"): + if v is None: + return "—" + return f"{currency} {v:,.2f}" + + +def local_return(target, fallback): + """Keep login and form redirects inside this mirror's origin.""" + if not target or target != target.strip(): + return fallback + decoded = unquote(target) + if "\\" in decoded or any(ord(char) < 32 or ord(char) == 127 for char in decoded): + return fallback + if decoded.startswith("//"): + return fallback + try: + resolved = urlsplit(urljoin(request.host_url, target)) + origin = urlsplit(request.host_url) + if (resolved.scheme, resolved.netloc) != (origin.scheme, origin.netloc): + return fallback + except ValueError: + return fallback + return target + + +def return_to_page(fallback): + return redirect(local_return(request.referrer, fallback)) + + +@app.template_global() +def search_url(**changes): + params = {key: value for key, value in request.args.items() + if key in {"q", "type", "genre", "style", "format", "year", "country", "sort", + "label", "catno", "barcode"}} + params.update(changes) + return url_for("search", **{key: value for key, value in params.items() if value is not None}) + + +def _ago(n, unit): + """Live Discogs writes "1 day ago", not "1 days ago".""" + return f"{n} {unit}{'' if n == 1 else 's'} ago" + + +@app.template_filter("relative") +def relative_time(dt): + if not dt: + return "" + delta = datetime.utcnow() - dt + s = int(delta.total_seconds()) + if s < 60: return "just now" + if s < 3600: return _ago(s // 60, "minute") + if s < 86400: return _ago(s // 3600, "hour") + if s < 86400 * 30: return _ago(s // 86400, "day") + if s < 86400 * 365: return _ago(s // (86400 * 30), "month") + return _ago(s // (86400 * 365), "year") + + +@app.template_filter("stars") +def stars_filter(rating): + if not rating: + return "·····" + full = int(round(rating)) + return "★" * full + "·" * (5 - full) + + +def paginate(query, page, per_page=25): + page = max(1, page) + total = query.count() + pages = max(1, math.ceil(total / per_page)) + page = min(page, pages) + items = query.offset((page - 1) * per_page).limit(per_page).all() + # Use SimpleNamespace so .items doesn't clash with dict.items() in Jinja. + from types import SimpleNamespace + return SimpleNamespace(items=items, page=page, pages=pages, total=total, per_page=per_page) + + +def release_query(q, genre=None, style=None, format_=None, year=None, country=None, + label=None, catno=None, barcode=None): + """Unsorted release query for a search; shared by results and facet counts.""" + qs = Release.query + if label: + qs = qs.filter(Release.labels.any(Label.name.ilike(f"%{label.strip()}%"))) + if catno: + qs = qs.filter(Release.catno.ilike(f"%{catno.strip()}%")) + if barcode: + qs = qs.filter(Release.barcode.ilike(f"%{barcode.strip()}%")) + relevance = None + if q: + terms = [t for t in re.split(r"\s+", q.strip()) if t] + if terms: + matches = [or_(Release.title.ilike(f"%{t}%"), Artist.name.ilike(f"%{t}%"), + Release.artist_credit.ilike(f"%{t}%"), + Release.credited_artists.any(Artist.name.ilike(f"%{t}%"))) + for t in terms] + relevance = sum(case((match, 1), else_=0) for match in matches) + qs = qs.join(Artist, Release.artist_id == Artist.id).filter(or_(*matches)) + if genre: + qs = qs.join(release_genres).join(Genre).filter(Genre.slug == genre) + if style: + qs = qs.join(release_styles).join(Style).filter(Style.slug == style) + if format_: + qs = qs.join(release_formats).join(Format).filter(Format.slug == format_) + if year: + try: + qs = qs.filter(Release.year == int(year)) + except ValueError: + pass + if country: + qs = qs.filter(Release.country.ilike(country)) + return qs, relevance + + +def search_facets(base, limit=15): + """Facet values with counts inside the current result set (live search sidebar).""" + ids = db.session.query(base.order_by(None).with_entities(Release.id).distinct().subquery().c.id) + + def grouped(model, assoc, column): + rows = db.session.query(model.name, model.slug, func.count(assoc.c.release_id)) \ + .join(assoc, model.id == getattr(assoc.c, column)) \ + .filter(assoc.c.release_id.in_(ids)) \ + .group_by(model.id).order_by(func.count(assoc.c.release_id).desc(), model.name) \ + .limit(limit).all() + return [{"name": name, "slug": slug, "count": count} for name, slug, count in rows] + + countries = db.session.query(Release.country, func.count(Release.id)) \ + .filter(Release.id.in_(ids), Release.country != "") \ + .group_by(Release.country).order_by(func.count(Release.id).desc(), Release.country).limit(limit).all() + years = db.session.query(Release.year, func.count(Release.id)) \ + .filter(Release.id.in_(ids), Release.year.isnot(None)) \ + .group_by(Release.year).order_by(func.count(Release.id).desc(), Release.year.desc()).limit(limit).all() + return { + "genre": grouped(Genre, release_genres, "genre_id"), + "style": grouped(Style, release_styles, "style_id"), + "format": grouped(Format, release_formats, "format_id"), + "country": [{"name": c, "slug": c, "count": n} for c, n in countries], + "year": [{"name": str(y), "slug": str(y), "count": n} for y, n in years], + } + + +def search_releases(q, genre=None, style=None, format_=None, year=None, country=None, + sort="relevance", page=1, per_page=25, label=None, catno=None, barcode=None): + qs, relevance = release_query(q, genre=genre, style=style, format_=format_, year=year, + country=country, label=label, catno=catno, barcode=barcode) + if sort == "newest": + qs = qs.order_by(Release.added_at.desc().nullslast()) + elif sort == "year_desc": + qs = qs.order_by(Release.year.desc().nullslast()) + elif sort == "year_asc": + qs = qs.order_by(Release.year.asc().nullslast()) + elif sort == "title": + qs = qs.order_by(Release.title.asc()) + elif sort == "have": + qs = qs.order_by(Release.have_count.desc()) + elif sort == "want": + qs = qs.order_by(Release.want_count.desc()) + elif sort == "rating": + qs = qs.order_by(Release.avg_rating.desc(), Release.rating_count.desc()) + else: + if relevance is not None: + qs = qs.order_by(relevance.desc()) + qs = qs.order_by(Release.have_count.desc()) + return paginate(qs.order_by(Release.id).distinct(), page, per_page) + + +# ────────────────────────────────────────────── +# Routes — public +# ────────────────────────────────────────────── + +CAROUSEL_SIZE = 14 # the live homepage carousels carry 14 cards each + + +def most_valuable_releases(limit=CAROUSEL_SIZE): + """Releases ranked by the highest current USD asking price in the benchmark marketplace.""" + top_price = db.session.query(Listing.release_id.label("release_id"), + func.max(Listing.price).label("top")) \ + .filter(Listing.status == "For Sale", Listing.currency == "USD") \ + .group_by(Listing.release_id).subquery() + return Release.query.join(top_price, top_price.c.release_id == Release.id) \ + .order_by(top_price.c.top.desc(), Release.id).limit(limit).all() + + +def best_selling_releases(limit=CAROUSEL_SIZE): + """Releases with the most active benchmark listings, then the most wants. + + The snapshot has no sales ledger, so the ranking is synthetic marketplace demand. + """ + return Release.query.filter(Release.num_for_sale > 0) \ + .order_by(Release.num_for_sale.desc(), Release.want_count.desc(), Release.id) \ + .limit(limit).all() + + +def most_collected_releases(limit=CAROUSEL_SIZE): + return Release.query.order_by(Release.have_count.desc(), Release.id).limit(limit).all() + + +@app.route("/") +def index(): + return render_template("index.html", + best_selling=best_selling_releases(), + most_valuable=most_valuable_releases(), + most_collected=most_collected_releases()) + + +@app.route("/search") +def search(): + q = request.args.get("q", "").strip() + type_ = request.args.get("type", "release") + if type_ not in ("artist", "label"): + type_ = "release" # the header's "All" category searches releases here + sort = request.args.get("sort", "relevance") + page = request.args.get("page", 1, type=int) + filters = { + "genre": request.args.get("genre"), + "style": request.args.get("style"), + "format_": request.args.get("format"), + "year": request.args.get("year"), + "country": request.args.get("country"), + "label": request.args.get("label"), + "catno": request.args.get("catno"), + "barcode": request.args.get("barcode"), + } + results = None + artists = labels = [] + entity_pagination = None + if type_ == "artist": + query = Artist.query + terms = [t for t in re.split(r"\s+", q) if t] + if terms: + clause = or_(*[Artist.name.ilike(f"%{t}%") for t in terms]) + query = query.filter(clause) + entity_pagination = paginate(query.order_by(Artist.in_collection.desc(), Artist.id), page, 50) + artists = entity_pagination.items + elif type_ == "label": + query = Label.query + terms = [t for t in re.split(r"\s+", q) if t] + if terms: + clause = or_(*[Label.name.ilike(f"%{t}%") for t in terms]) + query = query.filter(clause) + entity_pagination = paginate(query.order_by(Label.name, Label.id), page, 50) + labels = entity_pagination.items + else: + results = search_releases(q, sort=sort, page=page, **filters) + + # Live search tabs show a count per entity type; facets count inside the results. + terms = [t for t in re.split(r"\s+", q) if t] + base, _ = release_query(q, **filters) + counts = { + "release": base.order_by(None).with_entities(Release.id).distinct().count(), + "artist": (Artist.query.filter(or_(*[Artist.name.ilike(f"%{t}%") for t in terms])) + if terms else Artist.query).count(), + "label": (Label.query.filter(or_(*[Label.name.ilike(f"%{t}%") for t in terms])) + if terms else Label.query).count(), + } + facets = search_facets(base) if type_ == "release" else {} + return render_template("search.html", + q=q, type_=type_, sort=sort, + results=results, artists=artists, labels=labels, + entity_pagination=entity_pagination, + filters=filters, counts=counts, facets=facets) + + +@app.route("/release/") +@app.route("/release//") +def release_detail(rid, slug=None): + r = Release.query.filter_by(discogs_id=rid).first() or Release.query.get_or_404(rid) + reviews = r.reviews.order_by(Review.helpful.desc(), Review.created_at.desc()).limit(20).all() + rating_hist = defaultdict(int) + for rt in r.ratings.all(): + rating_hist[rt.value] += 1 + listings = r.listings.filter_by(status="For Sale").order_by(Listing.price.asc()).limit(25).all() + other_versions = [] + if r.master_id: + other_versions = Release.query.filter(Release.master_id == r.master_id, + Release.id != r.id).limit(12).all() + related = Release.query.filter(Release.artist_id == r.artist_id, Release.id != r.id) \ + .limit(8).all() + user_state = {} + if current_user.is_authenticated: + user_state["in_collection"] = CollectionItem.query.filter_by( + user_id=current_user.id, release_id=r.id).first() is not None + user_state["in_wantlist"] = WantlistItem.query.filter_by( + user_id=current_user.id, release_id=r.id).first() is not None + ur = Rating.query.filter_by(user_id=current_user.id, release_id=r.id).first() + user_state["my_rating"] = ur.value if ur else 0 + usd_prices = [l.price for l in listings if l.currency == "USD"] + sale_stats = { + "count": len(listings), + "low": min(usd_prices) if usd_prices else None, + "high": max(usd_prices) if usd_prices else None, + } + versions_total = Release.query.filter(Release.master_id == r.master_id).count() if r.master_id else 1 + return render_template("release.html", + r=r, reviews=reviews, rating_hist=rating_hist, + listings=listings, other_versions=other_versions, + versions_total=versions_total, sale_stats=sale_stats, + related=related, user_state=user_state) + + +@app.route("/master/") +def master_detail(mid): + m = Master.query.get_or_404(mid) + versions = Release.query.filter_by(master_id=m.id).order_by(Release.year.asc().nullslast()).all() + return render_template("master.html", m=m, versions=versions) + + +@app.route("/artist/") +@app.route("/artist//") +def artist_detail(aid, slug=None): + a = Artist.query.get_or_404(aid) + sort = request.args.get("sort", "year_desc") + page = request.args.get("page", 1, type=int) + q = Release.query.filter(or_(Release.artist_id == a.id, + Release.credited_artists.any(Artist.id == a.id))) + if sort == "year_asc": + q = q.order_by(Release.year.asc().nullslast()) + elif sort == "title": + q = q.order_by(Release.title.asc()) + elif sort == "have": + q = q.order_by(Release.have_count.desc()) + else: + q = q.order_by(Release.year.desc().nullslast()) + pag = paginate(q, page, 24) + return render_template("artist.html", a=a, pag=pag, sort=sort) + + +@app.route("/label/") +@app.route("/label//") +def label_detail(lid, slug=None): + l = Label.query.get_or_404(lid) + page = request.args.get("page", 1, type=int) + q = Release.query.join(release_labels).filter(release_labels.c.label_id == lid) \ + .order_by(Release.year.desc().nullslast()) + pag = paginate(q.distinct(), page, 24) + sublabels = Label.query.filter_by(parent_label_id=lid).order_by(Label.name).all() + return render_template("label.html", l=l, pag=pag, sublabels=sublabels) + + +@app.route("/genre/") +def genre_detail(slug): + g = Genre.query.filter_by(slug=slug).first_or_404() + page = request.args.get("page", 1, type=int) + sort = request.args.get("sort", "have") + q = Release.query.join(release_genres).filter(release_genres.c.genre_id == g.id) + if sort == "year_desc": + q = q.order_by(Release.year.desc().nullslast()) + elif sort == "rating": + q = q.order_by(Release.avg_rating.desc(), Release.rating_count.desc()) + else: + q = q.order_by(Release.have_count.desc()) + pag = paginate(q.distinct(), page, 24) + styles = Style.query.join(release_styles).join(Release).join(release_genres) \ + .filter(release_genres.c.genre_id == g.id).distinct() \ + .order_by(Style.name).all() + return render_template("genre.html", g=g, pag=pag, sort=sort, styles=styles) + + +@app.route("/style/") +def style_detail(slug): + s = Style.query.filter_by(slug=slug).first_or_404() + page = request.args.get("page", 1, type=int) + q = Release.query.join(release_styles).filter(release_styles.c.style_id == s.id) \ + .order_by(Release.have_count.desc()) + pag = paginate(q.distinct(), page, 24) + return render_template("style.html", s=s, pag=pag) + + +@app.route("/format/") +def format_detail(slug): + f = Format.query.filter_by(slug=slug).first_or_404() + page = request.args.get("page", 1, type=int) + q = Release.query.join(release_formats).filter(release_formats.c.format_id == f.id) \ + .order_by(Release.have_count.desc()) + pag = paginate(q.distinct(), page, 24) + return render_template("format.html", f=f, pag=pag) + + +@app.route("/explore") +def explore(): + genres = Genre.query.order_by(Genre.name).all() + formats = Format.query.order_by(Format.name).all() + decades = sorted({(y // 10) * 10 for (y,) in + db.session.query(Release.year).filter(Release.year != None).all()}) + countries = sorted({c for (c,) in + db.session.query(Release.country).filter(Release.country != "").all()}) + return render_template("explore.html", genres=genres, formats=formats, + decades=decades, countries=countries[:50]) + + +# ────────────────────────────────────────────── +# Lists +# ────────────────────────────────────────────── + +@app.route("/lists") +def lists_index(): + page = request.args.get("page", 1, type=int) + search = request.args.get("q", "").strip() + q = List.query.filter_by(is_public=True) + if search: + q = q.filter(List.title.ilike(f"%{search}%")) + pag = paginate(q.order_by(List.created_at.desc()), page, 20) + # "Lists We Like": the fullest public lists, each with the cover of its first release. + item_counts = db.session.query(ListItem.list_id, func.count(ListItem.id).label("n")) \ + .group_by(ListItem.list_id).subquery() + featured = List.query.join(item_counts, item_counts.c.list_id == List.id) \ + .filter(List.is_public.is_(True)).order_by(item_counts.c.n.desc(), List.id).limit(5).all() + return render_template("lists.html", pag=pag, search=search, featured=featured) + + +@app.route("/list/") +def list_detail(lid): + lst = List.query.get_or_404(lid) + if not lst.is_public and (not current_user.is_authenticated or current_user.id != lst.user_id): + abort(403) + return render_template("list.html", lst=lst) + + +@app.route("/list/new", methods=["GET", "POST"]) +@login_required +def list_new(): + if request.method == "POST": + title = request.form.get("title", "").strip() + if not title: + flash("Title required.", "error") + return redirect(url_for("list_new")) + lst = List(user_id=current_user.id, + title=title[:200], + description=request.form.get("description", "")[:2000], + is_public=bool(request.form.get("is_public"))) + db.session.add(lst) + db.session.commit() + flash(f"List '{lst.title}' created.", "success") + return redirect(url_for("list_detail", lid=lst.id)) + return render_template("list_new.html") + + +@app.route("/list//add", methods=["POST"]) +@login_required +def list_add_item(lid): + lst = List.query.get_or_404(lid) + if lst.user_id != current_user.id: + abort(403) + rid = request.form.get("release_id", type=int) + comment = request.form.get("comment", "").strip()[:400] + release = None + if rid: + # The form asks for the public ID shown in release URLs. Retain the + # internal-PK fallback for existing benchmark fixtures. + release = Release.query.filter_by(discogs_id=rid).first() or Release.query.get(rid) + if release: + pos = (lst.items.count() or 0) + 1 + db.session.add(ListItem(list_id=lid, release_id=release.id, comment=comment, position=pos)) + db.session.commit() + flash("Release added.", "success") + return redirect(url_for("list_detail", lid=lid)) + + +# ────────────────────────────────────────────── +# Marketplace +# ────────────────────────────────────────────── + +@app.route("/marketplace") +def marketplace(): + page = request.args.get("page", 1, type=int) + sort = request.args.get("sort", "price_asc") + media = request.args.get("media", "") # e.g. "Near Mint (NM or M-)" + genre = request.args.get("genre", "") + format_ = request.args.get("format", "") # live: /sell/list?format=Vinyl + ships_from = request.args.get("ships_from", "") + year = request.args.get("year", type=int) + price_min = request.args.get("price_min", type=float) + price_max = request.args.get("price_max", type=float) + text = request.args.get("q", "").strip() + currency = request.args.get("currency", "USD") + if currency not in CURRENCIES: + currency = "USD" + q = Listing.query.filter_by(status="For Sale", currency=currency).join(Release) + if media: + q = q.filter(Listing.media_condition == media) + if genre: + q = q.join(release_genres, Release.id == release_genres.c.release_id) \ + .join(Genre, Genre.id == release_genres.c.genre_id) \ + .filter(Genre.slug == genre) + if format_: + q = q.join(release_formats, Release.id == release_formats.c.release_id) \ + .join(Format, Format.id == release_formats.c.format_id) \ + .filter(or_(Format.slug == format_, Format.name == format_)) + if ships_from: + q = q.filter(Listing.shipping_from == ships_from) + if year: + q = q.filter(Release.year == year) + if price_min is not None: + q = q.filter(Listing.price >= price_min) + if price_max is not None: + q = q.filter(Listing.price < price_max) + if text: + terms = [t for t in re.split(r"\s+", text) if t] + for term in terms: + q = q.filter(or_(Release.title.ilike(f"%{term}%"), Release.artist_credit.ilike(f"%{term}%"), + Release.credited_artists.any(Artist.name.ilike(f"%{term}%")))) + facets = marketplace_facets(q) + if sort == "price_desc": + q = q.order_by(Listing.price.desc()) + elif sort == "newest": + q = q.order_by(Listing.posted_at.desc()) + else: + q = q.order_by(Listing.price.asc()) + pag = paginate(q.distinct(), page, 30) + genres = Genre.query.order_by(Genre.name).all() + formats = Format.query.order_by(Format.name).all() + selected = {"currency": currency, "media": media, "genre": genre, "format": format_, + "ships_from": ships_from, "year": year, "price_min": price_min, "price_max": price_max, "q": text} + return render_template("marketplace.html", pag=pag, sort=sort, + media=media, genre=genre, format_=format_, currency=currency, + currencies=CURRENCIES, grades=GRADES, genres=genres, formats=formats, + facets=facets, selected=selected) + + +PRICE_BUCKETS = [("Less than $5", None, 5.0), ("$5 - $10", 5.0, 10.0), ("$10 - $15", 10.0, 15.0), + ("$15 - $20", 15.0, 20.0), ("$20 - $40", 20.0, 40.0), ("More than $40", 40.0, None)] + + +def marketplace_facets(base, limit=8): + """Facet counts inside the current marketplace result set (live sidebar).""" + ids = db.session.query(base.order_by(None).with_entities(Listing.id).distinct().subquery().c.id) + listing_release = db.session.query(Listing.release_id).filter(Listing.id.in_(ids)) + + def rows(query): + return [{"name": name, "count": count} for name, count in query.all()] + + ships = rows(db.session.query(Listing.shipping_from, func.count(Listing.id)) + .filter(Listing.id.in_(ids)).group_by(Listing.shipping_from) + .order_by(func.count(Listing.id).desc(), Listing.shipping_from).limit(limit)) + media = rows(db.session.query(Listing.media_condition, func.count(Listing.id)) + .filter(Listing.id.in_(ids)).group_by(Listing.media_condition) + .order_by(func.count(Listing.id).desc())) + formats = [{"name": name, "slug": slug, "count": count} for name, slug, count in + db.session.query(Format.name, Format.slug, func.count(release_formats.c.release_id)) + .join(release_formats, Format.id == release_formats.c.format_id) + .filter(release_formats.c.release_id.in_(listing_release)) + .group_by(Format.id).order_by(func.count(release_formats.c.release_id).desc()).limit(limit).all()] + genres = [{"name": name, "slug": slug, "count": count} for name, slug, count in + db.session.query(Genre.name, Genre.slug, func.count(release_genres.c.release_id)) + .join(release_genres, Genre.id == release_genres.c.genre_id) + .filter(release_genres.c.release_id.in_(listing_release)) + .group_by(Genre.id).order_by(func.count(release_genres.c.release_id).desc()).limit(limit).all()] + years = rows(db.session.query(Release.year, func.count(Listing.id)) + .join(Listing, Listing.release_id == Release.id) + .filter(Listing.id.in_(ids), Release.year.isnot(None)) + .group_by(Release.year).order_by(func.count(Listing.id).desc(), Release.year.desc()).limit(limit)) + prices = [] + for label, low, high in PRICE_BUCKETS: + bucket = base.order_by(None) + if low is not None: + bucket = bucket.filter(Listing.price >= low) + if high is not None: + bucket = bucket.filter(Listing.price < high) + prices.append({"name": label, "count": bucket.with_entities(Listing.id).distinct().count(), + "min": low, "max": high}) + return {"ships_from": ships, "media": media, "format": formats, "genre": genres, "year": years, + "price": prices} + + +@app.route("/shop/mywants") +@login_required +def shop_my_wants(): + """Live: /shop/mywants — marketplace listings for releases on the user's wantlist.""" + page = request.args.get("page", 1, type=int) + currency = request.args.get("currency", "USD") + if currency not in CURRENCIES: + currency = "USD" + q = Listing.query.filter_by(status="For Sale", currency=currency).join(Release) \ + .join(WantlistItem, WantlistItem.release_id == Release.id) \ + .filter(WantlistItem.user_id == current_user.id) \ + .order_by(Listing.price.asc()) + pag = paginate(q.distinct(), page, 30) + return render_template("marketplace.html", pag=pag, sort="price_asc", media="", genre="", + format_="", currency=currency, currencies=CURRENCIES, grades=GRADES, + genres=Genre.query.order_by(Genre.name).all(), + formats=Format.query.order_by(Format.name).all(), + heading="Shop My Wants", + intro="Copies of releases on your wantlist that benchmark sellers currently offer.") + + +@app.route("/sell", methods=["GET", "POST"]) +@login_required +def sell(): + if request.method == "POST": + rid = request.form.get("release_id", type=int) + # Allow either the public Discogs ID or the internal PK so the + # form matches the IDs visible in URLs (/release/). + release = None + if rid: + release = Release.query.filter_by(discogs_id=rid).first() or Release.query.get(rid) + if not release: + flash("Pick a valid release.", "error") + return redirect(url_for("sell")) + try: + price = Decimal(request.form.get("price", "")) + if not price.is_finite() or not Decimal("0.01") <= price <= Decimal("99999999.99"): + raise ValueError + if price != price.quantize(Decimal("0.01")): + raise ValueError + except (InvalidOperation, ValueError): + flash("Enter a price from 0.01 to 99,999,999.99 with at most two decimal places.", "error") + return redirect(url_for("sell")) + media = request.form.get("media_condition", "Very Good Plus (VG+)") + sleeve = request.form.get("sleeve_condition", "Very Good Plus (VG+)") + currency = request.form.get("currency", "USD") + shipping_from = request.form.get("shipping_from", "").strip() + if media not in GRADES or sleeve not in GRADES or currency not in CURRENCIES: + flash("Choose a listed condition and currency.", "error") + return redirect(url_for("sell")) + if not shipping_from or len(shipping_from) > 80: + flash("Enter a shipping location of at most 80 characters.", "error") + return redirect(url_for("sell")) + l = Listing(user_id=current_user.id, release_id=release.id, + media_condition=media, sleeve_condition=sleeve, + comments=request.form.get("comments", "")[:600], + price=float(price), currency=currency, shipping_from=shipping_from, + allow_offers=bool(request.form.get("allow_offers"))) + current_user.is_seller = True + db.session.add(l) + db.session.flush() + release.num_for_sale = Listing.query.filter_by(release_id=release.id, status="For Sale").count() + release.lowest_price = db.session.query(func.min(Listing.price)) \ + .filter(Listing.release_id == release.id, + Listing.status == "For Sale", + Listing.currency == "USD").scalar() + db.session.commit() + flash("Listing posted to the marketplace.", "success") + return redirect(url_for("marketplace", currency=currency, sort="newest")) + return render_template("sell.html") + + +# ────────────────────────────────────────────── +# User / collection / wantlist +# ────────────────────────────────────────────── + +@app.route("/user/") +def user_profile(username): + u = User.query.filter_by(username=username).first_or_404() + coll_n = u.collection_items.count() + want_n = u.wantlist_items.count() + reviews = u.reviews.order_by(Review.created_at.desc()).limit(5).all() + lists = u.lists.filter_by(is_public=True).order_by(List.created_at.desc()).limit(6).all() + return render_template("user.html", u=u, coll_n=coll_n, want_n=want_n, + reviews=reviews, lists=lists) + + +@app.route("/user//collection") +def user_collection(username): + u = User.query.filter_by(username=username).first_or_404() + folder = request.args.get("folder", UNFILTERED_FOLDER) + page = request.args.get("page", 1, type=int) + q = u.collection_items.join(Release) + if folder != UNFILTERED_FOLDER: + q = q.filter(CollectionItem.folder == folder) + q = q.order_by(CollectionItem.added_at.desc()) + pag = paginate(q, page, 25) + return render_template("collection.html", u=u, pag=pag, folder=folder, + folders=COLLECTION_FOLDERS, unfiltered=UNFILTERED_FOLDER) + + +@app.route("/user//wantlist") +def user_wantlist(username): + u = User.query.filter_by(username=username).first_or_404() + page = request.args.get("page", 1, type=int) + q = u.wantlist_items.join(Release).order_by(WantlistItem.added_at.desc()) + pag = paginate(q, page, 25) + return render_template("wantlist.html", u=u, pag=pag) + + +@app.route("/user//lists") +def user_lists(username): + u = User.query.filter_by(username=username).first_or_404() + query = u.lists + if not current_user.is_authenticated or current_user.id != u.id: + query = query.filter_by(is_public=True) + lists = query.order_by(List.created_at.desc()).all() + return render_template("user_lists.html", u=u, lists=lists) + + +@app.route("/user//reviews") +def user_reviews(username): + u = User.query.filter_by(username=username).first_or_404() + reviews = u.reviews.order_by(Review.created_at.desc()).all() + return render_template("user_reviews.html", u=u, reviews=reviews) + + +@app.route("/user//feedback") +def user_feedback(username): + u = User.query.filter_by(username=username).first_or_404() + return render_template("user_feedback.html", u=u) + + +@app.route("/collection/add", methods=["POST"]) +@login_required +def collection_add(): + rid = request.form.get("release_id", type=int) + r = Release.query.get(rid) if rid else None + if not r: + return return_to_page(url_for("index")) + folder = request.form.get("folder", "Uncategorized") + media = request.form.get("media_condition", "Near Mint (NM or M-)") + sleeve = request.form.get("sleeve_condition", "Near Mint (NM or M-)") + if folder not in COLLECTION_FOLDERS or media not in GRADES or sleeve not in GRADES: + flash("Choose a listed collection folder and condition.", "error") + return return_to_page(url_for("release_detail", rid=r.discogs_id or r.id)) + existing = CollectionItem.query.filter_by(user_id=current_user.id, release_id=rid).first() + if existing: + flash("Already in your collection.", "info") + else: + c = CollectionItem(user_id=current_user.id, release_id=rid, + folder=folder, media_condition=media, sleeve_condition=sleeve, + notes=request.form.get("notes", "")[:280]) + db.session.add(c) + r.have_count = (r.have_count or 0) + 1 + db.session.commit() + flash("Added to your collection.", "success") + return return_to_page(url_for("release_detail", rid=r.discogs_id or r.id)) + + +@app.route("/collection/remove", methods=["POST"]) +@login_required +def collection_remove(): + rid = request.form.get("release_id", type=int) + c = CollectionItem.query.filter_by(user_id=current_user.id, release_id=rid).first() + if c: + r = Release.query.get(rid) + if r and r.have_count > 0: + r.have_count -= 1 + db.session.delete(c) + db.session.commit() + flash("Removed from collection.", "success") + return return_to_page(url_for("user_collection", username=current_user.username)) + + +@app.route("/wantlist/add", methods=["POST"]) +@login_required +def wantlist_add(): + rid = request.form.get("release_id", type=int) + r = Release.query.get(rid) if rid else None + if not r: + return return_to_page(url_for("index")) + grade = request.form.get("min_grade", "Very Good Plus (VG+)") + if grade not in GRADES: + flash("Choose a listed minimum grade.", "error") + return return_to_page(url_for("release_detail", rid=r.discogs_id or r.id)) + if WantlistItem.query.filter_by(user_id=current_user.id, release_id=rid).first(): + flash("Already in your wantlist.", "info") + else: + w = WantlistItem(user_id=current_user.id, release_id=rid, + min_grade=grade, + notes=request.form.get("notes", "")[:280]) + db.session.add(w) + r.want_count = (r.want_count or 0) + 1 + db.session.commit() + flash("Added to your wantlist.", "success") + return return_to_page(url_for("release_detail", rid=r.discogs_id or r.id)) + + +@app.route("/wantlist/remove", methods=["POST"]) +@login_required +def wantlist_remove(): + rid = request.form.get("release_id", type=int) + w = WantlistItem.query.filter_by(user_id=current_user.id, release_id=rid).first() + if w: + r = Release.query.get(rid) + if r and r.want_count > 0: + r.want_count -= 1 + db.session.delete(w) + db.session.commit() + flash("Removed from wantlist.", "success") + return return_to_page(url_for("user_wantlist", username=current_user.username)) + + +@app.route("/rate", methods=["POST"]) +@login_required +def rate(): + rid = request.form.get("release_id", type=int) + val = request.form.get("rating", type=int) + if not (rid and val and 1 <= val <= 5): + flash("Bad rating.", "error") + return return_to_page(url_for("index")) + r = Release.query.get_or_404(rid) + rt = Rating.query.filter_by(user_id=current_user.id, release_id=rid).first() + if rt: + rt.value = val + else: + db.session.add(Rating(user_id=current_user.id, release_id=rid, value=val)) + db.session.commit() + agg = db.session.query(func.avg(Rating.value), func.count(Rating.id)) \ + .filter(Rating.release_id == rid).first() + r.avg_rating = float(agg[0] or 0.0) + r.rating_count = int(agg[1] or 0) + db.session.commit() + return return_to_page(url_for("release_detail", rid=r.discogs_id or r.id)) + + +@app.route("/review", methods=["POST"]) +@login_required +def review_post(): + rid = request.form.get("release_id", type=int) + body = request.form.get("body", "").strip() + rating = request.form.get("rating", type=int) + if not rid or not body: + flash("Review body required.", "error") + return return_to_page(url_for("index")) + r = Release.query.get_or_404(rid) + rv = Review(user_id=current_user.id, release_id=r.id, body=body[:4000], + rating=rating if rating and 1 <= rating <= 5 else None) + db.session.add(rv) + db.session.commit() + flash("Review posted.", "success") + return redirect(url_for("release_detail", rid=r.discogs_id or r.id)) + + +# ────────────────────────────────────────────── +# Forums +# ────────────────────────────────────────────── + +@app.route("/forum") +def forum_index(): + forums = Forum.query.order_by(Forum.id).all() + stats = {} + for f in forums: + thread_ids = db.session.query(Thread.id).filter(Thread.forum_id == f.id) + stats[f.id] = { + "threads": f.threads.count(), + "posts": Post.query.filter(Post.thread_id.in_(thread_ids)).count(), + "users": db.session.query(func.count(func.distinct(Post.user_id))) + .filter(Post.thread_id.in_(thread_ids)).scalar() or 0, + } + return render_template("forum_index.html", forums=forums, stats=stats, tab="topics") + + +def render_thread_list(title, query, tab, intro=None): + page = request.args.get("page", 1, type=int) + pag = paginate(query, page, 25) + return render_template("forum_threads.html", heading=title, pag=pag, tab=tab, intro=intro, + query_text=request.args.get("query", "")) + + +@app.route("/forum/recent") +def forum_recent(): + latest = db.session.query(Post.thread_id, func.max(Post.created_at).label("last")) \ + .group_by(Post.thread_id).subquery() + query = Thread.query.outerjoin(latest, latest.c.thread_id == Thread.id) \ + .order_by(func.coalesce(latest.c.last, Thread.created_at).desc()) + return render_thread_list("Recent Thread Activity", query, "recent") + + +@app.route("/forum/search") +def forum_search(): + text = request.args.get("query", "").strip() + query = Thread.query.order_by(Thread.created_at.desc()) + if text: + matching = db.session.query(Post.thread_id).filter(Post.body.ilike(f"%{text}%")) + query = query.filter(or_(Thread.title.ilike(f"%{text}%"), Thread.id.in_(matching))) + return render_thread_list(f"Forum Search: {text}" if text else "Forum Search", query, "search") + + +@app.route("/forum/posted") +@login_required +def forum_posted(): + posted = db.session.query(Post.thread_id).filter(Post.user_id == current_user.id) + query = Thread.query.filter(Thread.id.in_(posted)).order_by(Thread.created_at.desc()) + return render_thread_list("Threads You Posted In", query, "posted") + + +@app.route("/forum/started") +@login_required +def forum_started(): + query = Thread.query.filter(Thread.user_id == current_user.id).order_by(Thread.created_at.desc()) + return render_thread_list("Threads You Started", query, "started") + + +@app.route("/forum/saved") +@login_required +def forum_saved(): + return render_thread_list("Saved Threads", Thread.query.filter(False), "saved", + intro="Saving threads is not available in this offline mirror.") + + +@app.route("/forum/watched") +@login_required +def forum_watched(): + return render_thread_list("Watched Threads", Thread.query.filter(False), "watched", + intro="Thread notifications are not available in this offline mirror.") + + +@app.route("/forum/") +def forum_view(slug): + f = Forum.query.filter_by(slug=slug).first_or_404() + page = request.args.get("page", 1, type=int) + q = f.threads.order_by(Thread.pinned.desc(), Thread.created_at.desc()) + pag = paginate(q, page, 25) + return render_template("forum.html", f=f, pag=pag) + + +@app.route("/thread/") +def thread_view(tid): + t = Thread.query.get_or_404(tid) + posts = t.posts.order_by(Post.created_at.asc()).all() + poster_ids = {p.user_id for p in posts} + post_counts = dict(db.session.query(Post.user_id, func.count(Post.id)) + .filter(Post.user_id.in_(poster_ids)).group_by(Post.user_id).all()) if poster_ids else {} + return render_template("thread.html", t=t, posts=posts, post_counts=post_counts) + + +@app.route("/thread//reply", methods=["POST"]) +@login_required +def thread_reply(tid): + t = Thread.query.get_or_404(tid) + if t.locked: + flash("Thread is locked.", "error") + return redirect(url_for("thread_view", tid=tid)) + body = request.form.get("body", "").strip() + if not body: + flash("Empty post.", "error") + return redirect(url_for("thread_view", tid=tid)) + p = Post(thread_id=tid, user_id=current_user.id, body=body[:4000]) + db.session.add(p) + db.session.commit() + return redirect(url_for("thread_view", tid=tid)) + + +@app.route("/forum//new", methods=["GET", "POST"]) +@login_required +def thread_new(slug): + f = Forum.query.filter_by(slug=slug).first_or_404() + if request.method == "POST": + title = request.form.get("title", "").strip() + body = request.form.get("body", "").strip() + if not title or not body: + flash("Title and body required.", "error") + return redirect(url_for("thread_new", slug=slug)) + t = Thread(forum_id=f.id, user_id=current_user.id, title=title[:280]) + db.session.add(t); db.session.flush() + db.session.add(Post(thread_id=t.id, user_id=current_user.id, body=body[:4000])) + db.session.commit() + return redirect(url_for("thread_view", tid=t.id)) + return render_template("thread_new.html", f=f) + + +# ────────────────────────────────────────────── +# Auth +# ────────────────────────────────────────────── + +@app.route("/login", methods=["GET", "POST"]) +def login(): + if current_user.is_authenticated: + return redirect(url_for("index")) + if request.method == "POST": + ident = request.form.get("username", "").strip() + pw = request.form.get("password", "") + u = User.query.filter(or_(User.username == ident, User.email == ident.lower())).first() + if u and len(pw.encode("utf-8")) <= 72 and bcrypt.check_password_hash(u.password_hash, pw): + login_user(u, remember=bool(request.form.get("remember"))) + flash(f"Welcome back, {u.username}.", "success") + return redirect(local_return(request.args.get("next"), url_for("index"))) + flash("Invalid credentials.", "error") + return render_template("login.html") + + +@app.route("/register", methods=["GET", "POST"]) +def register(): + if current_user.is_authenticated: + return redirect(url_for("index")) + if request.method == "POST": + username = request.form.get("username", "").strip() + email = request.form.get("email", "").strip().lower() + pw = request.form.get("password", "") + valid_email = re.fullmatch(r"[^\s@]+@[^\s@]+\.[^\s@]+", email) + if not (re.fullmatch(r"[\w.-]{3,40}", username) and valid_email and len(email) <= 160 + and len(pw) >= 6 and len(pw.encode("utf-8")) <= 72): + flash("Use a 3–40 character username (letters, numbers, dots, dashes or underscores), a valid email, and a password of at least 6 characters and at most 72 UTF-8 bytes.", "error") + return redirect(url_for("register")) + if User.query.filter_by(username=username).first(): + flash("Username taken.", "error"); return redirect(url_for("register")) + if User.query.filter_by(email=email).first(): + flash("Email already registered.", "error"); return redirect(url_for("register")) + u = User(username=username, email=email, + password_hash=bcrypt.generate_password_hash(pw).decode("utf-8"), + avatar_seed=username, location=request.form.get("location", "")[:100]) + db.session.add(u) + db.session.commit() + login_user(u) + flash("Account created. Welcome to Discogs!", "success") + return redirect(url_for("index")) + return render_template("register.html") + + +@app.route("/logout", methods=["POST"]) +def logout(): + logout_user() + flash("You have been signed out.", "info") + return redirect(url_for("index")) + + +@app.route("/settings", methods=["GET", "POST"]) +@login_required +def settings(): + if request.method == "POST": + current_user.real_name = request.form.get("real_name", "")[:120] + current_user.location = request.form.get("location", "")[:100] + current_user.bio = request.form.get("bio", "")[:2000] + db.session.commit() + flash("Profile updated.", "success") + return redirect(url_for("settings")) + return render_template("settings.html") + + +# ────────────────────────────────────────────── +# Navigation destinations mirrored from the live site +# ────────────────────────────────────────────── + +@app.route("/search/advanced") +def search_advanced(): + return render_template("search_advanced.html", + genres=Genre.query.order_by(Genre.name).all(), + styles=Style.query.order_by(Style.name).all(), + formats=Format.query.order_by(Format.name).all()) + + +@app.route("/digs") +def digs(): + essentials = Release.query.filter(Release.rating_count >= 1) \ + .order_by(Release.avg_rating.desc(), Release.rating_count.desc(), Release.id).limit(8).all() + return render_template("digs.html", essentials=essentials, + most_valuable=most_valuable_releases(8), + collecting=most_collected_releases(8)) + + +@app.route("/group") +def groups(): + return render_template("group.html", forums=Forum.query.order_by(Forum.id).all()) + + +def community_rows(): + rows = [] + for user in User.query.order_by(User.id).all(): + counts = { + "reviews": Review.query.filter_by(user_id=user.id).count(), + "lists": List.query.filter_by(user_id=user.id, is_public=True).count(), + "listings": Listing.query.filter_by(user_id=user.id, status="For Sale").count(), + "collection": CollectionItem.query.filter_by(user_id=user.id).count(), + "wantlist": WantlistItem.query.filter_by(user_id=user.id).count(), + } + counts["total"] = sum(counts.values()) + counts["user"] = user + rows.append(counts) + rows.sort(key=lambda row: (-row["total"], row["user"].username)) + return rows + + +@app.route("/stats/contributors") +def contributors(): + return render_template("community_stats.html", heading="Discography Contributors", + intro="Benchmark accounts ranked by their activity in this snapshot.", + rows=community_rows()) + + +@app.route("/leaderboard") +def leaderboard(): + return render_template("community_stats.html", heading="Monthly Leaderboard", + intro="Ranking of benchmark accounts by contributions recorded in this snapshot.", + rows=community_rows()[:25]) + + +@app.route("/selling/resources") +def selling_resources(): + return render_template("selling_resources.html") + + +INFO_PAGES = { + "/about/get-started": { + "title": "Get Started", + "lead": "Discogs is a user-built database of music releases and a marketplace for buying and selling them.", + "body": ["Create a free account to catalogue your collection, keep a wantlist, build lists, rate and review releases, post in the forum and list items for sale.", + "Every feature works locally in this offline mirror; nothing is sent to discogs.com."], + "links": [("Create an account", "/register"), ("Explore the database", "/search"), ("Browse the Marketplace", "/marketplace")], + }, + "/about/about": { + "title": "What is Discogs?", + "lead": "Discogs is the largest crowd-sourced music database and marketplace.", + "body": ["This mirror contains verified release metadata and cover images captured from Discogs, plus synthetic benchmark users, collections, wantlists, lists, reviews, listings and forum posts."], + "links": [("Explore Discography", "/explore"), ("Community forum", "/forum")], + }, + "/about/features/discography": { + "title": "Discography", + "lead": "Every release page records the label, catalog number, format, country, year, genres, styles, tracklist, credits and identifiers.", + "body": ["Use search or the Explore pages to browse by genre, style or format, and open a release to read its full details."], + "links": [("Explore Discography", "/explore"), ("Advanced Search", "/search/advanced")], + }, + "/about/features/music-marketplace": { + "title": "Marketplace", + "lead": "Buy and sell physical music with sellers around the world.", + "body": ["Marketplace listings in this mirror come from benchmark seller accounts. Filter by format, media condition, genre and currency, and sort by price or listing date."], + "links": [("Browse the Marketplace", "/marketplace"), ("List Item For Sale", "/sell")], + }, + "/about/features/collection": { + "title": "Collection", + "lead": "Keep track of the records you own.", + "body": ["Add a release to your collection from its release page, choose a folder and grade, and review everything from your profile."], + "links": [("Log in", "/login")], + }, + "/about/features/wantlist": { + "title": "Wantlist", + "lead": "Track the releases you are looking for.", + "body": ["Add a release to your wantlist with a minimum grade, then use Shop My Wants to find copies offered by sellers."], + "links": [("Shop My Wants", "/shop/mywants")], + }, + "/about/features/sales-history": { + "title": "Statistics", + "lead": "Marketplace sales history and price statistics.", + "body": ["Sales history is not part of this snapshot. Current asking prices are shown on each release page and in the Marketplace."], + "links": [("Browse the Marketplace", "/marketplace")], + }, + "/about/careers": { + "title": "Careers", + "lead": "Discogs job openings.", + "body": ["No job listings are included in this offline mirror."], + "links": [], + }, + "/about/get-involved/community-advisory": { + "title": "Community Advisory", + "lead": "The Community Advisory Turntable gathers contributor feedback on database and marketplace changes.", + "body": ["In this mirror, community discussion happens in the forum."], + "links": [("Community forum", "/forum")], + }, + "/about/trust": { + "title": "Trust Center", + "lead": "How Discogs protects buyers, sellers and contributors.", + "body": ["This offline mirror stores no payment details and performs no real transactions."], + "links": [("Community Guidelines", "/guidelines/community")], + }, + "/about/app": { + "title": "Discogs App", + "lead": "Take your collection anywhere on the Discogs App.", + "body": ["App store links are disabled in this offline mirror. Use the website to manage your collection, wantlist and lists."], + "links": [("Log in", "/login")], + }, + "/developers": { + "title": "Developer API", + "lead": "Discogs offers a REST API for release, artist, label and marketplace data.", + "body": ["The API is not exposed by this offline mirror."], + "links": [], + }, + "/help": { + "title": "Help Center", + "lead": "Answers to common questions about the database, marketplace and your account.", + "body": ["Questions about this mirror can be posted in the Help & Feedback forum."], + "links": [("Help & Feedback forum", "/forum/help"), ("Seller Resource Center", "/selling/resources"), + ("Submission Guidelines", "/guidelines/submissions")], + }, + "/status": { + "title": "System Status", + "lead": "All services on this offline mirror are operating normally.", + "body": [], + "links": [], + }, + "/release/add": { + "title": "Submit a Release", + "lead": "Contributors add new releases to the database through the submission form.", + "body": ["Submissions are disabled in this offline mirror because the catalog is a verified snapshot. Existing releases can still be rated, reviewed, collected and listed for sale."], + "links": [("Submission Guidelines", "/guidelines/submissions"), ("Explore Discography", "/explore")], + }, + "/guidelines/submissions": { + "title": "Submission Guidelines", + "lead": "Overview of the rules for adding and editing releases.", + "body": ["Enter data exactly as it appears on the release, keep artist and label names consistent with existing entries, list every format detail, and cite catalog numbers and barcodes from the physical item.", + "This mirror is read-only for submissions; the guidelines are provided for reference."], + "links": [("Submit a Release", "/release/add")], + }, + "/guidelines/community": { + "title": "Community Guidelines", + "lead": "Be respectful, stay on topic and keep the database accurate.", + "body": ["Forum posts, reviews and list comments should be civil and relevant to music. Marketplace disputes belong in the Marketplace forum."], + "links": [("Community forum", "/forum")], + }, + "/legal/cookie-settings": { + "title": "Cookie Settings", + "lead": "This offline mirror sets only the session cookie needed to keep you signed in.", + "body": ["No analytics or advertising cookies are used."], + "links": [("Cookie Policy", "/legal/cookie-policy")], + }, + "/legal/cookie-policy": { + "title": "Cookie and Internet Advertising Policy", + "lead": "How cookies are used on this site.", + "body": ["Only a session cookie is used, and it never leaves this mirror."], + "links": [], + }, + "/legal/terms-of-service": { + "title": "Terms of Service", + "lead": "Terms for using the database, marketplace and community features.", + "body": ["This mirror is a benchmark environment; listings are synthetic and no purchases are executed."], + "links": [], + }, + "/legal/privacy-policy": { + "title": "Privacy Policy", + "lead": "What personal data is processed and why.", + "body": ["Account details entered here stay inside this offline environment and are reset with the benchmark database."], + "links": [], + }, + "/legal/california-privacy-notice": { + "title": "California Privacy Notice", + "lead": "Additional privacy information for California residents.", + "body": ["No personal data is sold or shared by this offline mirror."], + "links": [], + }, + "/legal/accessibility-statement": { + "title": "Accessibility Statement", + "lead": "Discogs aims to make its site usable with assistive technologies.", + "body": ["Navigation menus, forms and carousels in this mirror are keyboard accessible and labelled for screen readers."], + "links": [], + }, + "/legal/impressum": { + "title": "Impressum", + "lead": "Legal notice.", + "body": ["This is an offline research mirror of Discogs used for agent benchmarking; it is not operated by Discogs."], + "links": [], + }, +} + + +def info_page(): + page = INFO_PAGES.get(request.path) + if page is None: + abort(404) + return render_template("info.html", page=page) + + +for _index, _path in enumerate(INFO_PAGES): + app.add_url_rule(_path, endpoint=f"info_page_{_index}", view_func=info_page) + + +# ────────────────────────────────────────────── +# Health / errors +# ────────────────────────────────────────────── + +@app.route("/_health") +def health(): + return {"ok": True, "site": "discogs", + "releases": Release.query.count(), + "artists": Artist.query.count()} + + +@app.errorhandler(404) +def not_found(e): + return render_template("404.html"), 404 + + +@app.errorhandler(403) +def forbidden(e): + return render_template("403.html"), 403 + + +# ────────────────────────────────────────────── +# Boot +# ────────────────────────────────────────────── + +with app.app_context(): + db.create_all() + if os.environ.get("DISCOGS_SKIP_SEED") != "1": + try: + import sys as _sys + _sys.path.insert(0, BASE_DIR) + from seed_data import seed_database, seed_benchmark_users, seed_community + seed_database() + seed_benchmark_users() + seed_community() + except Exception as e: + print(f"[discogs] seed warning: {e}") + import traceback; traceback.print_exc() + + +if __name__ == "__main__": + port = int(os.environ.get("PORT", 5000)) + app.run(host="0.0.0.0", port=port, debug=False) diff --git a/sites/discogs/catalog.py b/sites/discogs/catalog.py new file mode 100644 index 000000000..bc10fbe0d --- /dev/null +++ b/sites/discogs/catalog.py @@ -0,0 +1,75 @@ +"""Convert full Discogs release responses without filling missing facts.""" +from datetime import datetime, timezone + + +def artist_credit(artists): + parts = [] + for artist in artists: + parts.append(artist.get("anv") or artist["name"]) + if artist.get("join"): + parts.append(artist["join"]) + return " ".join(parts).strip() + + +def normalize_release(source): + rid = source.get("id") + if type(rid) is not int or rid <= 0: + raise ValueError("A positive Discogs release ID is required") + if source.get("resource_url") != f"https://api.discogs.com/releases/{rid}": + raise ValueError(f"Release {rid}: source identity does not match") + required = ("title", "artists", "labels", "formats", "genres", "tracklist") + if any(key not in source for key in required) or not source["artists"]: + raise ValueError(f"Release {rid}: expected a full release response, not search results") + for artist in source["artists"]: + if (artist.get("id") is not None and type(artist["id"]) is not int) or not artist.get("name"): + raise ValueError(f"Release {rid}: artist identity is missing") + for label in source["labels"]: + if (label.get("id") is not None and type(label["id"]) is not int) or not label.get("name"): + raise ValueError(f"Release {rid}: label identity is missing") + formats = [] + names = [] + for item in source["formats"]: + name = item["name"] + qty = item.get("qty") + description = [f"{qty} × {name}" if qty and str(qty) != "1" else name] + description.extend(item.get("descriptions") or []) + if item.get("text"): + description.append(item["text"]) + formats.append(", ".join(description)) + names.extend([name, *(item.get("descriptions") or [])]) + tracks = [] + + def add_tracks(items, depth=0): + for item in items: + tracks.append({ + "position": item.get("position") or "", + "title": item["title"], "duration": item.get("duration") or "", + "artist_credit": artist_credit(item.get("artists") or []), + "kind": item.get("type_") or "", "depth": depth, + }) + add_tracks(item.get("sub_tracks") or [], depth + 1) + + add_tracks(source["tracklist"]) + added_at = None + if source.get("date_added"): + added_at = datetime.fromisoformat(source["date_added"].replace("Z", "+00:00")) + if added_at.tzinfo: + added_at = added_at.astimezone(timezone.utc).replace(tzinfo=None) + return { + "id": rid, "title": source["title"], "artists": source["artists"], + "artist_credit": artist_credit(source["artists"]), "labels": source["labels"], + "master_id": source.get("master_id") or None, + "year": source.get("year") or None, "country": source.get("country") or "", + "released": source.get("released") or "", "notes": source.get("notes") or "", + "data_quality": source.get("data_quality") or "", + "barcodes": [item["value"] for item in source.get("identifiers", []) + if item.get("type") == "Barcode"], + "genres": source["genres"], "styles": source.get("styles") or [], + "format_names": list(dict.fromkeys(names)), + "format_description": "; ".join(formats), + "primary_format": source["formats"][0]["name"] if source["formats"] else "", + "tracks": tracks, "added_at": added_at, + "source_community": source.get("community") or {}, + "image_urls": [url for image in source.get("images") or [] + for url in (image.get("uri"), image.get("uri150")) if url], + } diff --git a/sites/discogs/refresh_catalog.py b/sites/discogs/refresh_catalog.py new file mode 100644 index 000000000..c495e62b7 --- /dev/null +++ b/sites/discogs/refresh_catalog.py @@ -0,0 +1,279 @@ +"""Build a new seed from captured release responses; never edit the input DB. + +The capture index contains url, status, file, sha256 and observed_at for each +response. Exclusions are an explicit JSON list of source-less release IDs. +All retained releases must have verified response bytes before any DB is built. +""" +import argparse +from collections import defaultdict +import hashlib +import json +import os +from pathlib import Path +import re +import sqlite3 +import tempfile +import unicodedata + +from PIL import Image + +from catalog import normalize_release + + +def slugify(value): + value = unicodedata.normalize("NFKD", value).encode("ascii", "ignore").decode() + return re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-") or "x" + + +def load_sources(directory): + directory = Path(directory) + records = {} + for receipt in json.loads((directory / "requests.json").read_text()): + if receipt.get("status") != 200: + continue + path = (directory / receipt["file"]).resolve() + if not path.is_relative_to(directory.resolve()): + raise ValueError("Capture file is outside its directory") + raw = path.read_bytes() + if hashlib.sha256(raw).hexdigest() != receipt["sha256"]: + raise ValueError(f"Capture hash mismatch: {path.name}") + source = json.loads(raw) + normalized = normalize_release(source) + if receipt["url"] != source["resource_url"]: + raise ValueError(f"Capture URL mismatch: {path.name}") + records[normalized["id"]] = (normalized, receipt, raw.decode("utf-8")) + return records + + +def load_images(directory, records): + if directory is None: + return {} + directory = Path(directory).resolve() + images = {} + for receipt in json.loads((directory / "requests.json").read_text()): + if receipt.get("status") != 200: + continue + release_id = receipt.get("release_id") + if release_id in images: + raise ValueError(f"Duplicate verified image for release {release_id}") + if release_id not in records: + raise ValueError(f"Image has no captured release source: {release_id}") + source_url = receipt.get("source_url") + if source_url not in records[release_id][0]["image_urls"]: + raise ValueError(f"Release {release_id}: image URL mismatch") + if receipt.get("file") != f"{release_id}.jpg": + raise ValueError(f"Release {release_id}: image filename mismatch") + path = (directory / receipt["file"]).resolve() + if not path.is_relative_to(directory): + raise ValueError("Image file is outside its directory") + raw = path.read_bytes() + if hashlib.sha256(raw).hexdigest() != receipt["sha256"]: + raise ValueError(f"Image hash mismatch: {path.name}") + try: + with Image.open(path) as image: + image.verify() + with Image.open(path) as image: + size = image.size + except Exception as error: + raise ValueError(f"Invalid image: {path.name}") from error + expected_size = (receipt.get("width"), receipt.get("height")) + if None not in expected_size and size != expected_size: + raise ValueError(f"Image dimensions mismatch: {path.name}") + images[release_id] = f"images/release/{release_id}.jpg" + return images + + +def add_columns(db, table, columns): + present = {row[1] for row in db.execute(f"PRAGMA table_info({table})")} + for name, declaration in columns.items(): + if name not in present: + db.execute(f"ALTER TABLE {table} ADD COLUMN {name} {declaration}") + + +def update_catalog(db, records, excluded, images=None): + images = images or {} + add_columns(db, "releases", { + "artist_credit": "TEXT DEFAULT ''", "format_description": "TEXT DEFAULT ''", + "source_json": "TEXT DEFAULT ''", "source_sha256": "TEXT DEFAULT ''", + "source_captured_at": "TEXT DEFAULT ''", + }) + add_columns(db, "tracks", {"kind": "TEXT DEFAULT 'track'", "depth": "INTEGER DEFAULT 0"}) + for table in ("artists", "labels", "masters"): + add_columns(db, table, {"discogs_id": "INTEGER"}) + db.execute(f"CREATE UNIQUE INDEX IF NOT EXISTS ix_{table}_discogs_id ON {table}(discogs_id)") + db.execute("""CREATE TABLE IF NOT EXISTS release_artists ( + release_id INTEGER NOT NULL REFERENCES releases(id), + artist_id INTEGER NOT NULL REFERENCES artists(id), + PRIMARY KEY (release_id, artist_id))""") + removed = [row[0] for row in db.execute("SELECT id, discogs_id FROM releases") if row[1] in excluded] + db.execute("CREATE TEMP TABLE excluded_releases (id INTEGER PRIMARY KEY)") + db.executemany("INSERT INTO excluded_releases VALUES (?)", [(rid,) for rid in removed]) + for table in ("ratings", "reviews", "collection_items", "wantlist_items", "list_items", "listings"): + db.execute(f"DELETE FROM {table} WHERE release_id IN (SELECT id FROM excluded_releases)") + for table in ("release_genres", "release_styles", "release_formats", "release_labels", "release_artists", "tracks"): + db.execute(f"DELETE FROM {table}") + db.execute("UPDATE releases SET master_id=NULL") + db.execute("DELETE FROM masters") + db.execute("DELETE FROM releases WHERE id IN (SELECT id FROM excluded_releases)") + source_ids = {row[1]: row[0] for row in db.execute("SELECT id, discogs_id FROM releases")} + caches = defaultdict(dict) + + def entity(table, source): + sid, name = source.get("id"), source["name"] + cache_key = ("id", sid) if sid is not None else ("name", name) + if cache_key in caches[table]: + return caches[table][cache_key] + row = None + if sid is not None: + row = db.execute(f"SELECT id FROM {table} WHERE discogs_id=?", (sid,)).fetchone() + if row is None: + row = db.execute(f"SELECT id FROM {table} WHERE name=? AND discogs_id IS NULL ORDER BY id LIMIT 1", (name,)).fetchone() + if row: + eid = row[0] + db.execute(f"UPDATE {table} SET discogs_id=?, name=? WHERE id=?", (sid, name, eid)) + else: + slug = f"{slugify(name)[:160]}-{sid}" if sid is not None else slugify(name)[:200] + if db.execute(f"SELECT 1 FROM {table} WHERE slug=?", (slug,)).fetchone(): + slug = f"{slug[:180]}-{hashlib.sha256(name.encode()).hexdigest()[:8]}" + if table == "artists": + eid = db.execute("""INSERT INTO artists + (discogs_id,name,slug,real_name,profile,members,sites,image_path,rating,in_collection) + VALUES (?,?,?,'','','','','',0,0)""", (sid, name, slug)).lastrowid + else: + eid = db.execute("""INSERT INTO labels + (discogs_id,name,slug,profile,contact_info) VALUES (?,?,?,'','')""", (sid, name, slug)).lastrowid + caches[table][cache_key] = eid + return eid + + def named(table, name): + if name in caches[table]: + return caches[table][name] + row = db.execute(f"SELECT id FROM {table} WHERE name=?", (name,)).fetchone() + if row: + nid = row[0] + else: + slug = slugify(name) + if db.execute(f"SELECT 1 FROM {table} WHERE slug=?", (slug,)).fetchone(): + slug += "-" + hashlib.sha256(name.encode()).hexdigest()[:8] + nid = db.execute(f"INSERT INTO {table} (name,slug) VALUES (?,?)", (name, slug)).lastrowid + caches[table][name] = nid + return nid + + masters = {} + for did, (data, receipt, raw) in sorted(records.items()): + if did in excluded: + continue + artist_ids = [entity("artists", artist) for artist in data["artists"]] + aid = artist_ids[0] + mid = None + if data["master_id"]: + source_mid = data["master_id"] + if source_mid not in masters: + # The release endpoint establishes membership, not a master's + # first-release year or main edition. Leave those unknown. + masters[source_mid] = db.execute( + "INSERT INTO masters (discogs_id,title,artist_id) VALUES (?,?,?)", + (source_mid, data["title"], aid)).lastrowid + mid = masters[source_mid] + rid = source_ids.get(did) + if rid is None: + rid = db.execute("""INSERT INTO releases + (discogs_id,title,artist_id,avg_rating,rating_count,have_count,want_count,num_for_sale) + VALUES (?,?,?,0,0,0,0,0)""", (did, data["title"], aid)).lastrowid + values = { + "title": data["title"], "artist_id": aid, "master_id": mid, + "year": data["year"], "released": data["released"], "country": data["country"], + "notes": data["notes"], "barcode": " / ".join(data["barcodes"]), + "catno": " / ".join(dict.fromkeys(l.get("catno", "") for l in data["labels"])), + "data_quality": data["data_quality"], "added_at": data["added_at"], + "artist_credit": data["artist_credit"], "format_description": data["format_description"], + "source_json": raw, "source_sha256": receipt["sha256"], + "source_captured_at": receipt["observed_at"], + # Only a hash-verified image tied to this exact release response is safe. + "image_path": images.get(did, ""), + } + assignments = ",".join(f"{key}=?" for key in values) + db.execute(f"UPDATE releases SET {assignments} WHERE id=?", [*values.values(), rid]) + for artist_id in dict.fromkeys(artist_ids): + db.execute("INSERT INTO release_artists VALUES (?,?)", (rid, artist_id)) + for table, relation, key, names in ( + ("genres", "release_genres", "genre_id", data["genres"]), + ("styles", "release_styles", "style_id", data["styles"]), + ("formats", "release_formats", "format_id", data["format_names"])): + for name in dict.fromkeys(names): + db.execute(f"INSERT INTO {relation} (release_id,{key}) VALUES (?,?)", (rid, named(table, name))) + labels = defaultdict(list) + for label in data["labels"]: + labels[entity("labels", label)].append(label.get("catno") or "") + for lid, catnos in labels.items(): + db.execute("INSERT INTO release_labels (release_id,label_id,catno) VALUES (?,?,?)", + (rid, lid, " / ".join(dict.fromkeys(catnos)))) + for track in data["tracks"]: + db.execute("""INSERT INTO tracks + (release_id,position,title,duration,artist_credit,kind,depth) VALUES (?,?,?,?,?,?,?)""", + (rid, track["position"], track["title"], track["duration"], track["artist_credit"], track["kind"], track["depth"])) + # These are explicitly synthetic community state, not live Discogs counts. + db.execute("""UPDATE releases SET + have_count=(SELECT count(*) FROM collection_items WHERE release_id=releases.id), + want_count=(SELECT count(*) FROM wantlist_items WHERE release_id=releases.id), + rating_count=(SELECT count(*) FROM ratings WHERE release_id=releases.id), + avg_rating=coalesce((SELECT avg(value) FROM ratings WHERE release_id=releases.id),0), + num_for_sale=(SELECT count(*) FROM listings WHERE release_id=releases.id AND status='For Sale'), + lowest_price=(SELECT min(price) FROM listings + WHERE release_id=releases.id AND status='For Sale' AND currency='USD')""") + # A single numeric minimum across currencies has no defined meaning; the + # cached release-level minimum is explicitly USD. + db.execute("""DELETE FROM artists WHERE id NOT IN (SELECT artist_id FROM release_artists) + AND id NOT IN (SELECT artist_id FROM list_items WHERE artist_id IS NOT NULL)""") + db.execute("""DELETE FROM labels WHERE id NOT IN (SELECT label_id FROM release_labels) + AND id NOT IN (SELECT label_id FROM list_items WHERE label_id IS NOT NULL)""") + db.execute("UPDATE artists SET in_collection=(SELECT count(*) FROM release_artists WHERE artist_id=artists.id)") + for table, relation, key in (("genres", "release_genres", "genre_id"), + ("styles", "release_styles", "style_id"), + ("formats", "release_formats", "format_id")): + db.execute(f"DELETE FROM {table} WHERE id NOT IN (SELECT {key} FROM {relation})") + return {"excluded_internal_ids": removed, "releases": db.execute("SELECT count(*) FROM releases").fetchone()[0]} + + +def refresh(seed, sources, excluded, output, images=None): + seed, output = Path(seed).resolve(), Path(output).resolve() + if output.exists() or seed == output: + raise ValueError("Output must be a new file; the input seed is immutable") + records = load_sources(sources) + verified_images = load_images(images, records) + with sqlite3.connect(f"file:{seed}?mode=ro", uri=True) as original: + original_ids = {row[0] for row in original.execute("SELECT discogs_id FROM releases")} + if not set(excluded) <= original_ids: + raise ValueError("Exclusion list contains IDs absent from the input seed") + missing = original_ids - set(excluded) - records.keys() + if missing: + raise ValueError(f"Missing verified sources for {len(missing)} retained releases: {sorted(missing)[:8]}") + fd, temporary = tempfile.mkstemp(prefix=".discogs-seed-", suffix=".db", dir=output.parent) + os.close(fd) + try: + with sqlite3.connect(temporary) as candidate: + original.backup(candidate) + result = update_catalog(candidate, records, set(excluded), verified_images) + violations = candidate.execute("PRAGMA foreign_key_check").fetchall() + if violations: + raise ValueError(f"Foreign key violations: {violations[:5]}") + if candidate.execute("PRAGMA integrity_check").fetchone()[0] != "ok": + raise ValueError("SQLite integrity check failed") + candidate.commit() + os.link(temporary, output) # Fails instead of replacing an existing output. + finally: + Path(temporary).unlink(missing_ok=True) + result["seed_sha256"] = hashlib.sha256(output.read_bytes()).hexdigest() + return result + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--seed", required=True, type=Path) + parser.add_argument("--sources", required=True, type=Path) + parser.add_argument("--exclude", required=True, type=Path) + parser.add_argument("--output", required=True, type=Path) + parser.add_argument("--images", type=Path) + args = parser.parse_args() + print(json.dumps(refresh(args.seed, args.sources, json.loads(args.exclude.read_text()), + args.output, images=args.images), indent=2)) diff --git a/sites/discogs/requirements.txt b/sites/discogs/requirements.txt new file mode 100644 index 000000000..07cdb42c6 --- /dev/null +++ b/sites/discogs/requirements.txt @@ -0,0 +1,9 @@ +Flask==3.1.0 +Flask-SQLAlchemy==3.1.1 +Flask-Login==0.6.3 +Flask-WTF==1.2.2 +Flask-Bcrypt==1.0.1 +WTForms==3.2.1 +SQLAlchemy==2.0.36 +Werkzeug==3.1.3 +bcrypt==5.0.0 diff --git a/sites/discogs/seed_data.py b/sites/discogs/seed_data.py new file mode 100644 index 000000000..1f7cf9495 --- /dev/null +++ b/sites/discogs/seed_data.py @@ -0,0 +1,470 @@ +"""Idempotent benchmark community seeding for the Discogs mirror. + +The catalog comes from the verified instance_seed/discogs.db asset. +refresh_catalog.py imports complete, captured Discogs release responses; +unknown catalog facts are never generated at boot. Users, ratings, reviews, +collections and marketplace listings are synthetic benchmark state. + +Every seed function returns before writing when its data is already present, +preserving the byte-identical reset contract. +""" +import random +import re +import unicodedata +from datetime import datetime, timedelta + +from app import ( + app, db, bcrypt, + User, Artist, Label, Genre, Style, Format, Master, Release, Track, + Rating, Review, CollectionItem, WantlistItem, List, ListItem, + Listing, Forum, Thread, Post, + release_genres, release_styles, release_labels, release_formats, + COLLECTION_FOLDERS, GRADES, +) + +# Pin a reference date so re-seeding from scraped_data/ is deterministic +# (NOW would otherwise make the produced DB non-reproducible +# and break byte-identical reset across rebuilds). +NOW = datetime(2026, 5, 26, 0, 0, 0) + + +def slugify(s): + s = unicodedata.normalize("NFKD", s or "").encode("ascii", "ignore").decode() + s = re.sub(r"[^a-z0-9]+", "-", s.lower()).strip("-") + return s or "x" + + +# ────────────────────────────────────────────── +# 1. Genres / Styles / Formats taxonomy +# ────────────────────────────────────────────── + +CANONICAL_GENRES = [ + "Rock", "Electronic", "Pop", "Hip Hop", "Jazz", "Funk / Soul", "Classical", + "Reggae", "Blues", "Folk, World, & Country", "Latin", "Non-Music", + "Stage & Screen", "Brass & Military", "Children's", +] + +CANONICAL_FORMATS = [ + "Vinyl", "CD", "Cassette", "8-Track", "Reel-To-Reel", "DVD", "Box Set", + "LP", "EP", "Single", "12\"", "7\"", "10\"", + "Album", "Compilation", "Reissue", "Remastered", "Mono", "Stereo", + "Limited Edition", "Promo", "Test Pressing", "Picture Disc", "Coloured Vinyl", + "Maxi-Single", "Mini-Album", "Digital", "FLAC", "MP3", "Shellac", "File", "Acetate", +] + + +# ────────────────────────────────────────────── +# 2. Releases (catalogue) +# ────────────────────────────────────────────── + +def seed_taxonomy(): + if Genre.query.count() > 0: + return + print("[seed] taxonomy (genres/formats)") + for n in CANONICAL_GENRES: + db.session.add(Genre(name=n, slug=slugify(n))) + for n in CANONICAL_FORMATS: + db.session.add(Format(name=n, slug=slugify(n))) + db.session.commit() + + +def seed_forums(): + if Forum.query.count() > 0: + return + print("[seed] forums") + forums = [ + ("Discogs Updates", "discogs-updates", "Announcements from the Discogs team."), + ("General Discussion", "general", "Talk about anything music-related."), + ("Marketplace", "marketplace", "Trading, sellers, buyers, and orders."), + ("Database", "database", "Submissions, formatting, master releases."), + ("Vinyl Collectors", "vinyl", "All things vinyl — pressings, pressings, pressings."), + ("Genre: Jazz", "jazz", "Bebop, fusion, free, modal, you name it."), + ("Genre: Electronic", "electronic", "Techno, house, ambient, IDM, dub."), + ("Genre: Hip Hop", "hip-hop", "From boom bap to drill."), + ("Crate Diggers", "crate-diggers", "Field reports from the world's record bins."), + ("Help & Feedback", "help", "Site bugs, account questions, suggestions."), + ] + for name, slug, desc in forums: + db.session.add(Forum(name=name, slug=slug, description=desc)) + db.session.commit() + + +def seed_database(): + if Release.query.count() > 0: + return + raise RuntimeError( + "Missing verified Discogs seed. Restore instance_seed/discogs.db; " + "build a new catalog with refresh_catalog.py and captured release responses." + ) + + +# ────────────────────────────────────────────── +# 3. Benchmark users (deterministic) +# ────────────────────────────────────────────── + +BENCH_USERS = [ + ("alice_crate", "alice@test.com", "alice12345", "Alice Johnson", "Brooklyn, USA"), + ("bob_vinyl", "bob@test.com", "bob123456", "Bob Martinez", "London, UK"), + ("carol_jazz", "carol@test.com", "carol12345", "Carol Tanaka", "Tokyo, Japan"), + ("dave_techno", "dave@test.com", "dave12345", "Dave Müller", "Berlin, Germany"), +] + +EXTRA_USERS = [ + ("dustyfingers", "dusty@example.com", "dusty12345", "Marcus Reid", "Detroit, USA"), + ("modulator", "modulator@example.com", "modul12345", "Sandra Kowalski", "Warsaw, Poland"), + ("dubplate", "dubplate@example.com", "dubpl12345", "Marvin Henderson", "Kingston, Jamaica"), + ("kosmische", "kosmische@example.com", "kosmi12345", "Anke Berger", "Cologne, Germany"), + ("acidhouse303", "acid303@example.com", "acid12345", "Luis Fernandez", "Chicago, USA"), + ("freejazz", "freejazz@example.com", "freej12345", "Eric Lefèvre", "Paris, France"), + ("bossanovafan", "bossa@example.com", "bossa12345", "Renata Souza", "Rio de Janeiro, Brazil"), + ("punk77", "punk77@example.com", "punkr12345", "Eddie O'Connell", "Dublin, Ireland"), + ("synthpopgirl", "synthpop@example.com", "synth12345", "Yuki Sato", "Osaka, Japan"), + ("metalhead", "metal@example.com", "metal12345", "Hans Eriksson", "Stockholm, Sweden"), + ("kpopcollector","kpop@example.com", "kpopc12345", "Min-jun Park", "Seoul, South Korea"), + ("dubstep_dj", "dubstep@example.com", "dubst12345", "Tariq Williams", "Croydon, UK"), + ("classicalfan", "classical@example.com", "class12345", "Eleanor Whitfield", "Vienna, Austria"), + ("indiekid", "indie@example.com", "indie12345", "Sam Patel", "Manchester, UK"), + ("countrypicker","country@example.com", "count12345", "Bobby Ray", "Nashville, USA"), + ("ambientlover", "ambient@example.com", "ambie12345", "Lin Chen", "Shanghai, China"), + ("northernsoul", "soul@example.com", "soulm12345", "Jenny Walsh", "Wigan, UK"), + ("reggaeroots", "roots@example.com", "roots12345", "Marcus Brown", "Bristol, UK"), + ("vaporwave", "vapor@example.com", "vapor12345", "Hayden Cooper", "Portland, USA"), + ("krautrocker", "kraut@example.com", "kraut12345", "Klaus Werner", "Munich, Germany"), + ("hip_hop_head", "hiphop@example.com", "hipho12345", "Andre Wright", "Atlanta, USA"), + ("psyckedelic", "psych@example.com", "psych12345", "Olivia Stone", "San Francisco, USA"), + ("garagerocker", "garage@example.com", "garag12345", "Tom Beckett", "Brooklyn, USA"), + ("vinylonly", "vinyl@example.com", "vinyl12345", "Sophia Romano", "Milan, Italy"), + ("djmixer", "mixer@example.com", "mixer12345", "Karim Hassan", "Cairo, Egypt"), +] + + +def seed_benchmark_users(): + if User.query.filter_by(email="alice@test.com").first(): + return + print("[seed] users") + all_users = BENCH_USERS + EXTRA_USERS + for username, email, pw, real_name, location in all_users: + u = User( + username=username, + email=email, + password_hash=bcrypt.generate_password_hash(pw).decode("utf-8"), + real_name=real_name, + location=location, + avatar_seed=username, + bio=f"Collector and crate-digger based in {location.split(',')[0]}.", + joined_at=datetime(2014 + random.randint(0, 11), + random.randint(1, 12), + random.randint(1, 28)), + is_seller=random.random() < 0.45, + seller_rating=round(random.uniform(4.2, 5.0), 1), + seller_feedback_count=random.randint(8, 540), + ) + db.session.add(u) + db.session.commit() + + +# ────────────────────────────────────────────── +# 4. Community: ratings / reviews / collections / wantlists / lists / listings / threads +# ────────────────────────────────────────────── + +REVIEW_TEMPLATES = [ + "An absolutely essential record. The production holds up beautifully decades later.", + "Picked this up at a flea market in {city} for a song — easily one of the best buys of my collecting life.", + "{artist} at the peak of their powers. Side B in particular is a masterclass.", + "Pressing quality on this {country} edition is superb. Quiet vinyl, deep grooves.", + "A divisive record but I love it. The transition from the third track to the fourth alone is worth the price.", + "Has aged better than I expected. Holds its own next to anything released today.", + "Don't sleep on the deeper cuts. The opener is the obvious banger, but the closer is what stays with you.", + "Mastered for the format. If you have a decent system, you can hear every detail.", + "Caught {artist} live around the time this came out and they were on fire. The record captures that energy.", + "Reissue sounds noticeably brighter than my original — some will love that, some won't.", + "Mono mix is, in my opinion, the way to hear this. The stereo separation feels gimmicky in places.", + "Cover art alone earns this a spot on the shelf. The music? Pure gold.", +] + +CITIES = ["Berlin", "Tokyo", "London", "Brooklyn", "Detroit", "Lagos", "São Paulo", + "Mexico City", "Bristol", "Manchester", "Athens", "Paris"] + +LIST_TITLES = [ + "Essential {decade}s — A Personal Top 25", + "Records I Always Bring to the Listening Bar", + "Late-Night Headphones Listens", + "Underrated {genre} Gems", + "Pressings Worth Tracking Down", + "First Albums Before They Broke Through", + "Records You Should Hear at Least Once", + "The {genre} Starter Pack", + "Crate Digger's Holy Grail List", + "{decade}s — My Favourite Year by Year", + "Sunday Morning Coffee Stack", + "Field Recordings & Experiments", +] + +THREAD_TITLES = [ + ("general", [ + "What did you spin this weekend?", + "The one record you'd save from a fire", + "Best record store you've ever visited", + "Favourite album opener of all time", + "Re-discovering an old favourite — share yours", + "Records that grow on you over time", + "Hidden gems from the year you were born", + ]), + ("vinyl", [ + "Best turntable under $500 in 2026?", + "How do you clean used records?", + "Brand new pressing has a warp — what would you do?", + "Mono vs Stereo — when does it actually matter?", + "Cartridges: MM vs MC for jazz", + "Storage solutions that actually work", + "What's your dream pressing plant?", + ]), + ("jazz", [ + "Top 5 Blue Note pressings of all time", + "Modal jazz starter records", + "Free jazz: where do I begin?", + "Spiritual jazz — fed up of trying to find original pressings", + "Best fusion records that aren't cheesy", + ]), + ("electronic", [ + "Underrated Detroit techno you should hear", + "What's the heaviest dub record in your collection?", + "Ambient for working from home", + "Acid house — where it all began", + "IDM's golden age — 1995–2002 or 2009–2014?", + ]), + ("hip-hop", [ + "Best instrumental hip-hop LPs", + "Underrated boom bap producers from the 90s", + "Records that changed your life as a kid", + "Sample sources — share your finds", + ]), + ("marketplace", [ + "Seller scammed me — what now?", + "Pricing my collection — how do you decide?", + "Shipping internationally: what's the best courier?", + "Buyer offered half my asking price. Counter or no?", + ]), + ("database", [ + "How to correctly submit a misprint variant", + "Master release vs Versions — when to split?", + "Discogs guidelines for promo pressings", + "Foreign-language credits — translate or leave?", + ]), + ("crate-diggers", [ + "Found a sealed copy of {something} for $5 — pictures inside", + "Best record fair you've ever been to", + "Tips for digging at estate sales", + "Pulled this rarity out of a $1 bin", + ]), + ("help", [ + "Forgot my password", + "Wantlist not syncing to mobile app", + "How do I edit a release I submitted years ago?", + ]), +] + +POST_TEMPLATES = [ + "Great question — for me, it's {artist}'s record from {year}. Nothing else comes close.", + "Cosigned. {city} is criminally underrated for crate digging.", + "I'd lean towards mono myself. The stereo mix on most of those records was an afterthought.", + "Original pressings are getting hard to find, but the {year} reissue is a great alternative.", + "Patience is everything in this hobby. The right copy always shows up eventually.", + "Try a wet clean first, then dry brush before every play. Big difference.", + "I bought one off Discogs last month — solid grade-VG+ for around $40. They're out there.", + "Have you tried the {country} pressing? The mastering on those is noticeably different.", + "Counter at 60% and see what happens. Worst case they say no.", + "Spinning {album} right now. Such a perfect Sunday morning record.", +] + + +def seed_community(): + if Rating.query.count() > 0: + return + print("[seed] community (ratings/reviews/collections/wantlists/lists/listings/threads)") + + users = User.query.all() + releases = Release.query.all() + if not users or not releases: + return + + rng = random.Random(42) + + # 4a. Ratings: ~12 per release on average, weighted toward "popular" ones. + for r in releases: + n = rng.choices([0, 3, 6, 10, 16, 25, 40], weights=[10, 14, 18, 18, 16, 14, 10])[0] + if not n: + continue + raters = rng.sample(users, min(n, len(users))) + # Slight bias toward 4-5 stars (Discogs ratings skew high). + weights = [3, 7, 18, 36, 36] # 1..5 + for u in raters: + v = rng.choices([1, 2, 3, 4, 5], weights=weights)[0] + db.session.add(Rating(user_id=u.id, release_id=r.id, value=v, + created_at=NOW - timedelta(days=rng.randint(1, 700)))) + db.session.commit() + + # Recompute avg + count. + for r in releases: + ratings = list(r.ratings) + if ratings: + r.avg_rating = sum(rt.value for rt in ratings) / len(ratings) + r.rating_count = len(ratings) + db.session.commit() + + # 4b. Reviews: ~30% of releases get 1-3 reviews. + for r in releases: + if rng.random() > 0.30: + continue + n = rng.choices([1, 2, 3], weights=[60, 30, 10])[0] + for _ in range(n): + u = rng.choice(users) + template = rng.choice(REVIEW_TEMPLATES) + body = template.format( + city=rng.choice(CITIES), + artist=r.artist.name, + country=r.country or "Japanese", + ) + db.session.add(Review(user_id=u.id, release_id=r.id, + body=body, + rating=rng.choices([3, 4, 5], weights=[20, 40, 40])[0], + helpful=rng.randint(0, 18), + created_at=NOW - timedelta(days=rng.randint(1, 600)))) + db.session.commit() + + # 4c. Collections + wantlists per user. + for u in users: + coll_n = rng.randint(40, 200) + want_n = rng.randint(20, 80) + coll_releases = rng.sample(releases, min(coll_n, len(releases))) + want_pool = [r for r in releases if r not in coll_releases] + want_releases = rng.sample(want_pool, min(want_n, len(want_pool))) + for r in coll_releases: + db.session.add(CollectionItem( + user_id=u.id, release_id=r.id, + folder=rng.choices(COLLECTION_FOLDERS, weights=[40, 0, 30, 25, 5])[0] + if rng.random() < 0.7 else "Uncategorized", + media_condition=rng.choices(GRADES[:5], weights=[5, 35, 35, 20, 5])[0], + sleeve_condition=rng.choices(GRADES[:5], weights=[4, 30, 35, 24, 7])[0], + added_at=NOW - timedelta(days=rng.randint(1, 1500)), + )) + for r in want_releases: + db.session.add(WantlistItem( + user_id=u.id, release_id=r.id, + min_grade=rng.choice(GRADES[:6]), + added_at=NOW - timedelta(days=rng.randint(1, 700)), + )) + db.session.commit() + + # Refresh have/want counts. + for r in releases: + r.have_count = CollectionItem.query.filter_by(release_id=r.id).count() + r.want_count = WantlistItem.query.filter_by(release_id=r.id).count() + db.session.commit() + + # 4d. Lists: each user makes 0-3. + genres = [g.name for g in Genre.query.all()] + for u in users: + n = rng.choices([0, 1, 2, 3], weights=[20, 40, 30, 10])[0] + for _ in range(n): + title = rng.choice(LIST_TITLES).format( + decade=str(rng.choice([1960, 1970, 1980, 1990, 2000, 2010])), + genre=rng.choice(genres), + ) + lst = List(user_id=u.id, + title=title[:200], + description=f"Curated by {u.username}.", + is_public=rng.random() < 0.92, + created_at=NOW - timedelta(days=rng.randint(1, 800))) + db.session.add(lst); db.session.flush() + for i, rel in enumerate(rng.sample(releases, rng.randint(6, 25)), start=1): + db.session.add(ListItem(list_id=lst.id, release_id=rel.id, + comment="" if rng.random() < 0.6 else f"#{i} — a personal favourite.", + position=i)) + db.session.commit() + + # 4e. Marketplace listings: ~20% of releases get 1-4 listings. + sellers = [u for u in users if u.is_seller] + if sellers: + for r in releases: + if rng.random() > 0.22: + continue + n = rng.choices([1, 2, 3, 4], weights=[55, 25, 12, 8])[0] + for _ in range(n): + seller = rng.choice(sellers) + # Reasonable price spread by format & year scarcity. + base = rng.uniform(8.0, 60.0) + if r.year and r.year < 1970: + base *= rng.uniform(1.4, 4.0) + price = round(base, 2) + l = Listing( + user_id=seller.id, release_id=r.id, + media_condition=rng.choices(GRADES[:6], weights=[4, 25, 36, 20, 10, 5])[0], + sleeve_condition=rng.choices(GRADES[:6], weights=[4, 22, 36, 22, 11, 5])[0], + comments=rng.choice([ + "Plays beautifully, light hairlines that don't affect playback.", + "Original inner sleeve included. Stunning copy.", + "Sleeve has light ringwear. Vinyl is mint.", + "Pressed at the original plant — checked the matrix runout.", + "Test played in full. Quiet pressing throughout.", + "", + ]), + price=price, + currency=rng.choice(["USD", "USD", "USD", "EUR", "GBP", "JPY"]), + shipping_from=seller.location.split(",")[-1].strip() if seller.location else "United States", + allow_offers=rng.random() < 0.45, + posted_at=NOW - timedelta(days=rng.randint(1, 90)), + ) + db.session.add(l) + db.session.commit() + for r in releases: + n = Listing.query.filter_by(release_id=r.id, status="For Sale").count() + r.num_for_sale = n + if n: + r.lowest_price = db.session.query(db.func.min(Listing.price)) \ + .filter(Listing.release_id == r.id, + Listing.status == "For Sale").scalar() + db.session.commit() + + # 4f. Forum threads + posts. + forums = {f.slug: f for f in Forum.query.all()} + for slug, titles in THREAD_TITLES: + f = forums.get(slug) + if not f: + continue + for title in titles: + starter = rng.choice(users) + t = Thread(forum_id=f.id, user_id=starter.id, + title=title.format(something="John Coltrane")[:280], + pinned=(title.startswith("Best") and rng.random() < 0.15), + created_at=NOW - timedelta(days=rng.randint(1, 365))) + db.session.add(t); db.session.flush() + opener_body = rng.choice([ + f"Curious to hear what everyone thinks. {title.lower()}? Share your picks.", + f"Was thinking about this on the train home tonight — {title.lower()}", + f"Long-time lurker, first time poster. Tell me your stories.", + ]) + db.session.add(Post(thread_id=t.id, user_id=starter.id, + body=opener_body, + created_at=t.created_at + timedelta(minutes=1))) + # 2-12 replies. + for _ in range(rng.randint(2, 12)): + u = rng.choice(users) + some_release = rng.choice(releases) + body = rng.choice(POST_TEMPLATES).format( + artist=some_release.artist.name, + year=some_release.year or 1973, + city=rng.choice(CITIES), + country=some_release.country or "Japanese", + album=some_release.title, + ) + db.session.add(Post(thread_id=t.id, user_id=u.id, body=body, + created_at=t.created_at + timedelta( + hours=rng.randint(1, 720)))) + db.session.commit() + + print(f"[seed] community done: {Rating.query.count()} ratings, " + f"{Review.query.count()} reviews, {CollectionItem.query.count()} collection items, " + f"{WantlistItem.query.count()} wantlist items, {List.query.count()} lists, " + f"{Listing.query.count()} listings, {Thread.query.count()} threads, " + f"{Post.query.count()} posts") diff --git a/sites/discogs/static/css/.gitkeep b/sites/discogs/static/css/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/sites/discogs/static/css/site.css b/sites/discogs/static/css/site.css new file mode 100644 index 000000000..a8a0399dc --- /dev/null +++ b/sites/discogs/static/css/site.css @@ -0,0 +1,803 @@ +/* Discogs mirror — global stylesheet + Header, footer and homepage carousels follow measurements captured from + https://www.discogs.com/ on 2026-09-12 (1440px, guest). */ + +* { box-sizing: border-box; } +html, body { margin: 0; padding: 0; font-family: "Helvetica Neue", Helvetica, "Nimbus Sans", Arial, sans-serif; + font-size: 14px; color: #000; background: #fff; line-height: 20px; } +a { color: #105e9c; text-decoration: none; } +a:hover { text-decoration: underline; color: #073961; } +img { max-width: 100%; height: auto; display: block; } +.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; + overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } +button, .btn { font: inherit; border: 1px solid #ccc; background: #fafafa; color: #1a1a1a; + padding: 6px 14px; border-radius: 3px; cursor: pointer; } +.btn { display: inline-flex; align-items: center; justify-content: center; } +button:hover, .btn:hover { background: #efefef; } +.btn-primary { background: #ff8c00; border-color: #cc6f00; color: #fff; } +.btn-primary:hover { background: #e57f00; } +.btn-secondary { background: #333; border-color: #222; color: #fff; } +.btn-secondary:hover { background: #1a1a1a; } +.btn-sm { padding: 3px 9px; font-size: 0.85em; } +input[type=text], input[type=password], input[type=email], input[type=number], +select, textarea { + font: inherit; padding: 7px 9px; border: 1px solid #c4c4c4; border-radius: 3px; + background: #fff; width: 100%; max-width: 100%; +} +textarea { min-height: 110px; resize: vertical; } +label { display: block; font-weight: 600; margin: 12px 0 4px; font-size: 0.92em; color: #444; } + +/* ───────── Header (live: 60px top row + 48px secondary row, #121212) ───────── */ +.site-header { background: #121212; color: #fefefe; position: relative; z-index: 50; + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; font-size: 16px; line-height: 20px; } +.site-header a { color: inherit; text-decoration: none; } +.site-header svg { fill: currentColor; display: block; } +.hdr-top { height: 61px; } +.hdr-wrap { display: flex; align-items: center; height: 60px; padding: 0 28px; } +.hdr-logo { display: flex; align-items: center; flex: 0 0 auto; height: 60px; padding: 8px 8px 8px 0; border-radius: 8px; } +.hdr-logo img { width: 128px; height: 50px; } +.hdr-search-wrap { position: relative; flex: 0 1 792px; min-width: 200px; margin: -6px 0 0 24px; + padding: 6px; border-radius: 24px 24px 0 0; } +.hdr-search-wrap:focus-within { background: #fff; } +.hdr-search { display: flex; align-items: center; height: 38px; background: #fff; border-radius: 20px; } +.hdr-query { flex: 1 1 auto; min-width: 0; height: 38px; padding: 0 14px; border: 0; border-radius: 20px; + background: transparent; font-size: 16px; color: #000; outline: none; } +.hdr-search-btn { flex: 0 0 41px; width: 41px; height: 40px; margin: 0 12px 0 0; padding: 0; border: 0; + background: transparent; color: #313131; display: flex; align-items: center; justify-content: center; } +.hdr-search-btn svg { width: 24px; height: 24px; } +.hdr-search-btn:hover { background: transparent; } +.hdr-listbox { display: none; position: absolute; left: 0; right: 0; top: 100%; background: #fff; color: #0f0f0f; + border-radius: 0 0 24px 24px; padding: 0 0 6px; z-index: 60; } +.hdr-search-wrap:focus-within .hdr-listbox { display: block; } +.hdr-categories { display: flex; align-items: flex-end; height: 46px; padding: 0 7px; } +.hdr-category { position: relative; margin: 6px 6px 4px 7px; padding: 6px 12px; font-size: 14px; font-weight: 400; + line-height: 24px; color: #0f0f0f; cursor: pointer; white-space: nowrap; } +.hdr-category input { position: absolute; width: 1px; height: 1px; opacity: 0; } +.hdr-category:has(input:checked), .hdr-category.selected { box-shadow: 0 4px 0 -1px #000; } +.hdr-advanced { display: inline-flex; align-items: center; gap: 4px; margin: 0 13px 4px; padding: 6px 12px; + font-size: 14px; line-height: 20px; color: #0f0f0f; border-radius: 8px; } +.hdr-advanced:hover { background: #ebebeb; } +.hdr-advanced svg { width: 24px; height: 24px; } +.hdr-spacer { flex: 1 1 0; min-width: 8px; } +.hdr-user { display: flex; align-items: center; gap: 16px; height: 54px; } +.hdr-icon-btn { display: grid; place-items: center; width: 40px; height: 40px; padding: 8px; border-radius: 8px; color: #fefefe; } +.hdr-icon-btn svg { width: 24px; height: 24px; } +.hdr-icon-btn:hover { background: #333; } +.hdr-entry-btn { display: inline-flex; align-items: center; justify-content: center; height: 38px; padding: 6px 16px; + border: 1px solid #fefefe; border-radius: 50px; font-size: 14px; font-weight: 500; line-height: 24px; + letter-spacing: 0.2px; color: #fefefe; white-space: nowrap; } +.hdr-entry-btn:hover { background: #fefefe; color: #121212; text-decoration: none; } +.hdr-account-btn { gap: 8px; } +.hdr-account-btn .avatar { width: 28px; height: 28px; font-size: 13px; } +.hdr-account-btn svg { margin-left: 0; } +.hdr-logout button { width: 100%; text-align: left; padding: 16px 24px; border: 0; border-radius: 0; background: transparent; + color: #aaa; font-size: 16px; line-height: 21px; cursor: pointer; } +.hdr-logout button:hover { background: transparent; color: #fff; } +.hdr-main { display: none; } +.hdr-bars { display: flex; align-items: center; justify-content: center; width: 48px; height: 48px; padding: 8px; border: 0; + border-radius: 8px; background: transparent; color: #fefefe; } +.hdr-bars svg { width: 32px; height: 32px; } +.hdr-bars:hover { background: #333; } + +.hdr-bottom { height: 48px; } +.hdr-wrap-bottom { height: 48px; padding: 0; } +.hdr-secondary { display: flex; align-items: center; width: 100%; height: 48px; } +/* Menus are native
, so they open on click with or without JS, as the live site does. */ +.hdr-dd { position: relative; margin: 6px; } +.hdr-dd-right { margin-left: auto; } +.hdr-dd-btn { display: flex; align-items: center; height: 36px; padding: 7px 24px; border: 0; border-radius: 8px; + background: #121212; color: #fefefe; font: inherit; font-size: 16px; line-height: 17px; cursor: pointer; + white-space: nowrap; list-style: none; } +.hdr-dd-btn::-webkit-details-marker, .hdr-dd-btn::marker { display: none; content: ""; } +.hdr-dd-btn svg { width: 22px; height: 22px; margin-left: 8px; transition: transform .15s; } +.hdr-dd-btn:hover, .hdr-dd[open] > .hdr-dd-btn { background: #e5e5e5; color: #121212; } +.hdr-dd[open] > .hdr-dd-btn svg { transform: rotate(180deg); } +.hdr-dd-content { position: absolute; left: 0; top: 100%; min-width: 100%; background: #121212; + z-index: 70; box-shadow: 0 8px 20px rgba(0, 0, 0, .35); } +.hdr-dd-right .hdr-dd-content { left: auto; right: 0; } +.hdr-dd-group { list-style: none; margin: 16px 0; padding: 0; } +.hdr-dd-group a, .hdr-dd-group button { display: flex; align-items: center; padding: 16px 24px; font-size: 16px; + line-height: 21px; color: #aaa; white-space: nowrap; } +.hdr-dd-group a:hover, .hdr-dd-group button:hover { color: #fff; } + +/* Mobile drawer (live: "Main Menu" opens a 250px panel). */ +.hdr-mobile { position: absolute; top: 0; right: 0; width: 250px; max-width: 100%; min-height: 100vh; background: #121212; + z-index: 80; padding: 0 0 24px; box-shadow: -8px 0 24px rgba(0, 0, 0, .4); } +.hdr-mobile[hidden] { display: none; } +.hdr-mobile-close { display: flex; align-items: center; justify-content: flex-end; width: 100%; height: 64px; padding: 16px 24px; + border: 0; background: transparent; color: #fefefe; } +.hdr-mobile-close svg { width: 32px; height: 32px; } +.hdr-mobile .hdr-dd { margin: 0; } +.hdr-mobile .hdr-dd-btn { width: 100%; justify-content: space-between; border-radius: 0; height: 48px; padding: 12px 24px; } +.hdr-mobile .hdr-dd-content { position: static; box-shadow: none; } +.hdr-mobile .hdr-dd-group { margin: 0; } +.hdr-mobile .hdr-dd-group a { padding: 12px 40px; } +.hdr-mobile-user { display: flex; flex-direction: column; gap: 12px; padding: 20px 24px; border-top: 1px solid #333; margin-top: 12px; } +.hdr-mobile-user a { color: #aaa; } +.hdr-mobile-user .hdr-entry-btn { color: #fefefe; } + +/* Layout */ +.page { max-width: 1200px; margin: 0 auto; padding: 20px; } +.page-narrow { max-width: 760px; margin: 0 auto; padding: 24px 20px; } +.row { display: flex; gap: 24px; flex-wrap: wrap; } +.col-main { flex: 1 1 660px; min-width: 0; } +.col-side { flex: 0 0 280px; } +.row > *, .form-columns > *, .listing-row > *, .thread-row > *, .profile-head > * { min-width: 0; } +.form-columns { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; } +.table-scroll { max-width: 100%; overflow-x: auto; } +.table-scroll:focus-visible { outline: 2px solid #105e9c; outline-offset: 3px; } +.card { background: #fff; border: 1px solid #e0e0e0; border-radius: 3px; padding: 16px; + margin-bottom: 18px; } +.card h2 { margin-top: 0; } +.info-page .info-lead { font-size: 1.1em; color: #1a1a1a; } +.info-links { padding-left: 20px; } +.stats-table { width: 100%; border-collapse: collapse; } +.stats-table th, .stats-table td { padding: 8px; border-bottom: 1px solid #ececec; text-align: left; } +.stats-table th { background: #f0f0f0; font-size: 0.83em; color: #555; text-transform: uppercase; letter-spacing: 0.05em; } +.digs-tabs a { scroll-margin-top: 20px; } + +/* ───────── Homepage (live structure: hero → 3 carousels → app promo) ───────── */ +.home-page { max-width: none; margin: 0; padding: 0; } +.gs-hero { background: #000; color: #fff; } +.gs-hero-content { display: flex; gap: 10px; margin: 0 76px; padding: 48px 0 28px; min-height: 350px; } +.gs-hero-main { flex: 1 1 852px; min-width: 0; } +.gs-hero-side { flex: 0 0 402px; display: flex; flex-direction: column; gap: 10px; } +.gs-spotlight { display: block; color: #fff; } +.gs-spotlight:hover { color: #fff; text-decoration: none; } +.gs-blog-post { position: relative; display: flex; align-items: flex-end; min-height: 262px; overflow: hidden; + background-size: cover; background-position: center; } +.gs-blog-side { min-height: 126px; } +.gs-title { margin: 0 0 18px 18px; padding: 25px; background: rgba(0, 0, 0, .5); font-size: 28px; font-weight: 700; + line-height: 28px; color: #fff; max-width: calc(100% - 36px); } +.gs-title-small { margin: 0 0 10px 10px; padding: 8px 10px; font-size: 14px; line-height: 14px; } +.home-carousels { padding: 0 20px; } + +.release-carousel { position: relative; width: 100%; } +.rc-header { display: flex; padding: 20px 0 0 48px; } +.rc-title { margin: 0; font-size: 18px; line-height: 35px; border: 0; padding: 0; } +.rc-title span { position: relative; display: inline-block; padding: 3px 12px; background: #071323; color: #fff; + font-size: 20px; font-weight: 700; line-height: 35px; } +.rc-title span::after { content: ""; position: absolute; top: 0; bottom: 0; right: -19.5px; width: 20px; background: #071323; + clip-path: polygon(0 0, 100% 0, 0 100%); } +.rc-swiper { position: relative; margin: 0 48px; overflow: hidden; } +.rc-track { display: flex; list-style: none; margin: 14px 0 70px; padding: 0; transition: transform .4s ease; } +.rc-slide { flex: 0 0 auto; width: 226.5px; margin-right: 24px; } +.rc-card { display: flex; flex-direction: column; height: 100%; background: #fff; box-shadow: 0 1px 3px rgba(0, 0, 0, .2); } +.rc-cover { display: block; position: relative; box-shadow: 0 1px 3px rgba(0, 0, 0, .35); } +.rc-cover img { width: 100%; height: auto; display: block; } +.rc-body { padding: 14px 14px 20px; } +.rc-t { font-size: 15.4px; font-weight: 700; line-height: 20px; color: #000; } +.rc-a { font-size: 14px; font-weight: 400; line-height: 20px; color: #000; } +.rc-t:hover, .rc-a:hover { color: #000; text-decoration: underline; } +.rc-meta-block { margin-top: 7px; } +.rc-meta { display: block; font-size: 11.9px; line-height: 20px; color: #6c6b68; white-space: nowrap; overflow: hidden; + text-overflow: ellipsis; } +.rc-btn { position: absolute; top: 50%; width: 28px; height: 28px; margin-top: -14px; padding: 0; border: 1px solid #bdbdbd; + border-radius: 50%; background: #fff; color: #000; display: flex; align-items: center; justify-content: center; + cursor: pointer; z-index: 2; } +.rc-btn svg { width: 16px; height: 16px; fill: currentColor; } +.rc-btn:hover { background: #f5f5f5; } +.rc-btn[disabled] { opacity: .35; cursor: default; } +.rc-prev { left: 0; } +.rc-next { right: 0; } +.rc-pagination { position: absolute; left: 0; right: 0; bottom: 22px; height: 20px; text-align: center; line-height: 20px; } +.rc-bullet { display: inline-block; width: 8px; height: 8px; margin: 0 4px; padding: 0; border: 0; border-radius: 50%; + background: #000; opacity: .2; vertical-align: middle; cursor: pointer; } +.rc-bullet.active { opacity: 1; } +.rc-bullet:hover { background: #000; } + +.app-promo { min-height: 500px; margin: 0; padding: 56px 52px 0; overflow: hidden; + background: #000; color: #fff; display: flex; gap: 32px; align-items: center; } +.app-copy { flex: 1 1 58%; align-self: flex-start; padding-top: 46px; } +.app-copy h2 { border: 0; color: #fff; font-size: clamp(2rem, 4vw, 3rem); line-height: 1.08; + max-width: 720px; margin-bottom: 24px; padding: 0; } +.app-copy p { font-size: 1.3rem; line-height: 1.4; max-width: 640px; } +.app-badges { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin-top: 22px; } +.app-badges img { width: auto; height: 42px; } +.app-phone { flex: 0 1 520px; align-self: flex-end; width: min(48%, 520px); } +.home-community { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 22px; + margin: 28px 0 0; } +.community-item { padding: 9px 0; border-bottom: 1px solid #eee; } + +h1, h2, h3 { font-weight: 600; line-height: 1.2; } +h1 { font-size: 1.85em; margin: 4px 0 14px; } +h2 { font-size: 1.3em; margin: 0 0 12px; border-bottom: 1px solid #e6e6e6; padding-bottom: 8px; } +h3 { font-size: 1.1em; margin: 0 0 8px; } +.muted { color: #777; font-size: 0.9em; } +.tiny { font-size: 0.78em; color: #888; } + +/* Flash messages */ +.flashes { list-style: none; padding: 0; margin: 0 0 14px; } +.home-page .flashes { margin: 0; padding: 12px 20px 0; } +.flashes li { padding: 9px 14px; border-radius: 3px; margin-bottom: 6px; } +.flashes .success { background: #e2f4d8; border: 1px solid #aac96f; color: #305018; } +.flashes .error { background: #fbe2e2; border: 1px solid #cc8585; color: #821e1e; } +.flashes .info { background: #e3eff7; border: 1px solid #87b8d7; color: #1f4d72; } + +/* Release grid */ +.release-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(160px, 100%), 1fr)); + gap: 18px; } +.release-card { background: #fff; border: 1px solid #e6e6e6; border-radius: 3px; + padding: 8px; text-align: left; } +.release-card .thumb { aspect-ratio: 1; background: #ececec; margin-bottom: 8px; + overflow: hidden; display: block; } +.release-card .thumb img { width: 100%; height: 100%; object-fit: cover; } +.release-card .title { font-weight: 600; color: #1a1a1a; margin: 4px 0 2px; font-size: 0.95em; + overflow: hidden; text-overflow: ellipsis; display: -webkit-box; + -webkit-line-clamp: 2; -webkit-box-orient: vertical; } +.release-card .artist { color: #555; font-size: 0.86em; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.release-card .meta { color: #888; font-size: 0.76em; margin-top: 4px; } +.release-card .price { color: #ff8c00; font-weight: 600; font-size: 0.92em; } + +/* Release detail */ +.rel-hero { display: flex; gap: 28px; flex-wrap: wrap; } +.rel-hero .cover { flex: 0 1 320px; max-width: 100%; background: #fff; border: 1px solid #e0e0e0; + padding: 14px; } +.rel-hero .cover img { width: 100%; aspect-ratio: 1; object-fit: contain; } +.rel-hero .meta { flex: 1 1 260px; min-width: 0; } +.rel-hero h1 { margin: 0; } +.rel-hero .artist-line { font-size: 1.25em; color: #444; margin: 6px 0 18px; } +.rel-hero .badges { display: flex; gap: 8px; flex-wrap: wrap; margin: 12px 0; } +.badge { background: #ebebeb; padding: 3px 9px; border-radius: 3px; font-size: 0.83em; + color: #555; } +.badge-genre { background: #e6eef7; color: #1c4e87; } +.badge-style { background: #ece6f7; color: #4f2cab; } +.badge-format { background: #f7e6e6; color: #871c1c; } + +.kv { display: grid; grid-template-columns: 160px minmax(0, 1fr); row-gap: 6px; column-gap: 12px; + margin: 14px 0; font-size: 0.92em; } +.kv dt { color: #777; font-weight: 600; } +.kv dd { margin: 0; color: #1a1a1a; overflow-wrap: anywhere; } + +.rating-bar { background: #ff8c00; color: #fff; padding: 12px 16px; border-radius: 3px; + text-align: center; margin: 14px 0; } +.rating-bar .big { font-size: 1.8em; font-weight: 700; } +.rating-bar .lbl { font-size: 0.85em; opacity: 0.9; } + +.actions { display: flex; gap: 10px; flex-wrap: wrap; margin: 14px 0; } +.actions form { display: inline; } + +/* Tracklist */ +.tracklist { width: 100%; border-collapse: collapse; margin-top: 8px; } +.tracklist th { text-align: left; padding: 8px; background: #f0f0f0; border-bottom: 1px solid #ddd; + font-size: 0.83em; color: #555; text-transform: uppercase; letter-spacing: 0.05em; } +.tracklist td { padding: 8px; border-bottom: 1px solid #ececec; font-size: 0.95em; vertical-align: top; } +.tracklist td.pos { color: #888; width: 50px; } +.tracklist td.dur { color: #777; width: 70px; text-align: right; font-variant-numeric: tabular-nums; } + +/* Reviews */ +.review { padding: 14px 0; border-bottom: 1px solid #eee; } +.review:last-child { border-bottom: 0; } +.review-head { display: flex; gap: 10px; align-items: center; margin-bottom: 6px; font-size: 0.9em; color: #555; } +.review-head .stars { color: #ff8c00; font-weight: 600; } +.review-body { color: #1a1a1a; white-space: pre-wrap; } + +/* Marketplace */ +.listing-row { display: grid; grid-template-columns: 1fr 90px 110px 90px 90px; + gap: 12px; padding: 10px 0; border-bottom: 1px solid #eee; align-items: center; + font-size: 0.92em; } +.listing-row .price { color: #1a1a1a; font-weight: 600; } +.listing-row .grade { color: #555; font-size: 0.83em; } + +/* Rating histogram */ +.rh { display: grid; grid-template-columns: 26px 1fr 40px; gap: 6px; align-items: center; + font-size: 0.85em; margin: 2px 0; } +.rh .bar { background: #ececec; height: 8px; border-radius: 3px; overflow: hidden; } +.rh .bar-fill { background: #ff8c00; height: 100%; } + +/* Forum */ +.thread-row { display: grid; grid-template-columns: 1fr 80px 120px 160px; gap: 12px; + padding: 10px 0; border-bottom: 1px solid #eee; font-size: 0.92em; } +.thread-row .replies { color: #555; text-align: right; } +.post { padding: 14px 0; border-bottom: 1px solid #eee; } +.post-head { display: flex; gap: 12px; font-size: 0.88em; color: #555; margin-bottom: 6px; } +.post-body { white-space: pre-wrap; } + +/* Avatars */ +.avatar { width: 30px; height: 30px; border-radius: 50%; + display: inline-flex; align-items: center; justify-content: center; + color: #fff; font-weight: 700; font-size: 0.85em; } +.avatar-md { width: 56px; height: 56px; font-size: 1.4em; } + +/* Pagination */ +.pagination { display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; margin: 22px 0; } +.pagination a, .pagination span { padding: 6px 12px; border: 1px solid #ccc; border-radius: 3px; + background: #fff; } +.pagination .current { background: #2c2c2c; color: #fff; border-color: #2c2c2c; } + +/* Sidebar facets */ +.facet h3 { margin-top: 18px; } +.facet ul { list-style: none; padding: 0; margin: 0; } +.facet li { padding: 3px 0; font-size: 0.9em; } +.facet .active a { font-weight: 700; color: #ff8c00; } + +/* Tabs */ +.tabs { display: flex; gap: 0; border-bottom: 1px solid #ddd; margin: 14px 0 18px; } +.tabs a { padding: 9px 16px; color: #555; border-bottom: 3px solid transparent; } +.tabs a.active { color: #1a1a1a; border-bottom-color: #ff8c00; font-weight: 600; } +.tabs a:hover { text-decoration: none; color: #1a1a1a; } + +/* ───────── Footer (live: #090505, 3 columns + social/newsletter, legal banner) ───────── */ +.site-footer { background: #090505; color: #fff; font-family: "Helvetica Neue", Helvetica, "Nimbus Sans", Arial, sans-serif; + font-size: 16px; line-height: 1.5; } +.site-footer a { color: inherit; text-decoration: none; } +.site-footer svg { fill: currentColor; display: block; } +.site-footer ul { list-style: none; margin: 0; padding: 0; } +.ftr-container { max-width: 1500px; margin: 0 auto; } +.ftr-nav-wrapper { display: flex; justify-content: space-between; gap: 20px; padding: 64px 0 45px; } +.ftr-main-list { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 40px; flex: 1 1 1000px; max-width: 1000px; } +.ftr-col-header { display: block; margin: 0 12px 18px; padding: 10px 8px; font-size: 16px; font-weight: 800; line-height: 24px; color: #fff; } +.ftr-sub-list { display: grid; gap: 10px; } +.ftr-sub-list a { display: block; margin: 4px 8px; padding: 0 12px; border-radius: 8px; font-size: 16px; line-height: 32px; color: #aaa; } +.ftr-sub-list a:hover { color: #4dd1fe; } +.ftr-social { flex: 0 0 400px; max-width: 400px; margin: 0 20px; } +.ftr-social-links { display: flex; align-items: center; justify-content: space-between; gap: 30px; margin: 0 0 20px; } +.ftr-social-links a { display: block; color: #fff; border-radius: 8px; } +.ftr-social-links svg { width: 27px; height: 27px; } +.ftr-social-links svg.ftr-x { width: 20px; height: 20px; } +.ftr-social-links a:hover { color: #4dd1fe; } +.ftr-email { display: flex; flex-direction: column; gap: 12px; } +.ftr-form-title { margin: 0 0 8px; border: 0; padding: 0; font-size: 14px; font-weight: 700; line-height: 21px; color: #fff; } +.ftr-email-input { width: 100%; height: 44px; padding: 8px 12px; border: 2px inset #767676; border-radius: 4px; background: #fff; + color: #000; font-size: 16px; line-height: 24px; } +.ftr-success { color: #fff; font-size: 14px; } +.ftr-signup-btn { width: 100%; height: 42px; padding: 8px 12px; border: 1px solid #e5e5e5; border-radius: 50px; background: transparent; + color: #fff; font-size: 16px; line-height: 24px; cursor: pointer; } +.ftr-signup-btn:hover { background: #fff; color: #090505; } +.ftr-terms { margin: 10px 0; font-size: 10px; line-height: 15px; color: #fff; } +.ftr-terms a { text-decoration: underline; } +.ftr-badges { display: flex; justify-content: space-between; gap: 11px; } +.ftr-badges img { width: 197px; height: 60px; object-fit: contain; } +.ftr-banner { background: #090505; color: #aaa; } +.ftr-banner-container { max-width: 1500px; margin: 0 auto; display: grid; gap: 45px; padding: 0 20px 0; min-height: 157px; } +.ftr-banner-buttons-wrapper { justify-self: end; width: 400px; max-width: 100%; } +.ftr-lang-select-btn { padding: 10px 15px; border: 1px solid #bdbdbd; border-radius: 3px; color: #fff; font-size: 14px; line-height: 21px; } +.ftr-lang-contents { display: flex; align-items: center; justify-content: center; gap: 7px; } +.ftr-globe svg { width: 18px; height: 18px; } +.ftr-lang-contents select { width: auto; padding: 0 22px 0 0; border: 0; background: transparent; color: #fff; font-size: 14px; + line-height: 21px; cursor: pointer; } +.ftr-lang-contents select option { background: #393939; color: #fff; } +.ftr-banner-links { display: flex; flex-wrap: wrap; align-items: center; gap: 5px; margin: 14px 0; font-size: 12px; line-height: 18px; color: #aaa; } +.ftr-banner-header { padding: 10px 8px 10px 0; } +.ftr-banner-link { display: flex; align-items: center; gap: 4px; margin: 0 12px; padding: 10px 8px; border-radius: 8px; font-size: 12px; color: #aaa; } +.ftr-banner-link:hover { color: #fff; } +.ftr-banner-link svg { width: 14px; height: 14px; } + +.shortcuts-dialog { width: min(760px, 92vw); padding: 0; border: 0; border-radius: 6px; color: #333; } +.shortcuts-dialog::backdrop { background: rgba(0, 0, 0, .6); } +.shortcuts-dialog-header { display: flex; align-items: center; justify-content: space-between; padding: 16px 20px; border-bottom: 1px solid #e5e5e5; } +.shortcuts-dialog-header h2 { margin: 0; border: 0; padding: 0; font-size: 18px; font-weight: 700; } +.shortcuts-dialog-close { border: 0; background: transparent; font-size: 24px; line-height: 1; padding: 0 6px; } +.shortcuts-dialog-body { padding: 16px 20px 20px; } +.shortcuts-checkbox { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; font-size: 14px; } +.shortcuts-checkbox label { display: inline; margin: 0; font-weight: 700; color: #333; } +.shortcuts-columns { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 24px; } +.shortcuts-column h3 { font-size: 15px; font-weight: 700; margin: 12px 0 6px; } +.shortcuts-list li { display: flex; align-items: center; gap: 6px; padding: 3px 0; font-size: 14px; } +.shortcuts-list kbd { display: inline-block; min-width: 22px; padding: 1px 6px; border: 1px solid #ccc; border-radius: 3px; + background: #f5f5f5; font-family: inherit; font-size: 12px; text-align: center; } +.shortcuts-note { margin: 14px 0 0; font-size: 12px; color: #777; } + +/* Misc */ +.section-header { display: flex; justify-content: space-between; align-items: baseline; } +.section-header a { font-size: 0.85em; } +.hero-actions { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; } +.thumb-row { display: flex; gap: 10px; overflow-x: auto; padding-bottom: 4px; } +.thumb-row .release-card { min-width: 130px; max-width: 130px; } +.divider { height: 1px; background: #e0e0e0; margin: 22px 0; } +.empty { color: #888; padding: 30px; text-align: center; font-style: italic; } +.tag-cloud { display: flex; flex-wrap: wrap; gap: 6px; } +.tag-cloud a { background: #ebebeb; padding: 4px 10px; border-radius: 3px; + color: #555; font-size: 0.88em; } +.tag-cloud a:hover { background: #ff8c00; color: #fff; text-decoration: none; } + +.profile-head { display: flex; gap: 20px; align-items: flex-start; margin-bottom: 18px; } +.profile-head .avatar-md { flex: 0 0 80px; width: 80px; height: 80px; font-size: 1.9em; } +.stat-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; margin: 14px 0; } +.stat { background: #fff; border: 1px solid #e0e0e0; padding: 14px; text-align: center; } +.stat .n { font-size: 1.8em; font-weight: 700; color: #1a1a1a; } +.stat .l { color: #777; font-size: 0.85em; } + +@media (max-width: 1100px) { + .gs-hero-content { margin: 0 24px; } + .gs-hero-side { flex-basis: 300px; } +} + +@media (max-width: 900px) { + .hdr-top { height: auto; } + .hdr-wrap { flex-wrap: wrap; height: auto; padding: 8px 16px; gap: 8px 12px; } + .hdr-logo { height: 48px; padding: 4px 0; } + .hdr-logo img { width: 102px; height: 40px; } + .hdr-search-wrap { order: 3; flex: 1 1 100%; margin: 0; padding: 0; border-radius: 24px; } + .hdr-search-wrap:focus-within { background: transparent; } + .hdr-listbox { border-radius: 0 0 24px 24px; padding-top: 4px; } + .hdr-spacer { flex: 1 1 0; } + .hdr-user { height: 48px; gap: 8px; } + .hdr-user .hdr-account { display: none; } + .hdr-user .hdr-entry-btn { display: none; } + .hdr-main { display: block; } + .hdr-bottom { display: none; } + .col-side { flex: 1 1 240px; } + .gs-hero-content { flex-direction: column; margin: 0 16px; padding: 24px 0 20px; min-height: 0; } + .gs-hero-side { flex-basis: auto; flex-direction: row; } + .gs-hero-side .gs-spotlight { flex: 1 1 0; min-width: 0; } + .gs-blog-post { min-height: 220px; } + .gs-blog-side { min-height: 126px; } + .gs-title { font-size: 22px; line-height: 24px; padding: 16px; } + .gs-title-small { font-size: 13px; line-height: 14px; padding: 8px 10px; } + .app-promo { min-height: 360px; padding: 38px 34px 0; } + .app-copy { padding-top: 20px; } + .ftr-nav-wrapper { flex-direction: column; padding: 40px 0 30px; } + .ftr-main-list { max-width: none; grid-template-columns: repeat(2, minmax(0, 1fr)); } + .ftr-social { flex-basis: auto; max-width: none; margin: 30px 20px 0; } + .ftr-banner-buttons-wrapper { justify-self: start; } + .shortcuts-columns { grid-template-columns: 1fr; } +} + +@media (max-width: 600px) { + .page, .page-narrow { padding: 16px 12px; } + .form-columns, .stat-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .listing-row, .thread-row { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .listing-row > :first-child, .thread-row > :first-child { grid-column: 1 / -1; } + .thread-row .replies { text-align: left; } + .kv { grid-template-columns: 90px minmax(0, 1fr); } + .profile-head, .review-head, .post-head, .section-header { flex-wrap: wrap; } + .post-body, .review-body { overflow-wrap: anywhere; } + .tabs { overflow-x: auto; } + .hdr-categories { height: auto; flex-wrap: wrap; padding: 0 2px; } + .hdr-category { margin: 4px 2px; padding: 4px 8px; } + .home-carousels { padding: 0 8px; } + .rc-header { padding: 16px 30px 0 34px; } + .rc-title { line-height: 22px; } + .rc-title span { max-width: 100%; font-size: 15px; line-height: 22px; padding: 5px 10px; white-space: normal; } + .rc-swiper { margin: 0 34px; } + .rc-slide { width: min(70vw, 226.5px); margin-right: 14px; } + .rc-track { margin: 12px 0 56px; } + .gs-hero-side { flex-direction: column; } + .app-promo { min-height: 0; padding: 34px 22px 0; flex-direction: column; align-items: stretch; } + .app-copy { padding-top: 0; } + .app-copy h2 { font-size: 2rem; } + .app-copy p { font-size: 1.05rem; } + .app-phone { width: min(100%, 460px); margin: 0 auto; } + .home-community { grid-template-columns: 1fr; } + .ftr-main-list { grid-template-columns: 1fr; gap: 20px; } + .ftr-social { margin: 24px 12px 0; } + .ftr-badges { flex-wrap: wrap; } + .ftr-badges img { width: 160px; height: 49px; } + .ftr-banner-container { padding: 0 12px; gap: 24px; } + .ftr-banner-buttons-wrapper { width: 100%; } + .ftr-banner-link { margin: 0 4px; } +} + +/* ───────── Task-path pages (batch 2: search, release, marketplace, lists, forum, auth) ───────── */ +.search-page { max-width: 1440px; padding: 16px 20px 30px; } +.sr-h1 { font-size: 20px; font-weight: 700; margin: 6px 0 10px; } +.sr-tabs { display: flex; gap: 24px; border-bottom: 1px solid #e5e5e5; margin-bottom: 14px; font-size: 13px; } +.sr-tabs a { padding: 8px 4px; color: #333; border-bottom: 3px solid transparent; font-weight: 500; white-space: nowrap; } +.sr-tabs a.active { border-bottom-color: #111; color: #111; font-weight: 700; } +.sr-tabs a:hover { text-decoration: none; color: #111; } +.sr-layout { display: flex; gap: 32px; } +.sr-sidebar { flex: 0 0 270px; } +.sr-facet { border-bottom: 1px solid #e5e5e5; padding: 6px 0; } +.sr-facet summary { cursor: pointer; font-weight: 700; font-size: 14px; padding: 8px 0; list-style: none; display: flex; justify-content: space-between; align-items: center; } +.sr-facet summary::-webkit-details-marker { display: none; } +.sr-facet summary::after { content: "⌄"; font-size: 18px; line-height: 10px; color: #333; } +.sr-facet[open] summary::after { content: "⌃"; } +.sr-facet ul { list-style: none; margin: 0 0 6px; padding: 0; } +.sr-facet li a { display: flex; align-items: center; gap: 8px; padding: 4px 0; color: #333; font-size: 13px; } +.sr-facet li a:hover { text-decoration: none; color: #000; } +.sr-check { flex: 0 0 14px; width: 14px; height: 14px; border: 1px solid #999; border-radius: 2px; font-size: 10px; line-height: 12px; text-align: center; color: #fff; } +.sr-facet li.active .sr-check { background: #111; border-color: #111; } +.sr-facet-name { flex: 1; } +.sr-facet-count { color: #777; font-size: 12px; } +.sr-side-links { padding: 12px 0; } +.sr-side-links a { display: block; font-size: 13px; margin: 6px 0; color: #333; text-decoration: underline; } +.sr-main { flex: 1 1 auto; min-width: 0; } +.sr-toolbar { display: flex; align-items: center; gap: 16px; font-size: 13px; margin: 6px 0 12px; flex-wrap: wrap; } +.sr-filters-btn { border: 1px solid #ccc; padding: 6px 12px; border-radius: 3px; font-weight: 600; color: #333; } +.sr-sort { margin-left: auto; display: flex; align-items: center; gap: 6px; font-weight: 400; color: #333; font-size: 13px; margin-bottom: 0; } +.sr-sort select { width: auto; padding: 5px 8px; font-size: 13px; } +.sr-count { color: #333; } +.sr-pager a, .sr-pager span { padding: 0 8px; font-size: 18px; color: #333; } +.sr-pager .disabled { color: #bbb; } +.sr-marketplace { margin-left: auto; text-decoration: underline; color: #333; } +.sr-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: 24px 40px; } +.sr-card { display: block; color: #000; } +.sr-card:hover { text-decoration: none; color: #000; } +.sr-thumb { display: block; aspect-ratio: 1; overflow: hidden; background: #eee; margin-bottom: 10px; } +.sr-thumb img { width: 100%; height: 100%; object-fit: cover; } +.sr-format { display: block; font-size: 11px; letter-spacing: .1em; color: #555; text-transform: uppercase; } +.sr-title { display: block; font-weight: 700; font-size: 14px; } +.sr-artist, .sr-details, .sr-year { display: block; font-size: 13px; color: #333; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.sr-year { color: #555; } +.sr-toolbar-bottom { margin-top: 24px; } +.sr-toolbar-bottom .pagination { margin: 0 auto; } +.sr-show { border: 1px solid #ccc; padding: 6px 10px; border-radius: 3px; } +.sr-entity-list { list-style: none; padding: 0; margin: 0; } +.sr-entity-list li { padding: 10px 0; border-bottom: 1px solid #eee; } + +.release-page { max-width: 1440px; padding: 16px 76px 40px; } +.release-page a { color: #2653d9; } +.rp-layout { display: flex; gap: 28px; } +.rp-main { flex: 1 1 auto; min-width: 0; } +.rp-side { flex: 0 0 388px; } +.rp-head { display: flex; gap: 20px; margin-bottom: 14px; } +.rp-thumb { flex: 0 0 150px; } +.rp-thumb img { width: 150px; height: 150px; object-fit: cover; border: 1px solid #ddd; } +.rp-more-images { display: block; text-align: center; font-size: 12px; margin-top: 4px; } +.rp-h1 { font-size: 19px; font-weight: 700; margin: 0 0 10px; line-height: 1.3; } +.rp-info { border-collapse: collapse; font-size: 14px; } +.rp-info th { text-align: left; font-weight: 400; color: #555; padding: 1px 12px 1px 0; vertical-align: top; white-space: nowrap; } +.rp-info td { padding: 1px 0; } +.rp-section { margin: 18px 0; } +.rp-h2 { font-size: 17px; font-weight: 700; border-bottom: 1px solid #e5e5e5; padding-bottom: 6px; margin: 0 0 8px; } +.rp-view-all { font-size: 12px; font-weight: 400; margin-left: 12px; } +.rp-tracklist { width: 100%; border-collapse: collapse; } +.rp-tracklist td { padding: 3px 6px; border-bottom: 1px solid #f0f0f0; font-size: 14px; vertical-align: top; } +.rp-pos { width: 40px; color: #333; } +.rp-dur { text-align: right; color: #555; width: 60px; font-variant-numeric: tabular-nums; } +.rp-track-credit { color: #555; } +.rp-credits, .rp-identifiers, .rp-box-links { list-style: none; padding: 0; margin: 0; font-size: 14px; } +.rp-credits li { padding: 1px 0; } +.rp-credit-name { color: #2653d9; } +.rp-notes { white-space: pre-line; font-size: 14px; } +.rp-versions { width: 100%; border-collapse: collapse; font-size: 14px; } +.rp-versions th { text-align: left; font-weight: 700; padding: 6px 8px; border-bottom: 1px solid #ddd; } +.rp-versions td { padding: 8px; border-bottom: 1px solid #eee; vertical-align: top; } +.rp-version-format { color: #333; } +.rp-recs { display: flex; gap: 12px; overflow-x: auto; padding: 4px 0 8px; } +.rp-rec { flex: 0 0 152px; border: 1px solid #e5e5e5; border-radius: 6px; padding: 8px; display: flex; flex-direction: column; gap: 3px; font-size: 12px; box-shadow: 0 1px 2px rgba(0, 0, 0, .08); } +.rp-rec-cover img { width: 100%; aspect-ratio: 1; object-fit: cover; } +.rp-rec-title { font-weight: 700; font-size: 13px; color: #000; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.rp-rec-artist, .rp-rec-meta { color: #555; } +.rp-rec-format { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.rp-rec-btn { display: block; text-align: center; border: 1px solid #ccc; border-radius: 3px; padding: 5px; background: #f7f7f7; color: #111; margin-top: 4px; } +.rp-review-form summary { display: inline-flex; cursor: pointer; } +.rp-box { border-bottom: 1px solid #e5e5e5; padding: 10px 0 14px; margin-bottom: 8px; font-size: 13px; } +.rp-box-h { font-size: 14px; font-weight: 700; margin: 0 0 6px; border: 0; padding: 0; display: flex; justify-content: space-between; align-items: center; } +.rp-release-id { font-weight: 400; color: #555; font-size: 12px; } +.rp-box-action { font-weight: 400; font-size: 13px; } +.rp-forsale { display: flex; gap: 12px; } +.rp-forsale img { width: 110px; height: 70px; object-fit: cover; } +.rp-forsale > div { display: flex; flex-direction: column; font-size: 13px; } +.rp-shop-btn { display: block; margin-top: 10px; background: #00a651; color: #fff; text-align: center; padding: 7px; border-radius: 2px; font-weight: 700; font-size: 13px; } +.rp-shop-btn:hover { color: #fff; background: #008f45; text-decoration: none; } +.rp-stats { display: grid; grid-template-columns: 1fr 1fr; gap: 0 16px; font-size: 13px; } +.rp-stats dl { display: grid; grid-template-columns: auto 1fr; margin: 0; gap: 2px 8px; } +.rp-stats dt { color: #555; } +.rp-stats dd { margin: 0; } +.rp-rate-row { display: flex; justify-content: space-between; align-items: center; margin: 10px 0; padding: 8px 0; border-top: 1px solid #eee; border-bottom: 1px solid #eee; font-size: 13px; } +.rp-stars-link { color: #bbb; font-size: 18px; letter-spacing: 2px; } +.rp-rate-form { display: flex; align-items: center; gap: 6px; } +.rp-rate-form label { margin: 0; display: inline; } +.rp-rate-form select { width: auto; font-size: 13px; padding: 4px 6px; } +.rp-actions { display: flex; flex-direction: column; gap: 8px; } +.rp-action-form { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; } +.rp-action-form select { font-size: 12px; padding: 5px; } +.rp-action-form .rp-action-btn { grid-column: 1 / -1; } +.rp-action-btn { display: block; text-align: center; border: 1px solid #bbb; border-radius: 3px; background: #f5f5f5; color: #111; padding: 8px; font-weight: 700; font-size: 13px; cursor: pointer; width: 100%; } +.rp-action-btn:hover { background: #e8e8e8; color: #111; text-decoration: none; } +.rp-more { display: block; font-size: 13px; margin-top: 6px; } + +.mp-page { max-width: 1440px; padding: 12px 20px 40px; } +.mp-top { display: flex; align-items: center; gap: 20px; flex-wrap: wrap; border-bottom: 1px solid #e5e5e5; padding-bottom: 8px; margin-bottom: 12px; } +.mp-h1 { font-size: 22px; font-weight: 700; margin: 0; } +.mp-tabs { display: flex; gap: 14px; font-size: 13px; } +.mp-tabs a { color: #2653d9; } +.mp-tabs a.active { color: #111; font-weight: 700; } +.mp-search { margin-left: auto; display: flex; border: 1px solid #ccc; border-radius: 3px; } +.mp-search input { border: 0; width: 220px; padding: 6px 8px; } +.mp-search button { border: 0; background: #fff; padding: 0 8px; } +.mp-search svg { width: 16px; height: 16px; fill: #333; } +.mp-layout { display: flex; gap: 24px; } +.mp-sidebar { flex: 0 0 220px; font-size: 12px; } +.mp-selected h3, .mp-facet h3 { font-size: 13px; margin: 10px 0 4px; border-bottom: 1px solid #e5e5e5; padding-bottom: 3px; } +.mp-selected ul, .mp-facet ul { list-style: none; margin: 0; padding: 0; } +.mp-selected li { background: #eee; margin: 2px 0; padding: 3px 6px; display: flex; justify-content: space-between; gap: 6px; } +.mp-selected li a { color: #c00; font-weight: 700; } +.mp-facet li { display: flex; gap: 8px; padding: 2px 0; } +.mp-count { color: #777; min-width: 44px; text-align: right; } +.mp-facet li a { color: #2653d9; } +.mp-facet li.active a { font-weight: 700; color: #111; } +.mp-sell-btn { display: block; margin-top: 16px; text-align: center; } +.mp-main { flex: 1 1 auto; min-width: 0; } +.mp-h2 { font-size: 18px; font-weight: 700; margin: 0 0 8px; border: 0; padding: 0; } +.mp-toolbar { display: flex; align-items: center; gap: 14px; font-size: 13px; margin-bottom: 8px; flex-wrap: wrap; } +.mp-pager a { color: #2653d9; margin: 0 4px; } +.mp-pager .disabled { color: #aaa; margin: 0 4px; } +.mp-sorts { margin-left: auto; display: flex; align-items: center; gap: 10px; } +.mp-sorts label { display: flex; align-items: center; gap: 4px; margin: 0; font-weight: 400; font-size: 13px; color: #333; } +.mp-sorts select { width: auto; padding: 3px 6px; font-size: 12px; } +.mp-show { border: 1px solid #ccc; padding: 4px 8px; border-radius: 3px; } +.mp-table { width: 100%; border-collapse: collapse; font-size: 13px; } +.mp-table th { text-align: left; background: #f5f5f5; padding: 8px 10px; border-bottom: 1px solid #ddd; font-size: 12px; } +.mp-table th a { color: #2653d9; } +.mp-row td { padding: 10px; border-bottom: 1px solid #e5e5e5; vertical-align: top; } +.mp-row:nth-child(even) { background: #fafafa; } +.mp-item { display: flex; gap: 12px; } +.mp-thumb { flex: 0 0 90px; display: flex; flex-direction: column; gap: 2px; font-size: 11px; color: #333; } +.mp-thumb img { width: 90px; height: 90px; object-fit: cover; border: 1px solid #ddd; margin-bottom: 4px; } +.mp-dot { display: inline-block; width: 9px; height: 9px; margin-right: 4px; border-radius: 1px; } +.mp-dot-have { background: #00b96b; } +.mp-dot-want { background: #f3b6c2; } +.mp-desc { flex: 1; min-width: 0; } +.mp-title { color: #2653d9; font-weight: 700; font-size: 14px; } +.mp-label { color: #555; } +.mp-view { color: #2653d9; } +.mp-comments { margin: 4px 0; } +.mp-seller-name { color: #2653d9; font-weight: 700; } +.mp-stars { color: #f5a623; letter-spacing: 1px; } +.mp-price { text-align: right; white-space: nowrap; } +.mp-amount { color: #b40000; font-size: 14px; } +.mp-shipping, .mp-offers { color: #555; } +.mp-actions { text-align: right; white-space: nowrap; } +.mp-cart-btn { display: inline-block; background: #00a651; color: #fff; font-weight: 700; padding: 7px 16px; border-radius: 3px; cursor: not-allowed; font-size: 12px; } +.mp-details { display: block; margin-top: 6px; color: #2653d9; font-weight: 700; } +.mp-disclaimer { font-size: 12px; color: #777; margin-top: 14px; } + +.ls-page { max-width: 1440px; padding: 0 0 30px; } +.ls-hero { position: relative; background-size: cover; background-position: center; color: #fff; } +.ls-hero::before { content: ""; position: absolute; inset: 0; background: rgba(0, 0, 0, .72); } +.ls-hero-inner { position: relative; display: flex; justify-content: space-between; gap: 24px; padding: 28px 24px; } +.ls-hero h1 { font-size: 34px; margin: 0 0 8px; font-weight: 700; } +.ls-hero p { max-width: 620px; font-size: 14px; margin: 0 0 8px; } +.ls-hero-link { color: #4dd1fe; font-weight: 700; font-size: 14px; } +.ls-hero-actions { display: flex; flex-direction: column; gap: 8px; align-self: center; } +.ls-btn-green { display: inline-block; background: #00a651; color: #fff; font-weight: 700; padding: 8px 18px; border-radius: 3px; font-size: 13px; text-align: center; } +.ls-btn-green:hover { color: #fff; background: #008f45; text-decoration: none; } +.ls-featured { padding: 20px 24px 8px; } +.ls-featured h2 { font-size: 18px; border: 0; padding: 0; margin: 0 0 4px; } +.ls-featured-grid { display: flex; gap: 24px; flex-wrap: wrap; margin-top: 16px; } +.ls-featured-card { display: block; width: 210px; text-align: center; color: #111; font-weight: 700; font-size: 13px; } +.ls-featured-cover { display: block; width: 210px; height: 140px; background: #eee; overflow: hidden; margin-bottom: 8px; } +.ls-featured-cover img { width: 100%; height: 100%; object-fit: cover; } +.ls-search { margin: 20px 24px 16px; display: flex; max-width: 480px; border: 1px solid #ccc; border-radius: 24px; overflow: hidden; } +.ls-search input { border: 0; padding: 12px 16px; flex: 1; border-radius: 0; } +.ls-search button { border: 0; background: #fff; padding: 0 14px; } +.ls-search svg { width: 18px; height: 18px; fill: #333; } +.ls-page .table-scroll { margin: 0 24px; } +.ls-table { width: 100%; border-collapse: collapse; font-size: 14px; } +.ls-table th { text-align: left; background: #f2f2f2; padding: 8px 12px; font-weight: 700; } +.ls-table td { padding: 10px 12px; vertical-align: middle; border-bottom: 1px solid #f0f0f0; } +.ls-table tr:nth-child(even) td { background: #fafafa; } +.ls-table a { color: #2653d9; } +.ls-col-list { width: 200px; } +.ls-col-user { width: 260px; white-space: nowrap; } +.ls-col-user .avatar { vertical-align: middle; margin-right: 6px; } +.ls-col-created { width: 200px; white-space: nowrap; } +.ls-footer { display: flex; align-items: center; gap: 14px; padding: 16px 24px; font-size: 13px; } +.ls-pager a, .ls-pager span { display: inline-block; border: 1px solid #ccc; padding: 6px 10px; margin-right: 4px; color: #333; } +.ls-show { margin-left: auto; border: 1px solid #ccc; padding: 6px 10px; } + +.ls-detail-page { max-width: 1440px; padding: 0 0 30px; } +.ub-band { background: #3a3a3a; color: #fff; } +.ub-inner { display: flex; justify-content: space-between; align-items: flex-start; padding: 24px 48px 0; } +.ub-avatar .avatar-md { width: 96px; height: 96px; font-size: 2.2em; border: 2px solid #fff; margin-bottom: -24px; position: relative; z-index: 1; } +.ub-actions { display: flex; gap: 6px; } +.ub-btn { background: #eee; color: #111; font-size: 12px; padding: 5px 8px; border-radius: 2px; font-weight: 600; } +.ub-tabs-row { background: #111; display: flex; align-items: stretch; padding-left: 160px; flex-wrap: wrap; } +.ub-name h2 { margin: 0; font-size: 16px; color: #fff; border: 0; padding: 12px 24px 12px 0; } +.ub-tabs { display: flex; margin-left: auto; flex-wrap: wrap; } +.ub-tabs a { color: #fff; font-size: 12px; font-weight: 700; padding: 12px 10px; border-left: 1px solid #333; } +.ub-tabs a.active { background: #3a3a3a; } +.ls-detail-layout { display: flex; gap: 40px; padding: 20px 48px; } +.ls-detail-main { flex: 1 1 auto; min-width: 0; } +.ls-detail-side { flex: 0 0 260px; font-size: 13px; } +.ls-side-h { font-size: 14px; border-bottom: 1px solid #e5e5e5; padding-bottom: 4px; margin: 20px 0 8px; } +.ls-detail-title { font-size: 20px; font-weight: 700; margin: 0 0 6px; } +.ls-detail-by { font-size: 13px; display: flex; align-items: center; gap: 6px; } +.ls-detail-by a { color: #2653d9; } +.ls-detail-desc { font-size: 13px; margin: 10px 0 18px; white-space: pre-line; } +.ls-items { list-style: none; padding: 0; margin: 0; } +.ls-item { display: flex; gap: 12px; padding: 12px 0; border-bottom: 1px solid #eee; align-items: flex-start; } +.ls-item-num { flex: 0 0 28px; font-weight: 700; color: #555; font-size: 14px; padding-top: 4px; } +.ls-item-cover img { width: 80px; height: 80px; object-fit: cover; border: 1px solid #ddd; } +.ls-item-title { color: #2653d9; font-weight: 700; font-size: 14px; } +.ls-item-meta { font-size: 12px; color: #555; margin: 2px 0; } +.ls-item-comment { font-size: 13px; margin-top: 4px; } +.ls-item-empty { text-align: center; padding: 40px; color: #555; } + +.fr-page { max-width: 1440px; padding: 0 0 30px; } +.fr-band { background: #1c1c1c; color: #fff; padding: 22px 76px 18px; } +.fr-band-inner { display: flex; justify-content: space-between; gap: 24px; align-items: flex-start; } +.fr-crumbs { font-size: 12px; color: #bbb; margin-bottom: 4px; } +.fr-crumbs a { color: #bbb; } +.fr-h1 { font-size: 26px; font-weight: 700; margin: 0 0 8px; display: flex; align-items: center; gap: 10px; } +.fr-icon { font-size: 28px; } +.fr-intro { font-size: 13px; margin: 0; } +.fr-band-extra { font-size: 12px; display: flex; flex-direction: column; gap: 8px; margin-top: 6px; } +.fr-started a { color: #4dd1fe; } +.fr-band-buttons { display: flex; gap: 6px; } +.fr-btn-dark { background: #333; color: #fff; border: 1px solid #555; font-size: 11px; padding: 4px 8px; border-radius: 2px; cursor: not-allowed; } +.fr-btn-dark:hover { background: #333; } +.fr-band-tools { display: flex; flex-direction: column; align-items: flex-end; gap: 8px; } +.fr-search { display: flex; border: 1px solid #ccc; border-radius: 3px; background: #fff; } +.fr-search input { border: 0; width: 250px; padding: 7px 10px; font-size: 14px; border-radius: 0; } +.fr-search button { border: 0; background: #fff; padding: 0 10px; } +.fr-search svg { width: 18px; height: 18px; fill: #333; } +.fr-btn-green { background: #00a651; color: #fff; font-weight: 700; font-size: 12px; padding: 6px 12px; border-radius: 2px; } +.fr-btn-green:hover { color: #fff; background: #008f45; text-decoration: none; } +.fr-tabs { display: flex; background: #000; padding: 0 76px; overflow-x: auto; } +.fr-tabs a { color: #fff; font-size: 13px; font-weight: 600; padding: 16px 16px; border-right: 1px solid #333; white-space: nowrap; } +.fr-tabs a.active { background: #1c1c1c; } +.fr-tabs a:hover { text-decoration: none; background: #1c1c1c; } +.fr-boards { padding: 20px 76px; } +.fr-board { display: flex; gap: 24px; align-items: flex-start; padding: 20px 0; border-bottom: 1px solid #e5e5e5; } +.fr-board-icon { flex: 0 0 70px; } +.fr-board-icon svg { width: 50px; height: 50px; fill: #ccc; } +.fr-board-body { flex: 1; min-width: 0; } +.fr-board-body h2 { font-size: 17px; margin: 0 0 4px; border: 0; padding: 0; } +.fr-board-body a { color: #2653d9; font-weight: 700; } +.fr-board-body p { margin: 0; font-size: 13px; } +.fr-board-stats { flex: 0 0 160px; display: flex; flex-direction: column; font-size: 12px; color: #555; } +.fr-threads { padding: 16px 76px; } +.fr-thread-row { display: grid; grid-template-columns: 1fr 70px 160px 160px 140px; gap: 12px; padding: 10px 0; border-bottom: 1px solid #eee; font-size: 13px; } +.fr-thread-row-board { grid-template-columns: 1fr 70px 160px 140px; } +.fr-thread-head { font-weight: 700; background: #f5f5f5; padding: 8px; } +.fr-replies { text-align: right; } +.fr-thread-row a { color: #2653d9; } +.fr-thread-layout { display: flex; gap: 24px; padding: 16px 76px; } +.fr-posts { flex: 1 1 auto; min-width: 0; max-width: 880px; } +.fr-thread-side { flex: 0 0 260px; padding-left: 24px; } +.fr-thread-side h3 { font-size: 16px; margin: 0 0 6px; } +.fr-thread-side a { color: #2653d9; font-size: 13px; } +.fr-post { display: flex; gap: 20px; padding: 16px 0; border-bottom: 1px solid #e5e5e5; } +.fr-post-side { flex: 0 0 60px; display: flex; flex-direction: column; align-items: center; gap: 6px; font-size: 12px; color: #555; } +.fr-post-side .avatar-md { width: 50px; height: 50px; font-size: 1.2em; border-radius: 4px; } +.fr-post-main { flex: 1; min-width: 0; } +.fr-post-head { font-size: 13px; margin: 0 0 6px; border: 0; padding: 0; } +.fr-post-head a { color: #2653d9; font-weight: 700; } +.fr-post-time { font-weight: 700; color: #111; margin-left: 4px; } +.fr-post-body { font-size: 14px; white-space: pre-wrap; line-height: 1.45; overflow-wrap: anywhere; } +.fr-permalink { display: inline-block; margin-top: 8px; font-size: 12px; color: #555; } +.fr-post-range { padding: 10px 0; font-size: 12px; color: #555; border-bottom: 1px solid #e5e5e5; } +.fr-reply { margin-top: 16px; } +.fr-login-note { margin-top: 16px; font-size: 13px; display: flex; align-items: center; gap: 10px; } + +.auth-page { background: #1c1c1c; min-height: calc(100vh - 109px); padding: 60px 20px; display: flex; flex-direction: column; align-items: center; } +.auth-page .flashes { width: 400px; max-width: 100%; } +.auth-card { background: #fff; width: 400px; max-width: 100%; border-radius: 6px; padding: 40px; box-shadow: 0 12px 40px rgba(0, 0, 0, .4); } +.auth-logo { width: 120px; height: 47px; margin: 0 auto 24px; filter: invert(1); } +.auth-h1 { text-align: center; font-size: 24px; font-weight: 400; margin: 0 0 8px; } +.auth-sub { text-align: center; font-size: 14px; color: #333; margin: 0 0 24px; } +.auth-field { position: relative; margin-bottom: 16px; } +.auth-field input { width: 100%; height: 52px; padding: 18px 44px 6px 14px; border: 1px solid #c2c8d0; border-radius: 3px; font-size: 16px; } +.auth-field label { position: absolute; left: 12px; top: 16px; font-size: 16px; color: #65676b; font-weight: 400; margin: 0; padding: 0 4px; background: #fff; pointer-events: none; transition: all .15s; } +.auth-field label span { color: #65676b; } +.auth-field input:focus + label, .auth-field input:not(:placeholder-shown) + label { top: -8px; font-size: 13px; color: #2653d9; } +.auth-field input:focus { outline: none; border-color: #2653d9; box-shadow: 0 0 0 1px #2653d9; } +.auth-eye { position: absolute; right: 10px; top: 14px; border: 0; background: transparent; font-size: 16px; padding: 2px 6px; } +.auth-eye:hover { background: transparent; } +.auth-forgot { display: inline-block; font-size: 14px; font-weight: 700; color: #2653d9; text-decoration: underline; margin: 0 0 20px; } +.auth-continue { width: 100%; height: 52px; border: 0; border-radius: 3px; background: #333; color: #fff; font-size: 16px; cursor: pointer; } +.auth-continue:hover { background: #111; } +.auth-alt { font-size: 14px; margin: 16px 0; } +.auth-alt a { color: #2653d9; font-weight: 700; text-decoration: underline; } +.auth-or { display: flex; align-items: center; gap: 12px; font-size: 12px; color: #333; margin: 8px 0 20px; } +.auth-or::before, .auth-or::after { content: ""; flex: 1; height: 1px; background: #c2c8d0; } +.auth-social { display: flex; flex-direction: column; gap: 8px; } +.auth-social button { display: flex; align-items: center; gap: 16px; height: 52px; border: 1px solid #c2c8d0; border-radius: 3px; background: #fff; font-size: 16px; color: #333; padding: 0 16px; cursor: not-allowed; } +.auth-social button:hover { background: #fff; } +.auth-social-icon { width: 20px; font-weight: 700; text-align: center; } + +@media (max-width: 900px) { + .sr-layout, .rp-layout, .mp-layout, .ls-detail-layout, .fr-thread-layout { flex-direction: column; } + .sr-sidebar, .rp-side, .mp-sidebar, .ls-detail-side, .fr-thread-side { flex-basis: auto; padding-left: 0; } + .release-page, .fr-band, .fr-tabs, .fr-boards, .fr-threads, .fr-thread-layout { padding-left: 16px; padding-right: 16px; } + .fr-band-inner { flex-direction: column; } + .fr-band-tools { align-items: flex-start; } + .fr-search input { width: 180px; } + .fr-thread-row, .fr-thread-row-board { grid-template-columns: 1fr 60px; } + .fr-thread-row > :nth-child(n+3) { grid-column: 1 / -1; } + .ub-inner, .ls-detail-layout { padding-left: 16px; padding-right: 16px; } + .ub-tabs-row { padding-left: 16px; } + .ls-hero-inner { flex-direction: column; } + .ls-hero-actions { align-self: flex-start; } + .mp-search { margin-left: 0; } + .mp-table thead { display: none; } + .mp-row { display: block; padding: 10px 0; } + .mp-row td { display: block; border: 0; padding: 4px 0; } + .mp-price, .mp-actions { text-align: left; } + .rp-head { flex-direction: column; } +} diff --git a/sites/discogs/static/icons/.gitkeep b/sites/discogs/static/icons/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/sites/discogs/static/icons/discogs.svg b/sites/discogs/static/icons/discogs.svg new file mode 100644 index 000000000..b575b324e --- /dev/null +++ b/sites/discogs/static/icons/discogs.svg @@ -0,0 +1,26 @@ +Discogs diff --git a/sites/discogs/static/icons/no-cover.svg b/sites/discogs/static/icons/no-cover.svg new file mode 100644 index 000000000..da8da3290 --- /dev/null +++ b/sites/discogs/static/icons/no-cover.svg @@ -0,0 +1,8 @@ + + + + + + no cover + diff --git a/sites/discogs/static/js/.gitkeep b/sites/discogs/static/js/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/sites/discogs/static/js/site.js b/sites/discogs/static/js/site.js new file mode 100644 index 000000000..f02a469e2 --- /dev/null +++ b/sites/discogs/static/js/site.js @@ -0,0 +1,142 @@ +/* Discogs mirror — progressive enhancement for the header, carousels and footer. + Everything works without JS except dropdown toggling on touch devices. */ +(function () { + 'use strict'; + + /* Header menus are native
; JS only adds "one open at a time" and + click-outside/Escape dismissal. Without JS they still open and close. */ + function closeAllDropdowns(except) { + document.querySelectorAll('.hdr-dd[open]').forEach(function (dd) { + if (dd !== except) dd.open = false; + }); + } + + document.querySelectorAll('.hdr-secondary .hdr-dd, .hdr-account').forEach(function (dd) { + dd.addEventListener('toggle', function () { + if (dd.open) closeAllDropdowns(dd); + }); + }); + document.addEventListener('click', function (event) { + if (!event.target.closest('.hdr-dd')) closeAllDropdowns(null); + }); + + /* Mobile drawer. */ + var header = document.querySelector('.site-header'); + var bars = document.querySelector('.hdr-bars'); + var drawer = document.querySelector('.hdr-mobile'); + function setDrawer(open) { + if (!header || !drawer) return; + header.classList.toggle('menu-open', open); + drawer.hidden = !open; + if (bars) bars.setAttribute('aria-expanded', open ? 'true' : 'false'); + } + if (bars) bars.addEventListener('click', function () { setDrawer(drawer.hidden); }); + document.querySelectorAll('.hdr-mobile-close').forEach(function (b) { + b.addEventListener('click', function () { setDrawer(false); }); + }); + + document.addEventListener('keydown', function (event) { + if (event.key !== 'Escape') return; + closeAllDropdowns(null); + setDrawer(false); + var dialog = document.getElementById('shortcuts-dialog'); + if (dialog && dialog.open) dialog.close(); + }); + + /* Search category panel: keep "View all results" pointing at the typed query. */ + document.querySelectorAll('.hdr-search').forEach(function (form) { + var input = form.querySelector('.hdr-query'); + var link = form.querySelector('.hdr-advanced'); + if (!input || !link) return; + var base = link.getAttribute('href').split('?')[0]; + function sync() { + var q = input.value.trim(); + link.setAttribute('href', q ? base + '?q=' + encodeURIComponent(q) : base); + } + input.addEventListener('input', sync); + sync(); + }); + + /* Release carousels: paged prev/next arrows and bullets, like the live site. */ + document.querySelectorAll('.release-carousel').forEach(function (section) { + var track = section.querySelector('.rc-track'); + var slides = track ? track.querySelectorAll('.rc-slide') : []; + var prev = section.querySelector('.rc-prev'); + var next = section.querySelector('.rc-next'); + var pagination = section.querySelector('.rc-pagination'); + if (!track || !slides.length || !prev || !next) return; + var page = 0; + var metrics = { perPage: 1, pages: 1, step: 0 }; + + function measure() { + var slide = slides[0]; + var style = getComputedStyle(slide); + var width = slide.getBoundingClientRect().width + parseFloat(style.marginRight || 0); + var visible = track.parentElement.getBoundingClientRect().width; + var perPage = Math.max(1, Math.floor((visible + parseFloat(style.marginRight || 0)) / width)); + metrics = { perPage: perPage, pages: Math.max(1, Math.ceil(slides.length / perPage)), step: width * perPage }; + if (page > metrics.pages - 1) page = metrics.pages - 1; + } + + function render() { + track.style.transform = 'translateX(' + (-page * metrics.step) + 'px)'; + prev.disabled = page === 0; + next.disabled = page >= metrics.pages - 1; + if (pagination) { + pagination.innerHTML = ''; + for (var i = 0; i < metrics.pages; i++) { + var bullet = document.createElement('button'); + bullet.type = 'button'; + bullet.className = 'rc-bullet' + (i === page ? ' active' : ''); + bullet.setAttribute('aria-label', 'Go to slide ' + (i + 1)); + bullet.dataset.page = String(i); + bullet.addEventListener('click', function (event) { + page = parseInt(event.currentTarget.dataset.page, 10); + render(); + }); + pagination.appendChild(bullet); + } + } + } + + prev.addEventListener('click', function () { if (page > 0) { page -= 1; render(); } }); + next.addEventListener('click', function () { if (page < metrics.pages - 1) { page += 1; render(); } }); + window.addEventListener('resize', function () { measure(); render(); }); + measure(); + render(); + }); + + /* Footer newsletter: offline mirror, confirm locally without sending anything. */ + document.querySelectorAll('form[data-newsletter]').forEach(function (form) { + form.addEventListener('submit', function (event) { + event.preventDefault(); + var success = form.querySelector('.ftr-success'); + var button = form.querySelector('.ftr-signup-btn'); + if (success) success.hidden = false; + if (button) button.textContent = 'Success!'; + }); + }); + + /* Auth forms: show/hide password like the live login page. */ + document.querySelectorAll('[data-toggle-password]').forEach(function (button) { + button.addEventListener('click', function () { + var input = document.getElementById(button.dataset.togglePassword); + if (!input) return; + var show = input.type === 'password'; + input.type = show ? 'text' : 'password'; + button.setAttribute('aria-label', show ? 'Hide password' : 'Show password'); + }); + }); + + /* Keyboard shortcuts legend. */ + document.querySelectorAll('[data-dialog="shortcuts"]').forEach(function (trigger) { + trigger.addEventListener('click', function (event) { + event.preventDefault(); + var dialog = document.getElementById('shortcuts-dialog'); + if (dialog && typeof dialog.showModal === 'function') dialog.showModal(); + }); + }); + document.querySelectorAll('.shortcuts-dialog-close').forEach(function (b) { + b.addEventListener('click', function () { b.closest('dialog').close(); }); + }); +})(); diff --git a/sites/discogs/tasks.jsonl b/sites/discogs/tasks.jsonl new file mode 100644 index 000000000..fad142214 --- /dev/null +++ b/sites/discogs/tasks.jsonl @@ -0,0 +1,15 @@ +{"web_name": "Discogs", "id": "Discogs--0", "ques": "Find the US vinyl editions of Thelonious Monk Septet's “Monk's Music” released in 1961 and 1966. Compare their detail pages: which edition prints durations for its tracks? Report that edition's year, both catalog numbers, and the duration shown for “Well, You Needn't”.", "web": "http://localhost:40029/", "upstream_url": "https://www.discogs.com/", "verifier_path": "sites/discogs/verify/verify_0.py", "judge_rubric": "FACT CHECKPOINTS:\n- MUST open the detail pages for both the 1961 and 1966 US vinyl editions of Monk's Music.\n- MUST identify which compared edition prints track durations and report its year.\n- MUST report both catalog numbers shown for that edition and the displayed duration of “Well, You Needn't”.\n- An empty answer, a one-edition shortcut, or unsupported values are a FAIL."} +{"web_name": "Discogs", "id": "Discogs--1", "ques": "Compare the 1986 and 1994 Japanese editions of Clifford Brown's “Memorial Album”. Which edition contains “Bellarosa”? Report its year, primary medium, catalog number, and the displayed duration of that track.", "web": "http://localhost:40029/", "upstream_url": "https://www.discogs.com/", "verifier_path": "sites/discogs/verify/verify_1.py", "judge_rubric": "FACT CHECKPOINTS:\n- MUST open the detail pages for both specified Japanese editions.\n- MUST identify which edition contains “Bellarosa”.\n- MUST report that edition's year, primary medium, catalog number, and the track's displayed duration.\n- An empty answer or facts copied from only a result card are a FAIL."} +{"web_name": "Discogs", "id": "Discogs--2", "ques": "Compare the 1982 French and 2007 Japanese editions of “Dig” by Miles Davis Featuring Sonny Rollins. Which one has seven numbered tracks? Report its country and year, catalog number, and the person credited for digital remastering.", "web": "http://localhost:40029/", "upstream_url": "https://www.discogs.com/", "verifier_path": "sites/discogs/verify/verify_2.py", "judge_rubric": "FACT CHECKPOINTS:\n- MUST open both specified Dig edition detail pages.\n- MUST identify the edition with seven numbered tracks.\n- MUST report its country/year, catalog number, and digital-remastering credit.\n- An empty answer or an answer without evidence of comparing both editions is a FAIL."} +{"web_name": "Discogs", "id": "Discogs--3", "ques": "There are two Spanish cassette editions of Bob Marley & The Wailers' “Exodus” dated 1978. Open both detail pages and compare their “Barcode and Other Identifiers” sections: one edition lists an extra “Impuesto de lujo” entry that the other does not. Report the public release ID of each edition, say which one carries that extra entry and give its value, and state the Depósito Legal value both editions share.", "web": "http://localhost:40029/", "upstream_url": "https://www.discogs.com/", "verifier_path": "sites/discogs/verify/verify_3.py", "judge_rubric": "FACT CHECKPOINTS:\n- MUST open both Spanish 1978 cassette edition detail pages.\n- MUST report both public release IDs and attach the extra “Impuesto de lujo” entry to the edition that actually lists it.\n- MUST give the value of that extra entry and the Depósito Legal value shared by both editions.\n- An empty answer, a swapped binding, or a one-edition shortcut is a FAIL."} +{"web_name": "Discogs", "id": "Discogs--4", "ques": "Find the 2025 worldwide vinyl release “Live God” by Nick Cave & The Bad Seeds. From its detail page, report the credited bass player and the final track listed on side D.", "web": "http://localhost:40029/", "upstream_url": "https://www.discogs.com/", "verifier_path": "sites/discogs/verify/verify_4.py", "judge_rubric": "FACT CHECKPOINTS:\n- MUST open the specified Live God release detail page.\n- MUST report the person credited for bass and the final track on side D.\n- Both facts must come from the release page; an empty or partial answer is a FAIL."} +{"web_name": "Discogs", "id": "Discogs--5", "ques": "In the Marketplace, use USD, filter media condition to “Near Mint (NM or M-)”, and sort cheapest first. Open the cheapest release. Report the release title and artist, seller, USD price, and every catalog number shown on the release detail page.", "web": "http://localhost:40029/", "upstream_url": "https://www.discogs.com/", "verifier_path": "sites/discogs/verify/verify_5.py", "judge_rubric": "FACT CHECKPOINTS:\n- MUST use the Marketplace with USD, Near Mint media condition, and ascending price.\n- MUST open the cheapest listing's release detail page.\n- MUST report the title/artist, seller, USD price, and all catalog numbers shown on that detail page.\n- A marketplace-card-only answer or an empty answer is a FAIL."} +{"web_name": "Discogs", "id": "Discogs--6", "ques": "In User Lists, find dave_techno's public list “Records I Always Bring to the Listening Bar”. Open the fourth release displayed in that list and report its title, artist, country, and catalog number from the release detail page.", "web": "http://localhost:40029/", "upstream_url": "https://www.discogs.com/", "verifier_path": "sites/discogs/verify/verify_6.py", "judge_rubric": "FACT CHECKPOINTS:\n- MUST open dave_techno's named public list and then the fourth displayed release.\n- MUST report that release's title, artist, country, and catalog number from its detail page.\n- A list-card-only answer, wrong list owner, or empty answer is a FAIL."} +{"web_name": "Discogs", "id": "Discogs--7", "ques": "Log in as alice_crate (password: alice12345). Find the 1966 US vinyl edition of Thelonious Monk Septet's “Monk's Music” and add it to the Vinyl folder of your collection with media condition “Near Mint (NM or M-)”.", "web": "http://localhost:40029/", "upstream_url": "https://www.discogs.com/", "verifier_path": "sites/discogs/verify/verify_7.py", "judge_rubric": "FACT CHECKPOINTS:\n- MUST authenticate as alice_crate and open the specified 1966 US release.\n- MUST add exactly that edition to alice_crate's collection in the Vinyl folder with the requested media condition.\n- The persisted after-state, not a success message alone, is required.\n- Extra collection mutations are a FAIL."} +{"web_name": "Discogs", "id": "Discogs--8", "ques": "Log in as bob_vinyl (password: bob123456). Find the 2010 US CD edition of “Dig” by Miles Davis Featuring Sonny Rollins and add it to your wantlist with minimum grade “Very Good (VG)”.", "web": "http://localhost:40029/", "upstream_url": "https://www.discogs.com/", "verifier_path": "sites/discogs/verify/verify_8.py", "judge_rubric": "FACT CHECKPOINTS:\n- MUST authenticate as bob_vinyl and open the specified 2010 US CD edition.\n- MUST add exactly that edition to bob_vinyl's wantlist with the requested minimum grade.\n- The persisted after-state, not a success message alone, is required.\n- Extra wantlist mutations are a FAIL."} +{"web_name": "Discogs", "id": "Discogs--9", "ques": "Log in as dave_techno (password: dave12345). Create a public list titled “Jazz Reissue Reference” with description “Compare these two reissues before the next listening session.” Find and add these editions in order: the 1966 US vinyl “Monk's Music”, then the 2007 Japanese CD “Dig”. Use comments “1966 US stereo edition” and “2007 Japan CD edition” respectively.", "web": "http://localhost:40029/", "upstream_url": "https://www.discogs.com/", "verifier_path": "sites/discogs/verify/verify_9.py", "judge_rubric": "FACT CHECKPOINTS:\n- MUST authenticate as dave_techno and create one public list with the exact requested title and description.\n- MUST discover and add the two specified editions in the requested order.\n- MUST persist the exact per-item comments.\n- A list with missing, swapped, duplicated, or extra items is a FAIL."} +{"web_name": "Discogs", "id": "Discogs--10", "ques": "Log in as bob_vinyl (password: bob123456). Find the 1980 Spanish vinyl edition of Bob Marley & The Wailers' “Exodus” and list it for sale at 42.00 GBP. Set media condition to “Very Good Plus (VG+)”, sleeve condition to “Near Mint (NM or M-)”, shipping from “United Kingdom”, allow offers, and use the comment “Clean Spanish reissue; play-graded.”", "web": "http://localhost:40029/", "upstream_url": "https://www.discogs.com/", "verifier_path": "sites/discogs/verify/verify_10.py", "judge_rubric": "FACT CHECKPOINTS:\n- MUST authenticate as bob_vinyl, identify the specified Spanish vinyl edition, and use the Sell flow.\n- MUST persist one listing with the exact price, currency, two conditions, shipping origin, offer flag, and comment.\n- The listing must reference the requested edition; extra listings are a FAIL.\n- A success message without the persisted after-state is a FAIL."} +{"web_name": "Discogs", "id": "Discogs--11", "ques": "Open the Vinyl Collectors forum and the thread “Cartridges: MM vs MC for jazz”. Log in as carol_jazz (password: carol12345) and post exactly: “For acoustic jazz, I prefer MC for the extra detail in cymbals and upper mids.”", "web": "http://localhost:40029/", "upstream_url": "https://www.discogs.com/", "verifier_path": "sites/discogs/verify/verify_11.py", "judge_rubric": "FACT CHECKPOINTS:\n- MUST open the named thread, authenticate as carol_jazz, and return to that thread.\n- MUST persist exactly one new post by carol_jazz in that thread with the requested body.\n- Posting to another thread, altered text, or extra posts is a FAIL."} +{"web_name": "Discogs", "id": "Discogs--12", "ques": "Register a new account with username “craterunner99”, email “craterunner99@test.com”, password “discogs2026”, and location “Portland, USA”. Then open Settings, set the real name to “Casey Runner” and the bio to “Digging since 2010.”, save, sign out, and log back in with the new credentials.", "web": "http://localhost:40029/", "upstream_url": "https://www.discogs.com/", "verifier_path": "sites/discogs/verify/verify_12.py", "judge_rubric": "FACT CHECKPOINTS:\n- MUST create exactly one account with the requested username/email/location.\n- MUST persist the requested real name and bio through Settings.\n- MUST sign out and successfully log back in as the new account.\n- Existing-user mutations, incomplete fields, or an unverified login are a FAIL."} +{"web_name": "Discogs", "id": "Discogs--13", "ques": "Log in as alice_crate (password: alice12345). Open the Vinyl folder of her Collection and remove the 1966 US vinyl edition “Jimmy Smith At The Organ (Volume 2)” by Jimmy Smith. Confirm it no longer appears in that folder.", "web": "http://localhost:40029/", "upstream_url": "https://www.discogs.com/", "verifier_path": "sites/discogs/verify/verify_13.py", "judge_rubric": "FACT CHECKPOINTS:\n- MUST authenticate as alice_crate and use her Collection's Vinyl folder.\n- MUST remove exactly the specified 1966 US edition and confirm it is absent.\n- The persisted after-state is required; removing another edition or extra collection rows is a FAIL."} +{"web_name": "Discogs", "id": "Discogs--14", "ques": "Log in as dave_techno (password: dave12345). Find the 2025 worldwide vinyl release “Live God” by Nick Cave & The Bad Seeds, rate it 4 stars, and post a 4-star review with exactly this text: “The live arrangements reward a focused listen from start to finish.”", "web": "http://localhost:40029/", "upstream_url": "https://www.discogs.com/", "verifier_path": "sites/discogs/verify/verify_14.py", "judge_rubric": "FACT CHECKPOINTS:\n- MUST authenticate as dave_techno and open the specified Live God release.\n- MUST persist a 4-star user rating and exactly one 4-star review with the requested text.\n- Both state changes are required; extra ratings or reviews are a FAIL.\n- A success message without matching after-state is a FAIL."} diff --git a/sites/discogs/templates/.gitkeep b/sites/discogs/templates/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/sites/discogs/templates/403.html b/sites/discogs/templates/403.html new file mode 100644 index 000000000..58b942c26 --- /dev/null +++ b/sites/discogs/templates/403.html @@ -0,0 +1,9 @@ +{% extends "base.html" %} +{% block title %}Forbidden | Discogs{% endblock %} +{% block content %} +
+

403 — Not allowed

+

You don't have access to that resource.

+

Back to home

+
+{% endblock %} diff --git a/sites/discogs/templates/404.html b/sites/discogs/templates/404.html new file mode 100644 index 000000000..1b44f9a6d --- /dev/null +++ b/sites/discogs/templates/404.html @@ -0,0 +1,9 @@ +{% extends "base.html" %} +{% block title %}Page not found | Discogs{% endblock %} +{% block content %} +
+

404 — Page not found

+

Looks like that record's not in our crates.

+

Back to home

+
+{% endblock %} diff --git a/sites/discogs/templates/_forum_band.html b/sites/discogs/templates/_forum_band.html new file mode 100644 index 000000000..c09879b37 --- /dev/null +++ b/sites/discogs/templates/_forum_band.html @@ -0,0 +1,28 @@ +{# Shared forum header band + tabs (live: dark band with title, intro, search; tabs below). #} +
+
+
+ {% if crumbs %}
{% for label, href in crumbs %}{% if href %}{{ label }}{% else %}{{ label }}{% endif %}{% if not loop.last %} » {% endif %}{% endfor %}
{% endif %} +

{% if icon %}{% endif %}{{ band_title }}

+ {% if band_intro %}

{{ band_intro }}

{% endif %} + {% if band_extra %}
{{ band_extra }}
{% endif %} +
+
+ + {% if new_thread_url %}+ Add New Thread{% endif %} +
+
+
+{% if show_tabs %} + +{% endif %} diff --git a/sites/discogs/templates/_macros.html b/sites/discogs/templates/_macros.html new file mode 100644 index 000000000..1657d573f --- /dev/null +++ b/sites/discogs/templates/_macros.html @@ -0,0 +1,79 @@ +{% macro release_card(r) %} + + {{ r.title }} +
{{ r.title }}
+
{{ r.display_artist }}
+
{{ r.year or '' }} · {{ r.primary_format or '' }}{% if r.country %} · {{ r.country }}{% endif %}
+ {% if r.lowest_price %}
from {{ r.lowest_price|price('USD') }}
{% endif %} +
+{% endmacro %} + +{# Homepage carousel card: same fields and order as the live release carousel cards. #} +{% macro carousel_card(r) %} +
  • +
    + + {{ r.title }} + +
    + {{ r.title }}
    + {{ r.display_artist }} +
    + {{ r.year or '' }} + {{ r.genre_str }}{% if r.style_str %}, {{ r.style_str }}{% endif %} + {{ r.format_description or r.primary_format }} + {% if r.num_for_sale and r.lowest_price %} + {{ r.num_for_sale }} {{ 'copy' if r.num_for_sale == 1 else 'copies' }} from ${{ '%.2f'|format(r.lowest_price) }} + {% endif %} +
    +
    +
    +
  • +{% endmacro %} + +{% macro carousel(title, releases, anchor) %} + +{% endmacro %} + +{% macro avatar(u, size='sm') %} + {{ u.username[0]|upper }} +{% endmacro %} + +{% macro pagination(pag, endpoint, params=None) %} + {% set params = params or {} %} + {% if pag.pages > 1 %} + + {% endif %} +{% endmacro %} + +{% macro stars(rating, count=None) %} + {{ rating|stars }} + {% if rating %}{{ '%.2f'|format(rating) }}{% if count %} ({{ count }}){% endif %}{% endif %} +{% endmacro %} diff --git a/sites/discogs/templates/artist.html b/sites/discogs/templates/artist.html new file mode 100644 index 000000000..1064cc806 --- /dev/null +++ b/sites/discogs/templates/artist.html @@ -0,0 +1,62 @@ +{% extends "base.html" %} +{% import "_macros.html" as m %} +{% block title %}{{ a.name }} | Discogs{% endblock %} + +{% block content %} +
    + {% if a.image_path %} + {{ a.name }} + {% else %} +
    + {{ a.name[0]|upper }} +
    + {% endif %} +
    +

    {{ a.name }}

    + {% if a.real_name %}

    Real Name: {{ a.real_name }}

    {% endif %} + {% if a.profile %}

    {{ a.profile }}

    {% endif %} +

    {{ a.releases.count() }} releases · {{ a.in_collection }} in collections

    +
    +
    + +
    +
    +
    +
    +

    Releases

    + +
    +
    + {% for r in pag.items %}{{ m.release_card(r) }}{% endfor %} +
    + {% if not pag.items %}

    No releases.

    {% endif %} + {{ m.pagination(pag, 'artist_detail', params={'aid': a.id, 'slug': a.slug, 'sort': sort}) }} +
    +
    +
    + {% if a.members %} +
    +

    Members

    +
      + {% for member in a.members.split('\n') if member %} +
    • {{ member }}
    • + {% endfor %} +
    +
    + {% endif %} + {% if a.sites %} +
    +

    Links

    +
      + {% for s in a.sites.split('\n') if s %}
    • {{ s }}
    • {% endfor %} +
    +
    + {% endif %} +
    +
    +{% endblock %} diff --git a/sites/discogs/templates/base.html b/sites/discogs/templates/base.html new file mode 100644 index 000000000..159d4eea0 --- /dev/null +++ b/sites/discogs/templates/base.html @@ -0,0 +1,248 @@ + + + + + {% block title %}Discogs - Music Database and Marketplace{% endblock %} + + + + + +{# ---- Shared inline icons (paths drawn locally; no external assets) ---- #} +{% set icon_chevron %}{% endset %} +{% set icon_search %}{% endset %} +{% set icon_cart %}{% endset %} +{% set icon_bars %}{% endset %} +{% set icon_close %}{% endset %} +{% set icon_arrow %}{% endset %} + + + +
    + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
      + {% for cat, msg in messages %} +
    • {{ msg }}
    • + {% endfor %} +
    + {% endif %} + {% endwith %} + + {% block content %}{% endblock %} +
    + +
    + + + +
    +

    Keyboard Shortcuts

    + +
    +
    +
    +
    +
    +

    Navigation

    +
      + {% for keys, label in [('g f','Go to Forum'),('g g','Go to Groups'),('g d','Go to Dashboard'),('g c','Go to Collection'),('g w','Go to Wantlist'),('g s','Go to Submissions'),('g p','Go to Purchases'),('g i','Go to Inventory'),('g o','Go to Orders'),('g m','Go to Messages'),('g u','Go to User Profile'),('h','Go To Previous Page'),('l','Go To Next Page')] %} +
    • {% for k in keys.split(' ') %}{{ k }}{% endfor %}{{ label }}
    • + {% endfor %} +
    +

    Forum Navigation

    +
      + {% for keys, label in [('f r','Go To Recent'),('f p','Go To Posted'),('f s','Go To Started'),('f v','Go To Saved'),('f w','Go To Watched')] %} +
    • {% for k in keys.split(' ') %}{{ k }}{% endfor %}{{ label }}
    • + {% endfor %} +
    +
    +
    +

    Actions

    +
      + {% for keys, label in [('a c','Add to Collection'),('a w','Add to Wantlist'),('a i','Add to Inventory'),('a l','Add to List'),('r w','Remove Release from Wantlist'),('g e','Go to Edit'),('j','Move Selection Down'),('k','Move Selection Up'),('o','Go to the Selection'),('v','View Images for Release'),('x','Check the Selection'),('/','Focus on Search'),('?','Show this Legend')] %} +
    • {% for k in keys.split(' ') %}{{ k }}{% endfor %}{{ label }}
    • + {% endfor %} +
    +
    +
    +

    Keyboard shortcuts are listed for reference; they are not active in this offline mirror.

    +
    +
    +
    + + + diff --git a/sites/discogs/templates/collection.html b/sites/discogs/templates/collection.html new file mode 100644 index 000000000..9bbd4337f --- /dev/null +++ b/sites/discogs/templates/collection.html @@ -0,0 +1,63 @@ +{% extends "base.html" %} +{% import "_macros.html" as m %} +{% block title %}{{ u.username }}'s Collection | Discogs{% endblock %} + +{% block content %} +

    {{ u.username }}'s Collection

    +

    {{ pag.total }} items

    + +
    + {% for f in [unfiltered] + folders %} + {{ f }} + {% endfor %} +
    + +
    +
    + + + + + + + + + + {% if current_user.is_authenticated and current_user.id == u.id %}{% endif %} + + + + {% for c in pag.items %} + + + + + + + + {% if current_user.is_authenticated and current_user.id == u.id %} + + {% endif %} + + {% endfor %} + +
    ReleaseLabelFormatConditionAdded
    + + + + + {{ c.release.title }} +
    {{ c.release.display_artist }} · {{ c.release.year or '' }}
    +
    {{ c.release.label_str }}{{ c.release.primary_format }}M: {{ c.media_condition }}
    S: {{ c.sleeve_condition }}
    {{ c.added_at|relative }} +
    + + + +
    +
    +
    + {% if not pag.items %}

    No items in this folder.

    {% endif %} + {{ m.pagination(pag, 'user_collection', params={'username': u.username, 'folder': folder}) }} +
    +{% endblock %} diff --git a/sites/discogs/templates/community_stats.html b/sites/discogs/templates/community_stats.html new file mode 100644 index 000000000..4bf0c69c4 --- /dev/null +++ b/sites/discogs/templates/community_stats.html @@ -0,0 +1,26 @@ +{% extends "base.html" %} +{% block title %}{{ heading }} | Discogs{% endblock %} +{% block content %} +

    {{ heading }}

    +

    {{ intro }}

    +
    +
    + + + + + + {% for row in rows %} + + + + + + + {% endfor %} + +
    #UserReviewsListsListingsCollectionWantlistTotal
    {{ loop.index }}{{ row.user.username }}{{ row.reviews }}{{ row.lists }}{{ row.listings }}{{ row.collection }}{{ row.wantlist }}{{ row.total }}
    +
    +
    +

    Counts reflect synthetic benchmark accounts and their activity in this snapshot; they are not live Discogs contributor statistics.

    +{% endblock %} diff --git a/sites/discogs/templates/digs.html b/sites/discogs/templates/digs.html new file mode 100644 index 000000000..3eef88108 --- /dev/null +++ b/sites/discogs/templates/digs.html @@ -0,0 +1,47 @@ +{% extends "base.html" %} +{% import "_macros.html" as m %} +{% block title %}Digs | Discogs{% endblock %} +{% block content %} +

    Digs

    +

    Discogs’ editorial hub. In this offline mirror, each section is built from the snapshot’s own catalog and benchmark community activity rather than from live articles.

    + + + +
    +

    Essentials

    +

    Highest-rated releases among benchmark users.

    +
    {% for r in essentials %}{{ m.release_card(r) }}{% endfor %}
    +
    + +
    +

    Features

    +
    +
    Discover Jazz, Deep Cuts & More From This Snapshot
    Browse the verified catalog by genre, style and format.
    +
    Most Valuable Marketplace Listings
    The highest-priced copies currently offered by benchmark sellers.
    +
    Explore Newly Verified Releases
    Releases ordered by the date they were added to the source database.
    +
    +
    + +
    +

    Most Valuable

    +

    Releases with the highest current USD asking prices in the benchmark marketplace.

    +
    {% for r in most_valuable %}{{ m.release_card(r) }}{% endfor %}
    +
    + +
    +

    Collecting

    +

    Most collected releases among benchmark users.

    +
    {% for r in collecting %}{{ m.release_card(r) }}{% endfor %}
    +
    + +
    +

    Audio Gear

    +

    Gear guides are not part of this snapshot. Turntable, cartridge and phono-stage discussions live in the Vinyl Collectors forum.

    +
    +{% endblock %} diff --git a/sites/discogs/templates/explore.html b/sites/discogs/templates/explore.html new file mode 100644 index 000000000..7c529ad81 --- /dev/null +++ b/sites/discogs/templates/explore.html @@ -0,0 +1,33 @@ +{% extends "base.html" %} +{% block title %}Explore | Discogs{% endblock %} +{% block content %} +

    Explore the Database

    + +
    +

    Genres

    +
    + {% for g in genres %}{{ g.name }}{% endfor %} +
    +
    + +
    +

    Formats

    +
    + {% for f in formats %}{{ f.name }}{% endfor %} +
    +
    + +
    +

    Decades

    +
    + {% for d in decades %}{{ d }}s{% endfor %} +
    +
    + +
    +

    Countries

    +
    + {% for c in countries %}{{ c }}{% endfor %} +
    +
    +{% endblock %} diff --git a/sites/discogs/templates/format.html b/sites/discogs/templates/format.html new file mode 100644 index 000000000..54aad3edc --- /dev/null +++ b/sites/discogs/templates/format.html @@ -0,0 +1,11 @@ +{% extends "base.html" %} +{% import "_macros.html" as m %} +{% block title %}{{ f.name }} | Discogs Format{% endblock %} +{% block content %} +

    {{ f.name }}

    +

    {{ pag.total }} releases on {{ f.name }}

    +
    +
    {% for r in pag.items %}{{ m.release_card(r) }}{% endfor %}
    + {{ m.pagination(pag, 'format_detail', params={'slug': f.slug}) }} +
    +{% endblock %} diff --git a/sites/discogs/templates/forum.html b/sites/discogs/templates/forum.html new file mode 100644 index 000000000..542865895 --- /dev/null +++ b/sites/discogs/templates/forum.html @@ -0,0 +1,28 @@ +{% extends "base.html" %} +{% import "_macros.html" as m %} +{% block title %}Discogs Forum - {{ f.name }}{% endblock %} +{% block main_class %}page fr-page{% endblock %} +{% block content %} +{% with band_title=f.name, band_intro=f.description, show_tabs=True, tab='topics', crumbs=[('Forum', url_for('forum_index')), (f.name, None)], new_thread_url=(url_for('thread_new', slug=f.slug) if current_user.is_authenticated else url_for('login', next=url_for('thread_new', slug=f.slug))) %}{% include "_forum_band.html" %}{% endwith %} + +
    +
    +
    Title
    Replies
    Author
    Started
    +
    + {% for t in pag.items %} +
    +
    + {% if t.pinned %}📌 {% endif %} + {% if t.locked %}🔒 {% endif %} + {{ t.title }} +
    +
    {{ t.posts.count() - 1 }}
    + +
    {{ t.created_at|relative }}
    +
    + {% else %} +

    No threads yet — start one!

    + {% endfor %} +
    +{{ m.pagination(pag, 'forum_view', params={'slug': f.slug}) }} +{% endblock %} diff --git a/sites/discogs/templates/forum_index.html b/sites/discogs/templates/forum_index.html new file mode 100644 index 000000000..1d0c308f0 --- /dev/null +++ b/sites/discogs/templates/forum_index.html @@ -0,0 +1,23 @@ +{% extends "base.html" %} +{% block title %}Discogs Forum{% endblock %} +{% block main_class %}page fr-page{% endblock %} +{% block content %} +{% with band_title='Forum', icon=True, band_intro='Your place to talk about music, Discogs, collecting, buying and selling music, and anything else!', show_tabs=True, tab='topics' %}{% include "_forum_band.html" %}{% endwith %} + +
    +{% for f in forums %} +
    + +
    +

    {{ f.name }}

    +

    {{ f.description }}

    +
    +
    + {{ stats[f.id].threads }} threads + {{ stats[f.id].posts }} posts + {{ stats[f.id].users }} active users +
    +
    +{% endfor %} +
    +{% endblock %} diff --git a/sites/discogs/templates/forum_threads.html b/sites/discogs/templates/forum_threads.html new file mode 100644 index 000000000..363b58958 --- /dev/null +++ b/sites/discogs/templates/forum_threads.html @@ -0,0 +1,25 @@ +{% extends "base.html" %} +{% import "_macros.html" as m %} +{% block title %}Discogs Forum - {{ heading }}{% endblock %} +{% block main_class %}page fr-page{% endblock %} +{% block content %} +{% with band_title=heading, icon=True, band_intro=intro, show_tabs=True, tab=tab, query_text=query_text %}{% include "_forum_band.html" %}{% endwith %} + +
    +
    +
    Title
    Replies
    Author
    Forum
    Started
    +
    + {% for t in pag.items %} +
    + +
    {{ t.posts.count() - 1 if t.posts.count() else 0 }}
    + + +
    {{ t.created_at|relative }}
    +
    + {% else %} +

    No threads to show.

    + {% endfor %} +
    +{{ m.pagination(pag, request.endpoint, params={'query': query_text or None}) }} +{% endblock %} diff --git a/sites/discogs/templates/genre.html b/sites/discogs/templates/genre.html new file mode 100644 index 000000000..b8d910b0f --- /dev/null +++ b/sites/discogs/templates/genre.html @@ -0,0 +1,30 @@ +{% extends "base.html" %} +{% import "_macros.html" as m %} +{% block title %}{{ g.name }} | Discogs Genre{% endblock %} + +{% block content %} +

    {{ g.name }}

    +

    {{ pag.total }} releases in this genre

    + +{% if styles %} +
    +

    Styles

    +
    + {% for s in styles %}{{ s.name }}{% endfor %} +
    +
    +{% endif %} + +
    +
    +

    Releases

    + +
    +
    {% for r in pag.items %}{{ m.release_card(r) }}{% endfor %}
    + {{ m.pagination(pag, 'genre_detail', params={'slug': g.slug, 'sort': sort}) }} +
    +{% endblock %} diff --git a/sites/discogs/templates/group.html b/sites/discogs/templates/group.html new file mode 100644 index 000000000..698a8774a --- /dev/null +++ b/sites/discogs/templates/group.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} +{% block title %}Groups | Discogs{% endblock %} +{% block content %} +

    Groups

    +

    Discussion groups in this offline mirror are the forum boards below. Join a conversation by opening a board and replying to a thread.

    +
    +{% for f in forums %} +
    +

    {{ f.name }}

    +

    {{ f.description }} · {{ f.threads.count() }} threads

    +
    +{% endfor %} +
    +{% endblock %} diff --git a/sites/discogs/templates/index.html b/sites/discogs/templates/index.html new file mode 100644 index 000000000..9f9e4781f --- /dev/null +++ b/sites/discogs/templates/index.html @@ -0,0 +1,50 @@ +{% extends "base.html" %} +{% import "_macros.html" as m %} +{% block title %}Discogs - Music Database and Marketplace{% endblock %} +{% block main_class %}home-page{% endblock %} + +{% block content %} +

    Discogs.com

    + + + +
    + {{ m.carousel("This Week’s Best-Selling Vinyl Records & CDs", best_selling, 'carousel-1') }} + {{ m.carousel("This Week’s Most Valuable Vinyl Records & CDs", most_valuable, 'carousel-2') }} + {{ m.carousel("This Week’s Most Collected Vinyl Records & CDs", most_collected, 'carousel-3') }} +
    + +
    +
    +

    Millions of Records. Thousands of Shops. All In One App.

    +

    Take your collection anywhere on the Discogs App. Download now.

    +
    + Download now from the App Store + Download now from Google Play +
    +
    + Phone showing the Discogs app +
    +{% endblock %} diff --git a/sites/discogs/templates/info.html b/sites/discogs/templates/info.html new file mode 100644 index 000000000..52f8332e2 --- /dev/null +++ b/sites/discogs/templates/info.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} +{% block title %}{{ page.title }} | Discogs{% endblock %} +{% block content %} +
    +

    {{ page.title }}

    + {% if page.lead %}

    {{ page.lead }}

    {% endif %} + {% for paragraph in page.body %}

    {{ paragraph }}

    {% endfor %} + {% if page.links %} + + {% endif %} +

    This page belongs to the WebHarbor offline mirror of Discogs. Marketplace, collection and community activity here are synthetic benchmark data; release metadata and cover images come from Discogs.

    +
    +{% endblock %} diff --git a/sites/discogs/templates/label.html b/sites/discogs/templates/label.html new file mode 100644 index 000000000..be529e35a --- /dev/null +++ b/sites/discogs/templates/label.html @@ -0,0 +1,39 @@ +{% extends "base.html" %} +{% import "_macros.html" as m %} +{% block title %}{{ l.name }} | Discogs Labels{% endblock %} + +{% block content %} +
    +
    + {{ l.name[0]|upper }} +
    +
    +

    {{ l.name }}

    + {% if l.parent_label %} +

    A division of {{ l.parent_label.name }}

    + {% endif %} + {% if l.profile %}

    {{ l.profile }}

    {% endif %} + {% if l.contact_info %}

    {{ l.contact_info }}

    {% endif %} +

    {{ pag.total }} releases

    +
    +
    + +{% if sublabels %} +
    +

    Sublabels

    +
    + {% for s in sublabels %} + {{ s.name }} + {% endfor %} +
    +
    +{% endif %} + +
    +

    Releases on {{ l.name }}

    +
    + {% for r in pag.items %}{{ m.release_card(r) }}{% endfor %} +
    + {{ m.pagination(pag, 'label_detail', params={'lid': l.id, 'slug': l.slug}) }} +
    +{% endblock %} diff --git a/sites/discogs/templates/list.html b/sites/discogs/templates/list.html new file mode 100644 index 000000000..9dd0d28eb --- /dev/null +++ b/sites/discogs/templates/list.html @@ -0,0 +1,75 @@ +{% extends "base.html" %} +{% import "_macros.html" as m %} +{% block title %}{{ lst.title }} by {{ lst.user.username }} | Discogs Lists{% endblock %} +{% block main_class %}page ls-detail-page{% endblock %} +{% block content %} +{% set u = lst.user %} +
    +
    +
    {{ m.avatar(u, 'md') }}
    + +
    +
    +

    {{ u.username }}

    + +
    +
    + +
    +
    +

    {{ lst.title }}

    +

    By {{ m.avatar(u) }} {{ u.username }} + updated {{ lst.created_at|relative }}{% if not lst.is_public %} · private{% endif %}

    + {% if lst.description %}
    {{ lst.description }}
    {% endif %} + +
      + {% for it in lst.items %} +
    1. + {{ loop.index }} + {% if it.release %} + {{ it.release.title }} +
      + {{ it.release.display_artist }} – {{ it.release.title }} +
      {{ it.release.format_description or it.release.primary_format }}{% if it.release.year %} · {{ it.release.year }}{% endif %}
      + {% if it.comment %}
      {{ it.comment }}
      {% endif %} +
      + {% elif it.artist %} +
      {{ it.artist.name }}{% if it.comment %}
      {{ it.comment }}
      {% endif %}
      + {% endif %} +
    2. + {% else %} +
    3. No items found.
    4. + {% endfor %} +
    +
    + + +
    +{% endblock %} diff --git a/sites/discogs/templates/list_new.html b/sites/discogs/templates/list_new.html new file mode 100644 index 000000000..91290e29f --- /dev/null +++ b/sites/discogs/templates/list_new.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} +{% block title %}Create a list | Discogs{% endblock %} +{% block content %} +

    Create a new list

    +
    + + + + + + +

    +
    +{% endblock %} diff --git a/sites/discogs/templates/lists.html b/sites/discogs/templates/lists.html new file mode 100644 index 000000000..11c1d9683 --- /dev/null +++ b/sites/discogs/templates/lists.html @@ -0,0 +1,72 @@ +{% extends "base.html" %} +{% import "_macros.html" as m %} +{% block title %}Recently Created Lists | Discogs Lists{% endblock %} +{% block main_class %}page ls-page{% endblock %} +{% block content %} +
    +
    +
    +

    Recent Lists

    +

    Explore lists from the Discogs community. Lists can be about anything–notable album covers, prolific producers, your favorite holiday albums. The possibilities are endless! Lists can contain artists, releases, labels, or even other lists.

    + Read More About Creating Lists +
    +
    + {% if current_user.is_authenticated %} + Manage My Lists + + Create new list + {% else %} + Manage My Lists + {% endif %} +
    +
    +
    + +{% if featured %} + +{% endif %} + + + +
    + + + + {% for l in pag.items %} + + + + + + + {% else %} + + {% endfor %} + +
    ListUserDescriptionCreated
    {{ l.title }}{{ m.avatar(l.user) }} {{ l.user.username }}{% if l.description %}{{ l.description[:200] }}{% if l.description|length > 200 %} …{% endif %}{% endif %}{{ l.created_at|relative }}

    No public lists match.

    +
    +{% set first = (pag.page - 1) * pag.per_page + 1 if pag.total else 0 %} +{% set last = [pag.page * pag.per_page, pag.total]|min %} + +{% endblock %} diff --git a/sites/discogs/templates/login.html b/sites/discogs/templates/login.html new file mode 100644 index 000000000..1840a0626 --- /dev/null +++ b/sites/discogs/templates/login.html @@ -0,0 +1,31 @@ +{% extends "base.html" %} +{% block title %}Log In | Discogs{% endblock %} +{% block main_class %}auth-page{% endblock %} +{% block content %} +
    + +

    Welcome

    +

    Log in to Discogs to continue

    +
    + +
    + + +
    +
    + + + +
    + Forgot password? + +
    +

    Don't have an account? Sign up

    +
    OR
    +
    + + + +
    +
    +{% endblock %} diff --git a/sites/discogs/templates/marketplace.html b/sites/discogs/templates/marketplace.html new file mode 100644 index 000000000..983f0524f --- /dev/null +++ b/sites/discogs/templates/marketplace.html @@ -0,0 +1,160 @@ +{% extends "base.html" %} +{% import "_macros.html" as m %} +{% block title %}{% if heading %}{{ heading }}{% else %}{{ media or '' }} {{ format_ or 'Vinyl Records, CDs, and More' }} in {{ currency }} For Sale at Discogs Marketplace{% endif %}{% endblock %} +{% block main_class %}page mp-page{% endblock %} + +{% set sel = selected if selected is defined else {'currency': currency, 'media': media, 'genre': genre, 'format': format_, 'ships_from': '', 'year': None, 'price_min': None, 'price_max': None, 'q': ''} %} +{% macro mp_url() %}{% set base = {'sort': sort, 'currency': sel.currency, 'media': sel.media or None, 'genre': sel.genre or None, 'format': sel.format or None, 'ships_from': sel.ships_from or None, 'year': sel.year, 'price_min': sel.price_min, 'price_max': sel.price_max, 'q': sel.q or None} %}{% set _ = base.update(kwargs) %}{{ url_for('marketplace', **base) }}{% endmacro %} + +{% block content %} +
    +

    {{ heading or 'Marketplace' }}

    + + +
    + +
    + + +
    + {% set first = (pag.page - 1) * pag.per_page + 1 if pag.total else 0 %} + {% set last = [pag.page * pag.per_page, pag.total]|min %} +

    {% if heading %}{{ intro }}{% else %}Shop {% if sel.media %}{{ sel.media }} {% endif %}{% if sel.format %}{{ sel.format }} Records{% else %}Vinyl Records, CDs, and More{% endif %} in {{ sel.currency }}{% endif %}

    +
    + {{ first }} – {{ last }} of {{ '{:,}'.format(pag.total) }} + + {% if pag.page > 1 %}Prev{% else %}Prev{% endif %} + {% if pag.page < pag.pages %}Next{% else %}Next{% endif %} + + {% if not heading %} + + + + Show: {{ pag.per_page }} + + {% endif %} +
    + + + + + + + + + + + + {% for l in pag.items %} + {% set rel = l.release %} + + + + + + + {% else %} + + {% endfor %} + +
    ItemSeller{% if not heading %}Price {% if sort == 'price_asc' %}▲{% elif sort == 'price_desc' %}▼{% endif %}{% else %}Price{% endif %}Actions
    +
    + {{ rel.title }} + {% if rel.rating_count %}Rated: {{ '%.2f'|format(rel.avg_rating) }}{% endif %} + {{ rel.have_count }} have + {{ rel.want_count }} want +
    +
    + {{ rel.display_artist }} - {{ rel.title }} ({{ rel.format_description or rel.primary_format }}) +
    Label: {% if rel.labels %}{{ rel.labels[0].name }}{% else %}Not On Label{% endif %}
    +
    Cat#: {{ rel.catno or 'none' }}
    +
    Media Condition: {{ l.media_condition }}
    +
    Sleeve Condition: {{ l.sleeve_condition }}
    + {% if l.comments %}
    {{ l.comments }}
    {% endif %} + View Release Page +
    +
    + {{ l.user.username }} +
    {{ (l.user.seller_rating or 0)|stars }} {{ '%.1f'|format((l.user.seller_rating or 0) * 20) }}%, {{ l.user.seller_feedback_count or 0 }} ratings
    +
    Ships From: {{ l.shipping_from }}
    +
    + {{ l.price|price(l.currency) }} +
    + shipping
    + {% if l.allow_offers %}
    Make Offer
    {% endif %} +
    + Add to Cart + Details +

    No active listings match these filters.

    + +
    + {{ first }} – {{ last }} of {{ '{:,}'.format(pag.total) }} + {{ m.pagination(pag, request.endpoint, params={'sort': sort, 'media': sel.media or None, 'genre': sel.genre or None, 'format': sel.format or None, 'currency': sel.currency, 'ships_from': sel.ships_from or None, 'year': sel.year, 'price_min': sel.price_min, 'price_max': sel.price_max, 'q': sel.q or None}) }} +
    +

    Listings on this mirror come from benchmark seller accounts; prices are asking prices and no checkout is performed.

    +
    +
    +{% endblock %} diff --git a/sites/discogs/templates/master.html b/sites/discogs/templates/master.html new file mode 100644 index 000000000..52b912264 --- /dev/null +++ b/sites/discogs/templates/master.html @@ -0,0 +1,30 @@ +{% extends "base.html" %} +{% import "_macros.html" as mac %} +{% block title %}{{ m.title }} (master) | Discogs{% endblock %} +{% block content %} +

    {{ m.title }} (master release)

    +

    by {{ m.artist.name }} + · first released {{ m.year or '' }}

    +
    +

    Versions ({{ versions|length }})

    +
    + + + + + + + {% for v in versions %} + + + + + + + + {% endfor %} + +
    Cat#FormatCountryYearHave
    {{ v.catno or '—' }}{{ v.primary_format }}{{ v.country }}{{ v.year or '' }}{{ v.have_count }}
    +
    +
    +{% endblock %} diff --git a/sites/discogs/templates/register.html b/sites/discogs/templates/register.html new file mode 100644 index 000000000..b3271e129 --- /dev/null +++ b/sites/discogs/templates/register.html @@ -0,0 +1,38 @@ +{% extends "base.html" %} +{% block title %}Sign Up | Discogs{% endblock %} +{% block main_class %}auth-page{% endblock %} +{% block content %} +
    + +

    Welcome

    +

    Sign up to Discogs to continue

    +
    + +
    + + +
    +
    + + +
    +
    + + + +
    +
    + + +
    + +
    +

    Already have an account? Log in

    +
    OR
    +
    + + + +
    +
    +{% endblock %} diff --git a/sites/discogs/templates/release.html b/sites/discogs/templates/release.html new file mode 100644 index 000000000..a05edbaf5 --- /dev/null +++ b/sites/discogs/templates/release.html @@ -0,0 +1,303 @@ +{% extends "base.html" %} +{% import "_macros.html" as m %} +{% block title %}{{ r.display_artist }} – {{ r.title }} – {{ r.format_description or r.primary_format }}{% if r.year %}, {{ r.year }}{% endif %} [r{{ r.discogs_id or r.id }}] | Discogs{% endblock %} +{% block main_class %}page release-page{% endblock %} + +{% block content %} +
    +
    +
    +
    + {{ r.title }} + More images +
    +
    +

    + {% for artist, display, join in r.artist_entries %}{{ display }}{% if join %} {{ join }} {% endif %}{% endfor %} + {{ r.title }} +

    + + + + {% if r.country %}{% endif %} + + {% if r.genres %}{% endif %} + {% if r.styles %}{% endif %} +
    Label: + {% if r.label_entries %} + {% for l, catno in r.label_entries %}{{ l.name }}{% if catno %} – {{ catno }}{% endif %}{% if not loop.last %}
    {% endif %}{% endfor %} + {% elif r.labels %} + {% for l in r.labels %}{{ l.name }}{% if not loop.last %}, {% endif %}{% endfor %}{% if r.catno %} – {{ r.catno }}{% endif %} + {% else %}Not On Label{% endif %} +
    Format: + {% set parts = (r.format_description or r.primary_format or '').split(', ') %} + {% if r.formats %}{{ parts[0] }}{% else %}{{ parts[0] }}{% endif %}{% if parts|length > 1 %}, {{ parts[1:]|join(', ') }}{% endif %} +
    Country:{{ r.country }}
    Released:{% if r.released %}{{ r.released }}{% elif r.year %}{{ r.year }}{% else %}Unknown{% endif %}
    Genre:{% for g in r.genres %}{{ g.name }}{% if not loop.last %}, {% endif %}{% endfor %}
    Style:{% for s in r.styles %}{{ s.name }}{% if not loop.last %}, {% endif %}{% endfor %}
    +
    +
    + + {% if r.tracks.count() %} +
    +

    Tracklist

    +
    + + + {% for t in r.tracks %} + + + + + + {% endfor %} + +
    {{ t.position }} + {{ t.title }} + {% if t.artist_credit %}– {{ t.artist_credit }}{% endif %} + {{ t.duration }}
    +
    +
    + {% endif %} + + {% if r.source_data.get('extraartists') %} +
    +

    Credits

    +
      + {% for credit in r.source_data['extraartists'] %} +
    • {{ credit.get('role', '') }} – {{ credit.get('anv') or credit['name'] }}{% if credit.get('tracks') %} (tracks: {{ credit['tracks'] }}){% endif %}
    • + {% endfor %} +
    +
    + {% endif %} + + {% if r.notes %} +
    +

    Notes

    +
    {{ r.notes }}
    +
    + {% endif %} + + {% if r.source_data.get('identifiers') or r.barcode %} +
    +

    Barcode and Other Identifiers

    +
      + {% for identifier in r.source_data.get('identifiers', []) %} +
    • {{ identifier['type'] }}{% if identifier.get('description') %} ({{ identifier['description'] }}){% endif %}: {{ identifier['value'] }}
    • + {% else %} +
    • Barcode: {{ r.barcode }}
    • + {% endfor %} +
    +
    + {% endif %} + + {% if other_versions %} +
    +

    Other Versions ({{ other_versions|length }} of {{ versions_total - 1 }}) + {% if r.master_id %}View All{% endif %}

    +
    + + + + {% for v in other_versions %} + + + + + + + + {% endfor %} + +
    Title (Format)LabelCat#CountryYear
    {{ v.title }} ({{ v.format_description or v.primary_format }}){% for l in v.labels[:2] %}{{ l.name }}{% if not loop.last %}, {% endif %}{% endfor %}{{ v.catno }}{{ v.country }}{{ v.year or '' }}
    +
    +
    + {% endif %} + + {% if related %} +
    +

    Recommendations

    +
    + {% for v in related %} +
    + {{ v.title }} + {{ v.title }} + {{ v.display_artist }} + {{ v.year or '' }} {{ v.country }} + {{ v.primary_format }}{% if v.format_description %} — {{ v.format_description.split(', ')[1:]|join(', ') }}{% endif %} + Shop + Want +
    + {% endfor %} +
    +
    + {% endif %} + +
    +

    For Sale ({{ listings|length }}) + Search Marketplace

    + {% for l in listings %} +
    +
    + {{ l.user.username }} +
    Media: {{ l.media_condition }} / Sleeve: {{ l.sleeve_condition }}
    + {% if l.comments %}
    {{ l.comments }}
    {% endif %} +
    +
    Ships From: {{ l.shipping_from }}
    +
    {{ l.price|price(l.currency) }}
    +
    {{ l.posted_at|relative }}
    +
    Add to Cart
    +
    + {% else %} +

    No copies are currently for sale.

    + {% endfor %} +
    + +
    +

    Reviews{% if reviews %} ({{ reviews|length }}){% endif %}

    + {% if current_user.is_authenticated %} +
    + Add Review +
    + + + + + + + +
    +
    + {% else %} +

    Add Review

    + {% endif %} + {% for rv in reviews %} +
    +
    + {{ m.avatar(rv.user) }} + {{ rv.user.username }} + {% if rv.rating %}{% for i in range(rv.rating) %}★{% endfor %}{% endif %} + {{ rv.created_at|relative }} + {% if rv.helpful %}· {{ rv.helpful }} helpful{% endif %} +
    +
    {{ rv.body }}
    +
    + {% endfor %} +
    +
    + + +
    +{% endblock %} diff --git a/sites/discogs/templates/search.html b/sites/discogs/templates/search.html new file mode 100644 index 000000000..b3b056c02 --- /dev/null +++ b/sites/discogs/templates/search.html @@ -0,0 +1,129 @@ +{% extends "base.html" %} +{% import "_macros.html" as m %} +{% block title %}{% if q %}Search results for {{ q }}{% else %}Explore{% endif %} | Discogs{% endblock %} +{% block main_class %}page search-page{% endblock %} + +{% macro facet(title, key, values, current) %} +
    + {{ title }} + +
    +{% endmacro %} + +{% block content %} +

    {% if q %}Search results for {{ q }}{% else %}Explore all releases{% endif %}

    + + + +
    + + +
    + {% if type_ == 'artist' %} +
    {{ '{:,}'.format(entity_pagination.total) }} artists
    + {% if artists %} +
      + {% for a in artists %} +
    • + {{ a.name }} + {% if a.real_name %}— {{ a.real_name }}{% endif %} +
      {{ a.releases.count() }} releases
      +
    • + {% endfor %} +
    + {% else %} +

    No artists found.

    + {% endif %} + {{ m.pagination(entity_pagination, 'search', params={'q': q, 'type': type_}) }} + {% elif type_ == 'label' %} +
    {{ '{:,}'.format(entity_pagination.total) }} labels
    + {% if labels %} + + {% else %} +

    No labels found.

    + {% endif %} + {{ m.pagination(entity_pagination, 'search', params={'q': q, 'type': type_}) }} + {% else %} + {% set first = (results.page - 1) * results.per_page + 1 if results.total else 0 %} + {% set last = [results.page * results.per_page, results.total]|min %} +
    + ‹ Filters + +
    +
    + {{ first }} - {{ last }} of {{ '{:,}'.format(results.total) }} + + {% if results.page > 1 %}{% else %}{% endif %} + {% if results.page < results.pages %}{% else %}{% endif %} + + Search Marketplace +
    + + + {% if not results.items %}

    No releases match your search.

    {% endif %} + +
    + {{ first }} - {{ last }} of {{ '{:,}'.format(results.total) }} + {{ m.pagination(results, 'search', params={'q': q, 'type': type_, 'sort': sort, + 'genre': filters.genre, 'style': filters.style, + 'format': filters.format_, 'year': filters.year, 'country': filters.country, + 'label': filters.label, 'catno': filters.catno, 'barcode': filters.barcode}) }} + Show: {{ results.per_page }} +
    + {% endif %} +
    +
    +{% endblock %} diff --git a/sites/discogs/templates/search_advanced.html b/sites/discogs/templates/search_advanced.html new file mode 100644 index 000000000..58e3f56f9 --- /dev/null +++ b/sites/discogs/templates/search_advanced.html @@ -0,0 +1,65 @@ +{% extends "base.html" %} +{% block title %}Advanced Search | Discogs{% endblock %} +{% block content %} +
    +

    Advanced Search

    +

    Combine title or artist keywords with label, catalog number, barcode, year, country, format, genre and style filters.

    + +
    +{% endblock %} diff --git a/sites/discogs/templates/sell.html b/sites/discogs/templates/sell.html new file mode 100644 index 000000000..4d1dc9422 --- /dev/null +++ b/sites/discogs/templates/sell.html @@ -0,0 +1,44 @@ +{% extends "base.html" %} +{% block title %}Sell on Discogs{% endblock %} +{% block content %} +

    List an item for sale

    +
    + + + +

    Find a release page and use its numeric ID.

    + +
    +
    + + +
    +
    + + +
    +
    + + + + + + + + + + + + + + + +

    +
    +{% endblock %} diff --git a/sites/discogs/templates/selling_resources.html b/sites/discogs/templates/selling_resources.html new file mode 100644 index 000000000..b4a2a9d4f --- /dev/null +++ b/sites/discogs/templates/selling_resources.html @@ -0,0 +1,50 @@ +{% extends "base.html" %} +{% block title %}Seller Resource Center | Discogs{% endblock %} +{% block content %} +
    +

    Seller Resource Center

    + + +
    +

    Start Selling

    +

    Anyone with an account can list music for sale. Open the release you want to sell, then choose Sell Music to enter the media and sleeve condition, price, currency, shipping origin and comments.

    +

    List Item For Sale

    +
    + +
    +

    How To Grade

    +

    Discogs uses the Goldmine grading standard for media and sleeves:

    +
    +
    Mint (M)
    Perfect; never played, possibly still sealed.
    +
    Near Mint (NM or M-)
    Nearly perfect; no obvious signs of wear.
    +
    Very Good Plus (VG+)
    Shows some signs of wear and may have been played, but well cared for.
    +
    Very Good (VG)
    Surface noise, light scratches and other signs of use are evident but do not overpower the music.
    +
    Good Plus (G+)
    Plays through without skipping but has significant surface noise and visible wear.
    +
    Good (G)
    Heavier wear; still playable.
    +
    Fair (F)
    Barely playable; may skip.
    +
    Poor (P)
    Cracked, badly warped or otherwise unplayable.
    +
    +
    + +
    +

    How To Price

    +

    Compare your copy with the current listings for the same release. Open the release page and check the marketplace section, or browse the Marketplace sorted by price.

    +
    + +
    +

    How To Pack & Ship

    +

    Remove records from their sleeves to prevent seam splits, use a stiff mailer with corner protection, and state the shipping origin on the listing so buyers can estimate delivery.

    +
    + +
    +

    Seller News & Updates

    +

    There are no seller announcements in this snapshot. Marketplace questions are discussed in the Marketplace forum.

    +
    +
    +{% endblock %} diff --git a/sites/discogs/templates/settings.html b/sites/discogs/templates/settings.html new file mode 100644 index 000000000..23691eece --- /dev/null +++ b/sites/discogs/templates/settings.html @@ -0,0 +1,17 @@ +{% extends "base.html" %} +{% block title %}Settings | Discogs{% endblock %} +{% block content %} +
    +

    Profile Settings

    +
    + + + + + + + +

    +
    +
    +{% endblock %} diff --git a/sites/discogs/templates/style.html b/sites/discogs/templates/style.html new file mode 100644 index 000000000..9f006f0cd --- /dev/null +++ b/sites/discogs/templates/style.html @@ -0,0 +1,11 @@ +{% extends "base.html" %} +{% import "_macros.html" as m %} +{% block title %}{{ s.name }} | Discogs Style{% endblock %} +{% block content %} +

    {{ s.name }}

    +

    {{ pag.total }} releases in this style

    +
    +
    {% for r in pag.items %}{{ m.release_card(r) }}{% endfor %}
    + {{ m.pagination(pag, 'style_detail', params={'slug': s.slug}) }} +
    +{% endblock %} diff --git a/sites/discogs/templates/thread.html b/sites/discogs/templates/thread.html new file mode 100644 index 000000000..3bc1689f8 --- /dev/null +++ b/sites/discogs/templates/thread.html @@ -0,0 +1,51 @@ +{% extends "base.html" %} +{% import "_macros.html" as m %} +{% block title %}Discogs Forum - {{ t.title }}{% endblock %} +{% block main_class %}page fr-page{% endblock %} +{% block content %} +{% set replies = posts|length - 1 if posts else 0 %} +{% set band_extra %} + Started by {{ t.user.username }} {{ t.created_at|relative }}, {{ replies }} {{ 'reply' if replies == 1 else 'replies' }} + + + + +{% endset %} +{% with band_title=t.title, band_extra=band_extra, show_tabs=False, crumbs=[('Forum', url_for('forum_index')), (t.forum.name, url_for('forum_view', slug=t.forum.slug))], new_thread_url=(url_for('thread_new', slug=t.forum.slug) if current_user.is_authenticated else url_for('login', next=url_for('thread_new', slug=t.forum.slug))) %}{% include "_forum_band.html" %}{% endwith %} + +
    +
    + {% for p in posts %} + + {% endfor %} +
    1 – {{ posts|length }} of {{ posts|length }}
    + + {% if current_user.is_authenticated and not t.locked %} +
    + + + +

    +
    + {% elif t.locked %} +

    🔒 This thread is locked.

    + {% else %} + + {% endif %} +
    + +
    +{% endblock %} diff --git a/sites/discogs/templates/thread_new.html b/sites/discogs/templates/thread_new.html new file mode 100644 index 000000000..a0366a56a --- /dev/null +++ b/sites/discogs/templates/thread_new.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} +{% block title %}New Thread – {{ f.name }} | Discogs Forum{% endblock %} +{% block content %} +

    Start a new thread in {{ f.name }}

    +
    + + + + + +

    +
    +{% endblock %} diff --git a/sites/discogs/templates/user.html b/sites/discogs/templates/user.html new file mode 100644 index 000000000..5767e0055 --- /dev/null +++ b/sites/discogs/templates/user.html @@ -0,0 +1,66 @@ +{% extends "base.html" %} +{% import "_macros.html" as m %} +{% block title %}{{ u.username }} | Discogs{% endblock %} + +{% block content %} +
    + {{ m.avatar(u, 'md') }} +
    +

    {{ u.username }}

    + {% if u.real_name %}

    {{ u.real_name }}

    {% endif %} + {% if u.location %}

    📍 {{ u.location }}

    {% endif %} +

    Joined {{ u.joined_at.strftime('%B %Y') }} + {% if u.is_seller %} · ⭐ Seller (rating {{ '%.1f'|format(u.seller_rating) }}, {{ u.seller_feedback_count }} feedbacks){% endif %} +

    + {% if u.bio %}

    {{ u.bio }}

    {% endif %} +
    +
    + +
    +
    {{ coll_n }}
    in Collection
    +
    {{ want_n }}
    in Wantlist
    +
    {{ u.reviews.count() }}
    Reviews
    +
    {{ u.lists.count() }}
    Lists
    +
    + + + +
    +
    +
    +

    Recent Reviews

    + {% for rv in reviews %} +
    +
    + {{ rv.release.title }} + {{ rv.release.display_artist }} + {% if rv.rating %}{% for i in range(rv.rating) %}★{% endfor %}{% endif %} + {{ rv.created_at|relative }} +
    +
    {{ rv.body[:300] }}{% if rv.body|length > 300 %}…{% endif %}
    +
    + {% else %} +

    No reviews yet.

    + {% endfor %} +
    +
    +
    +
    +

    Public Lists

    + {% for l in lists %} +

    {{ l.title }} + ({{ l.items.count() }})

    + {% else %} +

    No public lists.

    + {% endfor %} +
    +
    +
    +{% endblock %} diff --git a/sites/discogs/templates/user_feedback.html b/sites/discogs/templates/user_feedback.html new file mode 100644 index 000000000..285db649f --- /dev/null +++ b/sites/discogs/templates/user_feedback.html @@ -0,0 +1,10 @@ +{% extends "base.html" %} +{% block title %}{{ u.username }} – Feedback | Discogs{% endblock %} +{% block content %} +

    {{ u.username }}'s Seller Feedback

    +{% if u.is_seller %} +

    ⭐ {{ '%.1f'|format(u.seller_rating) }} / 5.0 over {{ u.seller_feedback_count }} transactions

    +{% else %} +

    {{ u.username }} is not an active seller.

    +{% endif %} +{% endblock %} diff --git a/sites/discogs/templates/user_lists.html b/sites/discogs/templates/user_lists.html new file mode 100644 index 000000000..daae9b336 --- /dev/null +++ b/sites/discogs/templates/user_lists.html @@ -0,0 +1,18 @@ +{% extends "base.html" %} +{% block title %}{{ u.username }} – Lists | Discogs{% endblock %} +{% block content %} +

    {{ u.username }}'s Lists

    +{% if current_user.is_authenticated and current_user.id == u.id %} +

    + Create new list

    +{% endif %} +{% for l in lists %} +
    +

    {{ l.title }} + {% if not l.is_public %}(private){% endif %}

    +

    {{ l.items.count() }} items · created {{ l.created_at|relative }}

    + {% if l.description %}

    {{ l.description }}

    {% endif %} +
    +{% else %} +

    No lists yet.

    +{% endfor %} +{% endblock %} diff --git a/sites/discogs/templates/user_reviews.html b/sites/discogs/templates/user_reviews.html new file mode 100644 index 000000000..af56052a5 --- /dev/null +++ b/sites/discogs/templates/user_reviews.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} +{% import "_macros.html" as m %} +{% block title %}{{ u.username }} – Reviews | Discogs{% endblock %} +{% block content %} +

    {{ u.username }}'s Reviews

    +

    {{ reviews|length }} reviews

    +
    +{% for rv in reviews %} +
    +
    + {{ rv.release.title }} + {{ rv.release.display_artist }} + {% if rv.rating %}{% for i in range(rv.rating) %}★{% endfor %}{% endif %} + {{ rv.created_at|relative }} +
    +
    {{ rv.body }}
    +
    +{% else %} +

    No reviews yet.

    +{% endfor %} +
    +{% endblock %} diff --git a/sites/discogs/templates/wantlist.html b/sites/discogs/templates/wantlist.html new file mode 100644 index 000000000..b5e1bdfe7 --- /dev/null +++ b/sites/discogs/templates/wantlist.html @@ -0,0 +1,54 @@ +{% extends "base.html" %} +{% import "_macros.html" as m %} +{% block title %}{{ u.username }}'s Wantlist | Discogs{% endblock %} + +{% block content %} +

    {{ u.username }}'s Wantlist

    +

    {{ pag.total }} items wanted

    + +
    +
    + + + + + + + + + {% if current_user.is_authenticated and current_user.id == u.id %}{% endif %} + + + + {% for w in pag.items %} + + + + + + + {% if current_user.is_authenticated and current_user.id == u.id %} + + {% endif %} + + {% endfor %} + +
    ReleaseMin GradeLowest PriceAdded
    + + + + + {{ w.release.title }} +
    {{ w.release.display_artist }} · {{ w.release.year or '' }}
    +
    {{ w.min_grade }}{{ w.release.lowest_price|price }}{{ w.added_at|relative }} +
    + + + +
    +
    +
    + {% if not pag.items %}

    Wantlist is empty.

    {% endif %} + {{ m.pagination(pag, 'user_wantlist', params={'username': u.username}) }} +
    +{% endblock %} diff --git a/sites/discogs/tests/test_app.py b/sites/discogs/tests/test_app.py new file mode 100644 index 000000000..a936d6edf --- /dev/null +++ b/sites/discogs/tests/test_app.py @@ -0,0 +1,502 @@ +"""Behavioral regressions; use an isolated database and synthetic fixtures.""" +import importlib +from datetime import datetime +import os +import re +from pathlib import Path +import sys +import tempfile +import unittest +from flask.testing import FlaskClient + +SITE = Path(__file__).resolve().parents[1] +TEST_DIR = tempfile.TemporaryDirectory(prefix="discogs-tests-") +os.environ["DISCOGS_INSTANCE_DIR"] = TEST_DIR.name +os.environ["DISCOGS_SKIP_SEED"] = "1" +sys.path.insert(0, str(SITE)) +m = importlib.import_module("app") + + +class IsolatedClient(FlaskClient): + def open(self, *args, **kwargs): + # Keep request-local login state separate from the fixture's DB context. + with m.app.app_context(): + return super().open(*args, **kwargs) + + +m.app.test_client_class = IsolatedClient + + +class AppTests(unittest.TestCase): + def setUp(self): + m.app.config.update(TESTING=True, WTF_CSRF_ENABLED=False) + self.context = m.app.app_context() + self.context.push() + self.reset_fixtures() + + def reset_fixtures(self): + m.db.session.rollback() + m.db.session.remove() + m.db.drop_all() + m.db.create_all() + password = m.bcrypt.generate_password_hash("test-pass").decode() + m.db.session.add_all([ + m.User(id=1, username="alice", email="alice@example.com", password_hash=password), + m.User(id=2, username="bob", email="bob@example.com", password_hash=password), + m.Artist(id=1, name="Bob Marley", slug="bob-marley"), + m.Artist(id=2, name="Ziggy Marley", slug="ziggy-marley"), + m.Label(id=1, name="Example Records", slug="example"), + m.Genre(id=1, name="Reggae", slug="reggae"), + m.Style(id=1, name="Dub", slug="dub"), + ]) + m.db.session.flush() + m.db.session.add_all([ + m.Release(id=1, discogs_id=1001, artist_id=1, title="Studio Session", have_count=10), + m.Release(id=2, discogs_id=1002, artist_id=2, title="Another Session", have_count=100), + m.List(id=1, user_id=1, title="Public selection", is_public=True), + m.List(id=2, user_id=1, title="Private selection", description="Private notes", is_public=False), + m.Forum(id=1, name="General Discussion", slug="general"), + m.Forum(id=2, name="Help & Feedback", slug="help"), + m.Thread(id=1, forum_id=1, user_id=1, title="Listening notes"), + ]) + m.db.session.commit() + self.client = m.app.test_client() + + def tearDown(self): + m.db.session.remove() + self.context.pop() + + def login(self, username="alice", target=None): + return self.client.post("/login", query_string={"next": target} if target else {}, + data={"username": username, "password": "test-pass"}) + + def listing(self, **overrides): + data = {"release_id": "1001", "price": "42.00", "currency": "USD", + "media_condition": "Very Good Plus (VG+)", + "sleeve_condition": "Very Good Plus (VG+)", "shipping_from": "United Kingdom"} + data.update(overrides) + return self.client.post("/sell", data=data) + + def test_private_lists_are_visible_only_to_owner(self): + for identity in (None, "bob", "alice"): + with self.subTest(identity=identity): + self.client = m.app.test_client() + if identity: + self.login(identity) + html = self.client.get("/user/alice/lists").get_data(as_text=True) + self.assertIn("Public selection", html) + self.assertEqual("Private selection" in html, identity == "alice") + self.assertEqual("Private notes" in html, identity == "alice") + self.assertEqual(self.client.get("/list/2").status_code, 200 if identity == "alice" else 403) + + def test_other_user_cannot_add_to_private_list(self): + self.login("bob") + self.assertEqual(self.client.post("/list/2/add", data={"release_id": 1}).status_code, 403) + self.assertEqual(m.ListItem.query.count(), 0) + + def test_list_add_accepts_visible_discogs_release_id(self): + self.login() + response = self.client.post( + "/list/1/add", + data={"release_id": 1002, "comment": "The edition shown in the release URL."}, + ) + self.assertEqual(response.status_code, 302) + item = m.ListItem.query.one() + self.assertEqual((item.list_id, item.release_id, item.comment), + (1, 2, "The edition shown in the release URL.")) + + def test_review_for_missing_release_has_no_side_effect(self): + self.login() + response = self.client.post("/review", data={"release_id": 999999, "body": "Must not persist"}) + self.assertEqual(response.status_code, 404) + self.assertEqual(m.Review.query.count(), 0) + + def test_invalid_sale_values_do_not_change_database(self): + cases = [{"price": value} for value in ("-1", "0", "nan", "inf", "1e309", "1.001", "999999999999")] + cases += [{"currency": "ZZZ"}, {"media_condition": "invented"}, + {"sleeve_condition": "invented"}, {"shipping_from": ""}] + for data in cases: + with self.subTest(data=data): + self.reset_fixtures() + self.login() + response = self.listing(**data) + self.assertLess(response.status_code, 500) + self.assertEqual(m.Listing.query.count(), 0) + self.assertFalse(m.db.session.get(m.User, 1).is_seller) + self.assertEqual(m.db.session.get(m.Release, 1).num_for_sale, 0) + + def test_valid_sale_has_bound_release_and_currency(self): + self.login() + response = self.listing(currency="GBP") + self.assertEqual(response.status_code, 302) + self.assertEqual(response.location, "/marketplace?currency=GBP&sort=newest") + row = m.Listing.query.one() + self.assertEqual((row.user_id, row.release_id, row.price, row.currency), (1, 1, 42, "GBP")) + self.assertTrue(m.db.session.get(m.User, 1).is_seller) + + def test_marketplace_price_sort_is_scoped_to_one_currency(self): + m.db.session.add_all([ + m.Listing(user_id=1, release_id=1, price=10, currency="USD", shipping_from="US"), + m.Listing(user_id=2, release_id=2, price=1, currency="GBP", shipping_from="UK"), + ]) + m.db.session.commit() + usd = self.client.get("/marketplace?sort=price_asc").get_data(as_text=True) + self.assertIn("USD 10.00", usd) + self.assertNotIn("GBP 1.00", usd) + gbp = self.client.get("/marketplace?sort=price_asc¤cy=GBP").get_data(as_text=True) + self.assertIn("GBP 1.00", gbp) + self.assertNotIn("USD 10.00", gbp) + + def test_login_rejects_foreign_or_ambiguous_returns(self): + for target in ("https://foreign.invalid/", "//foreign.invalid/", "/\\foreign.invalid/", + "javascript:alert(1)", " https://foreign.invalid/", "/%5cforeign.invalid/"): + with self.subTest(target=target): + self.client = m.app.test_client() + self.assertEqual(self.login(target=target).location, "/") + + def test_login_preserves_local_return(self): + self.assertEqual(self.login(target="/list/1?view=items").location, "/list/1?view=items") + + def test_contextual_login_links_preserve_the_current_page(self): + release = self.client.get("/release/1001").get_data(as_text=True) + thread = self.client.get("/thread/1").get_data(as_text=True) + self.assertIn('href="/login?next=/release/1001"', release) + self.assertIn('href="/login?next=/thread/1"', thread) + self.assertNotIn('href="/login"', release) + self.assertNotIn('href="/login"', thread) + + def test_overlong_login_password_is_rejected_without_server_error(self): + response = self.client.post("/login", data={"username": "alice", "password": "a" * 73}) + self.assertEqual(response.status_code, 200) + self.assertIn("Invalid credentials", response.get_data(as_text=True)) + self.assertEqual(self.client.get("/settings").status_code, 302) + + def test_mutations_ignore_foreign_referrer(self): + self.login() + response = self.client.post("/collection/add", data={"release_id": 1}, + headers={"Referer": "https://foreign.invalid/"}) + self.assertEqual(response.location, "/release/1001") + + def test_logout_requires_post_and_csrf(self): + self.login() + self.assertEqual(self.client.get("/logout").status_code, 405) + m.app.config["WTF_CSRF_ENABLED"] = True + self.assertEqual(self.client.post("/logout").status_code, 400) + self.assertEqual(self.client.get("/settings").status_code, 200) + + def test_invalid_pages_do_not_crash(self): + for route in ("/search", "/marketplace", "/lists", "/user/alice/collection", "/user/alice/wantlist"): + with self.subTest(route=route): + self.assertEqual(self.client.get(route, query_string={"page": "oops"}).status_code, 200) + + def test_empty_entity_browse_is_populated(self): + self.assertIn("Bob Marley", self.client.get("/search?type=artist").get_data(as_text=True)) + self.assertIn("Example Records", self.client.get("/search?type=label").get_data(as_text=True)) + + def test_homepage_mirrors_live_sections_with_local_media(self): + m.db.session.add_all([ + m.Listing(user_id=1, release_id=1, price=10, currency="USD", shipping_from="US"), + m.Listing(user_id=2, release_id=2, price=900, currency="USD", shipping_from="US"), + m.Listing(user_id=2, release_id=2, price=5, currency="GBP", shipping_from="UK"), + ]) + m.db.session.get(m.Release, 1).num_for_sale = 1 + m.db.session.get(m.Release, 1).lowest_price = 10 + m.db.session.get(m.Release, 2).num_for_sale = 1 + m.db.session.get(m.Release, 2).lowest_price = 900 + m.db.session.commit() + html = self.client.get("/").get_data(as_text=True) + for title in ("This Week’s Best-Selling Vinyl Records & CDs", + "This Week’s Most Valuable Vinyl Records & CDs", + "This Week’s Most Collected Vinyl Records & CDs"): + self.assertIn(title, html) + self.assertIn("1 copy from $900.00", html) + self.assertIn('class="rc-btn rc-prev"', html) + self.assertIn('class="rc-btn rc-next"', html) + self.assertIn("/static/external_cache/homepage/phone-hands.png", html) + self.assertNotIn("https://www.discogs.com/images/app/", html) + # Most Valuable ranks by the highest USD asking price, not the GBP listing. + valuable = m.most_valuable_releases() + self.assertEqual([row.id for row in valuable], [2, 1]) + + def _local_links(self, html, container_class): + from html.parser import HTMLParser + + class Links(HTMLParser): + def __init__(self): + super().__init__() + self.depth = None + self.hrefs = [] + + def handle_starttag(self, tag, attrs): + values = dict(attrs) + classes = (values.get("class") or "").split() + if self.depth is None and container_class in classes: + self.depth = 0 + if self.depth is not None: + if tag == "a" and values.get("href"): + self.hrefs.append(values["href"]) + if tag not in ("img", "input", "br", "meta", "link", "path"): + self.depth += 1 + + def handle_endtag(self, tag): + if self.depth is not None and tag not in ("img", "input", "br", "meta", "link", "path"): + self.depth -= 1 + if self.depth <= 0: + self.depth = None + + parser = Links() + parser.feed(html) + return parser.hrefs + + def test_header_and_footer_targets_are_local_and_resolve(self): + html = self.client.get("/").get_data(as_text=True) + hrefs = self._local_links(html, "site-header") + self._local_links(html, "site-footer") + self.assertGreater(len(hrefs), 40) + for href in hrefs: + with self.subTest(href=href): + self.assertFalse(href.startswith(("http://", "https://", "//")), href) + if href.startswith("#"): + continue + path = href.split("#")[0] + status = self.client.get(path).status_code + self.assertIn(status, (200, 302), (href, status)) + for label in ("Explore Discography", "Shop Music", "Sell Music", "Community", "Digs", + "Advanced Search", "Shop My Wants", "List Explorer", "Monthly Leaderboard", + "Help & Resources", "Keyboard Shortcuts", "Cookie Settings", "Impressum", + "Sign Up / Log In"): + self.assertIn(label, html) + + def test_menus_and_logout_do_not_depend_on_javascript(self): + # Menus are native
    , so every destination is in the DOM and reachable + # by clicking the summary even if site.js never runs. + self.login() + html = self.client.get("/").get_data(as_text=True) + self.assertIn("
    alice<", html) + + def test_forum_recent_search_and_thread_layout(self): + recent = self.client.get("/forum/recent").get_data(as_text=True) + self.assertIn("Listening notes", recent) + self.assertIn("Recent Thread Activity", recent) + self.assertIn("Listening notes", self.client.get("/forum/search?query=Listening").get_data(as_text=True)) + self.assertIn("No threads to show", self.client.get("/forum/search?query=zzz-nothing").get_data(as_text=True)) + self.assertEqual(self.client.get("/forum/posted").status_code, 302) + index = self.client.get("/forum").get_data(as_text=True) + self.assertIn("Your place to talk about music", index) + self.assertIn("1 threads", index) + thread = self.client.get("/thread/1").get_data(as_text=True) + self.assertIn("General Discussion", thread) + self.assertIn("You must be logged in to post.", thread) + self.login() + self.assertIn("Post reply", self.client.get("/thread/1").get_data(as_text=True)) + + def test_auth_pages_mirror_live_copy_and_keep_field_names(self): + login = self.client.get("/login").get_data(as_text=True) + for text in ("Log in to Discogs to continue", "Forgot password?", 'name="username"', 'name="password"', "Sign up"): + self.assertIn(text, login) + register = self.client.get("/register").get_data(as_text=True) + for text in ("Sign up to Discogs to continue", 'name="username"', 'name="email"', 'name="password"', 'name="location"'): + self.assertIn(text, register) + + def test_release_page_shows_live_sections_and_keeps_action_forms(self): + html = self.client.get("/release/1001").get_data(as_text=True) + for text in ("[r1001]", "Statistics", "Have:", "Want:", "Add to Collection", "Add to Wantlist", "Sell a copy"): + self.assertIn(text, html) + self.login() + html = self.client.get("/release/1001").get_data(as_text=True) + self.assertIn('action="/collection/add"', html) + self.assertIn('name="folder"', html) + self.assertIn('action="/wantlist/add"', html) + self.assertIn('action="/rate"', html) + self.assertIn('action="/review"', html) + + def test_advanced_search_filters_by_label_and_catalog_number(self): + release = m.db.session.get(m.Release, 1) + release.labels.append(m.db.session.get(m.Label, 1)) + release.catno = "RS 9242" + m.db.session.commit() + page = self.client.get("/search/advanced").get_data(as_text=True) + self.assertIn('action="/search"', page) + self.assertEqual([row.id for row in m.search_releases("", label="Example").items], [1]) + self.assertEqual([row.id for row in m.search_releases("", catno="9242").items], [1]) + self.assertEqual([row.id for row in m.search_releases("", catno="0000").items], []) + self.assertEqual(self.client.get("/search?label=Example&type=all").status_code, 200) + + def test_relevance_prefers_full_token_match_over_popularity(self): + results = m.search_releases("Bob Marley").items + self.assertEqual([row.id for row in results], [1, 2]) + + def test_newest_sort_uses_the_source_addition_date(self): + m.db.session.get(m.Release, 1).added_at = datetime(2025, 1, 1) + m.db.session.get(m.Release, 2).added_at = datetime(2024, 1, 1) + m.db.session.commit() + results = m.search_releases("", sort="newest").items + self.assertEqual([row.id for row in results], [1, 2]) + + def test_facets_preserve_other_filters(self): + # Facets now list only values present in the result set (live behaviour), so give + # the matching release the genre/style/year the query asks for. + release = m.db.session.get(m.Release, 1) + release.year = 1978 + release.genres.append(m.db.session.get(m.Genre, 1)) + release.styles.append(m.db.session.get(m.Style, 1)) + m.db.session.commit() + html = self.client.get("/search?q=Bob&genre=reggae&year=1978").get_data(as_text=True) + self.assertIn("Search results for Bob", html) + self.assertIn("Release (1)", html) + from html.parser import HTMLParser + from urllib.parse import parse_qs, urlsplit + + class Links(HTMLParser): + def __init__(self): + super().__init__() + self.urls = [] + + def handle_starttag(self, tag, attrs): + if tag in ("a", "option"): + values = dict(attrs) + self.urls.append(values.get("href", values.get("value", ""))) + + links = Links() + links.feed(html) + choices = [parse_qs(urlsplit(url).query) for url in links.urls if "style=dub" in url] + self.assertTrue(choices) + self.assertEqual(choices[0].get("genre"), ["reggae"]) + self.assertEqual(choices[0].get("year"), ["1978"]) + + def test_collection_validation_and_duplicate_add_are_atomic(self): + self.login() + for field, value in (("folder", "invalid"), ("media_condition", "invalid"), ("sleeve_condition", "invalid")): + self.client.post("/collection/add", data={"release_id": 1, field: value}) + self.assertEqual(m.CollectionItem.query.count(), 0) + self.assertEqual(m.db.session.get(m.Release, 1).have_count, 10) + for _ in range(2): + self.client.post("/collection/add", data={"release_id": 1, "folder": "Vinyl"}) + self.assertEqual(m.CollectionItem.query.count(), 1) + self.assertEqual(m.db.session.get(m.Release, 1).have_count, 11) + + def test_wantlist_rejects_unknown_grade(self): + self.login() + self.client.post("/wantlist/add", data={"release_id": 1, "min_grade": "invalid"}) + self.assertEqual(m.WantlistItem.query.count(), 0) + + def test_registration_rejects_invalid_identity_and_overlong_password(self): + for overrides in ({"email": "@"}, {"username": "../escape"}, {"password": "a" * 73}): + with self.subTest(overrides=overrides): + self.reset_fixtures() + data = {"username": "new_user", "email": "new@example.com", "password": "test-pass"} + data.update(overrides) + response = self.client.post("/register", data=data) + self.assertLess(response.status_code, 500) + self.assertEqual(m.User.query.count(), 2) + + def test_all_is_not_a_writable_collection_folder(self): + """"All" is the unfiltered view, not a folder. While it sat in the folder enum it + was offered in the form, accepted by the write path, and then unreachable: the + collection list treats folder == "All" as "do not filter", so such a row could + never be viewed as a folder again.""" + self.assertNotIn("All", m.COLLECTION_FOLDERS) + self.assertEqual("All", m.UNFILTERED_FOLDER) + self.login("alice") + page = self.client.get("/release/1001").get_data(as_text=True) + select = re.search(r']*name="folder"[^>]*>(.*?)', page, re.S) + self.assertIsNotNone(select) + self.assertNotIn(">All<", select.group(1)) + self.client.post("/collection/add", data={ + "release_id": 1, "folder": "All", + "media_condition": "Near Mint (NM or M-)", + "sleeve_condition": "Near Mint (NM or M-)"}, follow_redirects=True) + self.assertEqual(0, m.CollectionItem.query.count(), + "a rejected folder must not write a collection row") + + def test_collection_tab_row_lists_all_exactly_once(self): + self.login("alice") + page = self.client.get("/user/alice/collection").get_data(as_text=True) + tabs = re.findall(r'folder=([^"&]+)"', page) + self.assertEqual(1, tabs.count("All"), f"tabs={tabs}") + + def test_relative_time_matches_live_singular_and_plural(self): + """Live Discogs writes "1 day ago" and "12 minutes ago"; every unit here was + hard-coded plural and the minute unit was abbreviated to "min".""" + from datetime import datetime, timedelta + now = datetime.utcnow() + for delta, expected in [ + (timedelta(minutes=1), "1 minute ago"), (timedelta(minutes=12), "12 minutes ago"), + (timedelta(hours=1), "1 hour ago"), (timedelta(hours=11), "11 hours ago"), + (timedelta(days=1), "1 day ago"), (timedelta(days=10), "10 days ago"), + (timedelta(days=40), "1 month ago"), (timedelta(days=400), "1 year ago"), + ]: + with self.subTest(expected=expected): + self.assertEqual(expected, m.relative_time(now - delta)) + + +if __name__ == "__main__": + unittest.main() diff --git a/sites/discogs/tests/test_catalog.py b/sites/discogs/tests/test_catalog.py new file mode 100644 index 000000000..c3d98b352 --- /dev/null +++ b/sites/discogs/tests/test_catalog.py @@ -0,0 +1,95 @@ +"""Source-import contract tests using synthetic API-shaped records.""" +from copy import deepcopy +import sys +from pathlib import Path +import unittest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from catalog import normalize_release + + +def release_record(): + return { + "id": 123, "resource_url": "https://api.discogs.com/releases/123", + "title": "Example", "year": 0, "master_id": 456, + "artists": [{"id": 1, "name": "Artist", "anv": "Artist Alias", "join": "&"}, + {"id": 2, "name": "Guest", "join": ""}], + "labels": [{"id": 11, "name": "Label", "catno": "CAT-1"}, + {"id": 11, "name": "Label", "catno": "CAT-2"}], + "formats": [{"name": "Vinyl", "qty": "2", "text": "Blue", + "descriptions": ["LP", "Album"]}, + {"name": "CD", "qty": "1", "descriptions": ["Album"]}], + "genres": ["Rock"], "styles": ["Alternative Rock"], + "tracklist": [{"position": "", "title": "Suite", "type_": "heading"}, + {"position": "1", "title": "First", "type_": "track", "duration": ""}, + {"position": "2", "title": "Second", "type_": "index", "duration": "", + "sub_tracks": [{"position": "2a", "title": "Part", "type_": "track"}]}, + {"position": "10", "title": "Last", "type_": "track", "duration": "3:00"}], + "community": {"have": 0, "want": 0, "rating": {"average": 0, "count": 0}}, + "identifiers": [{"type": "Barcode", "value": "001"}, + {"type": "Matrix / Runout", "value": "SIDE-A"}], + "data_quality": "Needs Vote", "notes": "Source notes.", + } + + +class CatalogTests(unittest.TestCase): + def test_missing_information_and_source_zero_are_not_invented(self): + result = normalize_release(release_record()) + self.assertIsNone(result["year"]) + self.assertEqual(result["country"], "") + self.assertEqual(result["released"], "") + self.assertIsNone(result["added_at"]) + self.assertEqual(result["data_quality"], "Needs Vote") + self.assertEqual(result["tracks"][1]["duration"], "") + self.assertEqual(result["source_community"]["have"], 0) + + def test_track_order_headings_and_subtracks_survive(self): + tracks = normalize_release(release_record())["tracks"] + self.assertEqual([t["title"] for t in tracks], ["Suite", "First", "Second", "Part", "Last"]) + self.assertEqual([t["kind"] for t in tracks], ["heading", "track", "index", "track", "track"]) + self.assertEqual([t["depth"] for t in tracks], [0, 0, 0, 1, 0]) + + def test_artist_aliases_format_quantities_and_label_bindings_survive(self): + result = normalize_release(release_record()) + self.assertEqual(result["artist_credit"], "Artist Alias & Guest") + self.assertEqual(result["format_description"], "2 × Vinyl, LP, Album, Blue; CD, Album") + self.assertEqual([l["catno"] for l in result["labels"]], ["CAT-1", "CAT-2"]) + self.assertEqual(result["primary_format"], "Vinyl") + + def test_master_identity_does_not_come_from_title(self): + first = release_record() + second = deepcopy(first) + second["master_id"] = 789 + self.assertNotEqual(normalize_release(first)["master_id"], normalize_release(second)["master_id"]) + del second["master_id"] + self.assertIsNone(normalize_release(second)["master_id"]) + + def test_search_summary_or_wrong_identity_cannot_be_imported(self): + for change in ({"resource_url": "https://api.discogs.com/releases/999"}, + {"resource_url": "https://example.com/releases/123"}, + {"id": None}, {"artists": []}): + source = release_record() + source.update(change) + with self.subTest(change=change), self.assertRaises(ValueError): + normalize_release(source) + source = release_record() + del source["tracklist"] + with self.assertRaises(ValueError): + normalize_release(source) + + def test_legitimate_empty_tracklist_is_preserved(self): + source = release_record() + source["tracklist"] = [] + self.assertEqual(normalize_release(source)["tracks"], []) + + def test_source_native_missing_artist_and_label_ids_are_preserved(self): + source = release_record() + source["artists"] = [{"id": None, "name": "Unlinked Artist", "join": ""}] + source["labels"] = [{"id": None, "name": "Unlinked Label", "catno": "UL-1"}] + result = normalize_release(source) + self.assertEqual(result["artists"], source["artists"]) + self.assertEqual(result["labels"], source["labels"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/sites/discogs/tests/test_refresh_catalog.py b/sites/discogs/tests/test_refresh_catalog.py new file mode 100644 index 000000000..de8fcd2e9 --- /dev/null +++ b/sites/discogs/tests/test_refresh_catalog.py @@ -0,0 +1,198 @@ +"""Transactional seed refresh and source-to-page regressions.""" +import hashlib +import json +from pathlib import Path +import sqlite3 +import tempfile +import unittest + +from PIL import Image + +import test_app as harness +from test_catalog import release_record +from refresh_catalog import refresh + +m = harness.m + + +class RefreshCatalogTests(unittest.TestCase): + def setUp(self): + self.context = m.app.app_context() + self.context.push() + m.db.session.remove() + m.db.drop_all() + m.db.create_all() + m.db.session.add_all([ + m.Artist(id=1, name="Original Artist", slug="original"), + m.User(id=1, username="fixture", email="fixture@example.com", password_hash="unused"), + ]) + m.db.session.flush() + m.db.session.add_all([ + m.Release(id=1, discogs_id=123, title="Old title", artist_id=1, + country="Invented", image_path="images/old.jpg"), + m.Release(id=2, discogs_id=90000001, title="Untraceable edition", artist_id=1), + m.List(id=1, user_id=1, title="Original list"), + ]) + m.db.session.flush() + for rid in (1, 2): + m.db.session.add_all([ + m.CollectionItem(user_id=1, release_id=rid), + m.ListItem(list_id=1, release_id=rid, position=rid), + m.Rating(user_id=1, release_id=rid, value=4), + m.Listing(user_id=1, release_id=rid, price=10 if rid == 1 else 5, + currency="USD", shipping_from="US"), + m.Track(release_id=rid, title="Generated track", position="A1"), + ]) + m.db.session.add(m.Listing(user_id=1, release_id=1, price=1, + currency="GBP", shipping_from="UK")) + m.db.session.commit() + self.directory = tempfile.TemporaryDirectory(prefix="discogs-refresh-") + self.root = Path(self.directory.name) + self.seed = self.root / "original.db" + self.output = self.root / "candidate.db" + with sqlite3.connect(m.db.engine.url.database) as source, sqlite3.connect(self.seed) as target: + source.backup(target) + self.before_hash = hashlib.sha256(self.seed.read_bytes()).hexdigest() + self.sources = self.root / "sources" + self.sources.mkdir() + self.capture([release_record()]) + + def capture(self, records): + receipts = [] + for source in records: + raw = json.dumps(source, ensure_ascii=False).encode() + name = f"{source['id']}.json" + (self.sources / name).write_bytes(raw) + receipts.append({"url": source["resource_url"], "status": 200, "file": name, + "sha256": hashlib.sha256(raw).hexdigest(), + "observed_at": "2026-09-10T00:00:00+00:00"}) + (self.sources / "requests.json").write_text(json.dumps(receipts)) + + def capture_image(self, source_url): + images = self.root / "images" + images.mkdir(exist_ok=True) + path = images / "123.jpg" + Image.new("RGB", (3, 2), "red").save(path, format="JPEG") + receipt = { + "release_id": 123, + "source_url": source_url, + "status": 200, + "file": path.name, + "sha256": hashlib.sha256(path.read_bytes()).hexdigest(), + "observed_at": "2026-09-10T00:00:00+00:00", + "width": 3, + "height": 2, + } + (images / "requests.json").write_text(json.dumps([receipt])) + return images + + def tearDown(self): + m.db.session.remove() + self.context.pop() + self.directory.cleanup() + + def test_refresh_preserves_valid_community_and_removes_only_invalid_references(self): + result = refresh(self.seed, self.sources, [90000001], self.output) + self.assertEqual(result["excluded_internal_ids"], [2]) + self.assertEqual(hashlib.sha256(self.seed.read_bytes()).hexdigest(), self.before_hash) + with sqlite3.connect(self.output) as db: + self.assertEqual(db.execute("PRAGMA foreign_key_check").fetchall(), []) + self.assertEqual(db.execute("SELECT username FROM users").fetchall(), [("fixture",)]) + self.assertEqual(db.execute("SELECT release_id FROM collection_items").fetchall(), [(1,)]) + self.assertEqual(db.execute("SELECT title FROM lists").fetchall(), [("Original list",)]) + self.assertEqual(db.execute("SELECT release_id,position FROM list_items").fetchall(), [(1, 1)]) + self.assertEqual(db.execute("""SELECT country,year,have_count,rating_count,avg_rating, + num_for_sale,lowest_price,image_path FROM releases""").fetchone(), + ("", None, 1, 1, 4.0, 2, 10.0, "")) + self.assertEqual(db.execute("SELECT title FROM tracks ORDER BY id").fetchall(), + [(t,) for t in ("Suite", "First", "Second", "Part", "Last")]) + + def test_missing_capture_fails_before_output_or_source_mutation(self): + with self.assertRaisesRegex(ValueError, "Missing verified sources"): + refresh(self.seed, self.sources, [], self.output) + self.assertFalse(self.output.exists()) + self.assertEqual(hashlib.sha256(self.seed.read_bytes()).hexdigest(), self.before_hash) + + def test_bad_capture_hash_is_rejected(self): + (self.sources / "123.json").write_text('{}') + with self.assertRaisesRegex(ValueError, "hash mismatch"): + refresh(self.seed, self.sources, [90000001], self.output) + self.assertFalse(self.output.exists()) + + def test_existing_output_cannot_be_overwritten(self): + self.output.write_bytes(b"keep this") + with self.assertRaises(ValueError): + refresh(self.seed, self.sources, [90000001], self.output) + self.assertEqual(self.output.read_bytes(), b"keep this") + + def test_same_title_different_master_is_not_merged(self): + second = release_record() + second.update(id=124, resource_url="https://api.discogs.com/releases/124", master_id=789) + self.capture([release_record(), second]) + refresh(self.seed, self.sources, [90000001], self.output) + with sqlite3.connect(self.output) as db: + self.assertEqual(db.execute("SELECT count(DISTINCT master_id) FROM releases").fetchone()[0], 2) + self.assertEqual(db.execute("SELECT year,main_release_id FROM masters").fetchall(), [(None, None), (None, None)]) + + def test_captured_credits_details_and_order_reach_the_ui(self): + refresh(self.seed, self.sources, [90000001], self.output) + m.db.session.remove() + with sqlite3.connect(self.output) as source, sqlite3.connect(m.db.engine.url.database) as target: + source.backup(target) + html = m.app.test_client().get("/release/123").get_data(as_text=True) + for value in ("Artist Alias & Guest", "2 × Vinyl, LP, Album, Blue", "CAT-1", "CAT-2", "Source notes.", "SIDE-A"): + self.assertIn(value, html) + release = m.Release.query.filter_by(discogs_id=123).one() + self.assertEqual([(artist.discogs_id, display, join) + for artist, display, join in release.artist_entries], + [(1, "Artist Alias", "&"), (2, "Guest", "")]) + self.assertLess(html.index("First"), html.index("Second")) + self.assertLess(html.index("Second"), html.index("Last")) + self.assertNotIn("Image courtesy Wikipedia", html) + self.assertEqual([r.discogs_id for r in m.search_releases("Guest").items], [123]) + guest = m.Artist.query.filter_by(discogs_id=2).one() + self.assertIn("Example", m.app.test_client().get(f"/artist/{guest.id}").get_data(as_text=True)) + + def test_missing_entity_ids_remain_unlinked_without_merging_distinct_names(self): + source = release_record() + source["artists"] = [ + {"id": None, "name": "First Unlinked Artist", "join": "&"}, + {"id": None, "name": "Second Unlinked Artist", "join": ""}, + ] + source["labels"] = [ + {"id": None, "name": "First Unlinked Label", "catno": "A"}, + {"id": None, "name": "Second Unlinked Label", "catno": "B"}, + ] + self.capture([source]) + refresh(self.seed, self.sources, [90000001], self.output) + with sqlite3.connect(self.output) as db: + self.assertEqual(db.execute( + "SELECT name,discogs_id FROM artists WHERE name LIKE '%Unlinked Artist' ORDER BY name" + ).fetchall(), [("First Unlinked Artist", None), ("Second Unlinked Artist", None)]) + self.assertEqual(db.execute( + "SELECT name,discogs_id FROM labels WHERE name LIKE '%Unlinked Label' ORDER BY name" + ).fetchall(), [("First Unlinked Label", None), ("Second Unlinked Label", None)]) + + def test_only_verified_release_image_is_referenced(self): + source = release_record() + image_url = "https://i.discogs.com/example/release-123.jpeg" + source["images"] = [{"type": "primary", "uri150": image_url, "width": 150, "height": 150}] + self.capture([source]) + images = self.capture_image(image_url) + refresh(self.seed, self.sources, [90000001], self.output, images=images) + with sqlite3.connect(self.output) as db: + self.assertEqual(db.execute("SELECT image_path FROM releases").fetchone()[0], + "images/release/123.jpg") + + def test_image_from_another_source_url_is_rejected(self): + source = release_record() + source["images"] = [{"type": "primary", "uri150": "https://i.discogs.com/right.jpeg"}] + self.capture([source]) + images = self.capture_image("https://i.discogs.com/wrong.jpeg") + with self.assertRaisesRegex(ValueError, "image URL mismatch"): + refresh(self.seed, self.sources, [90000001], self.output, images=images) + self.assertFalse(self.output.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/sites/discogs/verify/test_environment_quality.py b/sites/discogs/verify/test_environment_quality.py new file mode 100644 index 000000000..8109f454b --- /dev/null +++ b/sites/discogs/verify/test_environment_quality.py @@ -0,0 +1,72 @@ +"""Static contract checks for the Discogs task and verifier bundle.""" + +from __future__ import annotations + +import json +import unittest +from pathlib import Path + + +SITE_DIR = Path(__file__).resolve().parent.parent +REPO_ROOT = SITE_DIR.parents[1] +EXPECTED_KEYS = { + "web_name", + "id", + "ques", + "web", + "upstream_url", + "verifier_path", + "judge_rubric", +} + + +class EnvironmentQualityTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.rows = [ + json.loads(line) + for line in (SITE_DIR / "tasks.jsonl").read_text(encoding="utf-8").splitlines() + if line.strip() + ] + + def test_task_count_never_drops_below_fifteen(self) -> None: + self.assertGreaterEqual(len(self.rows), 15) + + def test_task_ids_schema_urls_and_verifier_paths_are_consistent(self) -> None: + self.assertEqual(len(self.rows), len({row["id"] for row in self.rows})) + for index, row in enumerate(self.rows): + with self.subTest(task=index): + self.assertEqual(EXPECTED_KEYS, set(row)) + self.assertEqual("Discogs", row["web_name"]) + self.assertEqual(f"Discogs--{index}", row["id"]) + self.assertEqual("http://localhost:40029/", row["web"]) + self.assertEqual("https://www.discogs.com/", row["upstream_url"]) + self.assertEqual( + f"sites/discogs/verify/verify_{index}.py", + row["verifier_path"], + ) + self.assertTrue((REPO_ROOT / row["verifier_path"]).is_file()) + self.assertNotIn("answer", row) + + def test_rubrics_are_nonempty_rules_without_hidden_answer_keys(self) -> None: + hidden_answers = { + 0: ("11:24", "RS 9242"), + 1: ("CP32-5244", "4:15"), + 2: ("UCCO-9038", "Joe Tarantino"), + 3: ("9732909", "8837214", "Dolby System", "Repress"), + 4: ("Colin Greenwood", "As The Waters Cover The Sea"), + 5: ("Kelly Blue", "kosmische", "8.27", "SMJ-6114", "SRS-6059"), + 6: ("Jeff Beck", "IGDA 1063/64"), + } + for index, row in enumerate(self.rows): + with self.subTest(task=index): + rubric = row["judge_rubric"] + self.assertTrue(rubric.startswith("FACT CHECKPOINTS:")) + self.assertIn("FAIL", rubric) + for hidden in hidden_answers.get(index, ()): + if hidden.casefold() not in row["ques"].casefold(): + self.assertNotIn(hidden.casefold(), rubric.casefold()) + + +if __name__ == "__main__": + unittest.main() diff --git a/sites/discogs/verify/test_verifiers.py b/sites/discogs/verify/test_verifiers.py new file mode 100644 index 000000000..8536054ab --- /dev/null +++ b/sites/discogs/verify/test_verifiers.py @@ -0,0 +1,796 @@ +"""End-to-end positive and adversarial tests for every Discogs verifier.""" + +from __future__ import annotations + +import json +import os +import shutil +import sqlite3 +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + + +VERIFY_DIR = Path(__file__).resolve().parent +SEED_DB = VERIFY_DIR.parent / "instance_seed" / "discogs.db" +BASE_URL = "http://localhost:40029" +# Grading must not depend on which host port the operator published the mirror on; +# the repository's own guide runs the same image on 41000+. These alternates are used +# to prove the verifiers accept any single loopback origin and still reject a +# trajectory that wanders off one. +ALT_ORIGIN = "http://127.0.0.1:41029" +# Each verifier is a short local SQLite job, but this suite is often run on a machine +# that is busy building or serving other environments. The cap exists to catch a hung +# verifier, not to benchmark the host, so it is generous and tunable. +VERIFIER_TIMEOUT = int(os.environ.get("WHR_VERIFIER_TIMEOUT", "180")) +FOREIGN_ORIGIN = "https://attacker.invalid" + + +def rewrite_origin(steps: list[dict], new_origin: str, old: str = BASE_URL) -> list[dict]: + """Re-host every recorded URL in `steps` on `new_origin`.""" + moved = [] + for step in steps: + copy = dict(step) + for key in ("url", "url_after"): + if copy.get(key): + copy[key] = copy[key].replace(old, new_origin) + moved.append(copy) + return moved + + +def navigate(path: str, *, origin: str = BASE_URL) -> dict: + return {"url": f"{origin}{path}", "action": "navigate", "params": {}} + + +def input_text(path: str, text: str, index: int = 1) -> dict: + return { + "url": f"{BASE_URL}{path}", + "action": "input", + "params": {"index": index, "text": text}, + } + + +def click_to(path: str, destination: str, text: str = "") -> dict: + return { + "url": f"{BASE_URL}{path}", + "url_after": f"{BASE_URL}{destination}", + "action": "click", + "params": {"text": text} if text else {"index": 1}, + } + + +def next_id(connection: sqlite3.Connection, table: str) -> int: + return connection.execute( + f"SELECT COALESCE(MAX(id), 0) + 1 FROM {table}" + ).fetchone()[0] + + +def user_id(connection: sqlite3.Connection, username: str) -> int: + return connection.execute( + "SELECT id FROM users WHERE username=?", (username,) + ).fetchone()[0] + + +def release_id(connection: sqlite3.Connection, discogs_id: int) -> int: + return connection.execute( + "SELECT id FROM releases WHERE discogs_id=?", (discogs_id,) + ).fetchone()[0] + + +class VerifierTests(unittest.TestCase): + maxDiff = None + + def run_verifier( + self, + task: int, + steps: list[dict], + answer: str, + mutate=None, + *, + task_id: str | None = None, + include_snapshots: bool = True, + base_url: str = BASE_URL, + env: dict[str, str] | None = None, + ) -> tuple[int, dict]: + with tempfile.TemporaryDirectory(prefix=f"discogs-verify-{task}-") as temp_dir: + root = Path(temp_dir) + run_dir = root / "run" + run_dir.mkdir() + command = [ + sys.executable, + str(VERIFY_DIR / f"verify_{task}.py"), + "--run_dir", + str(run_dir), + "--no_llm", + "true", + ] + if include_snapshots: + initial = root / "initial_state.db" + after = root / "after_state.db" + shutil.copy2(SEED_DB, initial) + shutil.copy2(SEED_DB, after) + if mutate: + connection = sqlite3.connect(after) + try: + mutate(connection) + connection.commit() + finally: + connection.close() + command.extend(["--initial_db", str(initial), "--after_db", str(after)]) + trajectory = { + "task_id": task_id or f"Discogs--{task}", + "start_url": f"{base_url}/", + "steps": steps, + "final_url": ( + steps[-1].get("url_after", steps[-1].get("url")) + if steps + else f"{base_url}/" + ), + "final_answer": answer, + } + (run_dir / "trajectory.json").write_text( + json.dumps(trajectory), encoding="utf-8" + ) + result = subprocess.run( + command, + capture_output=True, + text=True, + timeout=VERIFIER_TIMEOUT, + check=False, + env={**os.environ, **env} if env else None, + ) + try: + verdict = json.loads(result.stdout) + except json.JSONDecodeError as error: + self.fail( + f"task {task} emitted invalid JSON: stdout={result.stdout!r} " + f"stderr={result.stderr!r}: {error}" + ) + return result.returncode, verdict + + @staticmethod + def mutate_collection_add(connection: sqlite3.Connection) -> None: + uid = user_id(connection, "alice_crate") + rid = release_id(connection, 3376357) + connection.execute( + """INSERT INTO collection_items + (id,user_id,release_id,folder,media_condition,sleeve_condition,notes,added_at) + VALUES(?,?,?,?,?,?,?,?)""", + ( + next_id(connection, "collection_items"), + uid, + rid, + "Vinyl", + "Near Mint (NM or M-)", + "Near Mint (NM or M-)", + "", + "2026-09-12 09:00:00", + ), + ) + connection.execute( + "UPDATE releases SET have_count=have_count+1 WHERE id=?", (rid,) + ) + + @staticmethod + def mutate_wantlist_add(connection: sqlite3.Connection) -> None: + uid = user_id(connection, "bob_vinyl") + rid = release_id(connection, 11554424) + connection.execute( + """INSERT INTO wantlist_items + (id,user_id,release_id,min_grade,notes,added_at) + VALUES(?,?,?,?,?,?)""", + ( + next_id(connection, "wantlist_items"), + uid, + rid, + "Very Good (VG)", + "", + "2026-09-12 09:00:00", + ), + ) + connection.execute( + "UPDATE releases SET want_count=want_count+1 WHERE id=?", (rid,) + ) + + @staticmethod + def mutate_list_create(connection: sqlite3.Connection) -> None: + lid = next_id(connection, "lists") + connection.execute( + """INSERT INTO lists + (id,user_id,title,description,is_public,created_at) + VALUES(?,?,?,?,?,?)""", + ( + lid, + user_id(connection, "dave_techno"), + "Jazz Reissue Reference", + "Compare these two reissues before the next listening session.", + 1, + "2026-09-12 09:00:00", + ), + ) + for position, comment, discogs_id in ( + (1, "1966 US stereo edition", 3376357), + (2, "2007 Japan CD edition", 2379219), + ): + connection.execute( + """INSERT INTO list_items + (id,list_id,release_id,artist_id,label_id,comment,position) + VALUES(?,?,?,?,?,?,?)""", + ( + next_id(connection, "list_items"), + lid, + release_id(connection, discogs_id), + None, + None, + comment, + position, + ), + ) + + @staticmethod + def mutate_listing_add(connection: sqlite3.Connection) -> None: + uid = user_id(connection, "bob_vinyl") + rid = release_id(connection, 5741659) + connection.execute( + """INSERT INTO listings + (id,user_id,release_id,media_condition,sleeve_condition,comments, + price,currency,shipping_from,allow_offers,status,posted_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + next_id(connection, "listings"), + uid, + rid, + "Very Good Plus (VG+)", + "Near Mint (NM or M-)", + "Clean Spanish reissue; play-graded.", + 42.0, + "GBP", + "United Kingdom", + 1, + "For Sale", + "2026-09-12 09:00:00", + ), + ) + connection.execute("UPDATE users SET is_seller=1 WHERE id=?", (uid,)) + connection.execute( + "UPDATE releases SET num_for_sale=num_for_sale+1 WHERE id=?", (rid,) + ) + + @staticmethod + def mutate_forum_post(connection: sqlite3.Connection) -> None: + connection.execute( + """INSERT INTO posts(id,thread_id,user_id,body,created_at) + VALUES(?,?,?,?,?)""", + ( + next_id(connection, "posts"), + 12, + user_id(connection, "carol_jazz"), + "For acoustic jazz, I prefer MC for the extra detail in cymbals and upper mids.", + "2026-09-12 09:00:00", + ), + ) + + @staticmethod + def mutate_registration(connection: sqlite3.Connection) -> None: + connection.execute( + """INSERT INTO users + (id,username,email,password_hash,location,real_name,bio,avatar_seed, + joined_at,is_seller,seller_rating,seller_feedback_count) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + next_id(connection, "users"), + "craterunner99", + "craterunner99@test.com", + "$2b$12$fixture-hash-not-the-plaintext-password", + "Portland, USA", + "Casey Runner", + "Digging since 2010.", + "craterunner99", + "2026-09-12 09:00:00", + 0, + 0.0, + 0, + ), + ) + + @staticmethod + def mutate_collection_remove(connection: sqlite3.Connection) -> None: + rid = release_id(connection, 20271358) + connection.execute( + "DELETE FROM collection_items WHERE user_id=? AND release_id=?", + (user_id(connection, "alice_crate"), rid), + ) + connection.execute( + "UPDATE releases SET have_count=have_count-1 WHERE id=?", (rid,) + ) + + @staticmethod + def mutate_rating_review(connection: sqlite3.Connection) -> None: + uid = user_id(connection, "dave_techno") + rid = release_id(connection, 35846581) + connection.execute( + "INSERT INTO ratings(id,user_id,release_id,value,created_at) VALUES(?,?,?,?,?)", + (next_id(connection, "ratings"), uid, rid, 4, "2026-09-12 09:00:00"), + ) + connection.execute( + """INSERT INTO reviews(id,user_id,release_id,body,rating,helpful,created_at) + VALUES(?,?,?,?,?,?,?)""", + ( + next_id(connection, "reviews"), + uid, + rid, + "The live arrangements reward a focused listen from start to finish.", + 4, + 0, + "2026-09-12 09:00:00", + ), + ) + connection.execute( + "UPDATE releases SET avg_rating=4.0,rating_count=1 WHERE id=?", (rid,) + ) + + def positive_case(self, task: int) -> tuple[list[dict], str, object | None]: + cases = { + 0: ( + [navigate("/release/8031582"), navigate("/release/3376357")], + "The 1966 edition prints durations. Its catalog numbers are 242 and RS 9242; Well, You Needn't is 11:24.", + None, + ), + 1: ( + [navigate("/release/7852399"), navigate("/release/7251385")], + "The 1986 CD edition, catalog CP32-5244, contains Bellarosa with a displayed duration of 4:15.", + None, + ), + 2: ( + [navigate("/release/4337598"), navigate("/release/2379219")], + "The Japan 2007 edition has 7 numbered tracks; its catalog number is UCCO-9038 and Joe Tarantino is credited for digital remastering.", + None, + ), + 3: ( + [navigate("/release/9732909"), navigate("/release/8837214")], + "Release 9732909 lists the extra Impuesto de lujo entry Num. 6649. " + "The other edition is release 8837214. Both editions share " + "Deposito Legal B. 9417-1978.", + None, + ), + 4: ( + [navigate("/release/35846581")], + "Colin Greenwood is credited on bass; the final side-D track is As The Waters Cover The Sea.", + None, + ), + 5: ( + [ + navigate( + "/marketplace?currency=USD&media=Near+Mint+%28NM+or+M-%29&sort=price_asc" + ), + navigate("/release/19878259"), + ], + "Kelly Blue by Wynton Kelly is sold by kosmische for 8.27 USD; the catalog numbers are SMJ-6114 and SRS-6059.", + None, + ), + 6: ( + [navigate("/lists"), navigate("/list/3"), navigate("/release/4627265")], + "The fourth release is Jeff Beck by Jeff Beck, from Italy, catalog IGDA 1063/64.", + None, + ), + 7: ( + [navigate("/login"), navigate("/release/3376357")], + "Added the requested edition to the Vinyl folder with Near Mint media condition.", + self.mutate_collection_add, + ), + 8: ( + [navigate("/login"), navigate("/release/11554424")], + "Added the requested edition to bob_vinyl's wantlist with Very Good minimum grade.", + self.mutate_wantlist_add, + ), + 9: ( + [ + navigate("/login"), + navigate("/release/3376357"), + navigate("/release/2379219"), + navigate("/list/new"), + ], + "Created the public list and added both editions in the requested order.", + self.mutate_list_create, + ), + 10: ( + [navigate("/login"), navigate("/release/5741659"), navigate("/sell")], + "Listed the requested release with the specified sale details.", + self.mutate_listing_add, + ), + 11: ( + [ + navigate("/forum/vinyl"), + navigate("/thread/12"), + navigate("/login"), + navigate("/thread/12"), + ], + "Posted the exact reply to the requested thread.", + self.mutate_forum_post, + ), + 12: ( + [ + navigate("/register"), + navigate("/settings"), + click_to("/settings", "/", "Log out"), + navigate("/login"), + input_text("/login", "craterunner99", 1), + input_text("/login", "discogs2026", 2), + click_to("/login", "/"), + ], + "Registered, updated the profile, signed out, and logged back in.", + self.mutate_registration, + ), + 13: ( + [ + navigate("/login"), + navigate("/user/alice_crate/collection?folder=Vinyl"), + ], + "Removed the specified edition and confirmed it is absent from the Vinyl folder.", + self.mutate_collection_remove, + ), + 14: ( + [navigate("/login"), navigate("/release/35846581")], + "Saved the 4-star rating and exact 4-star review.", + self.mutate_rating_review, + ), + } + return cases[task] + + def test_all_positive_cases_pass(self) -> None: + for task in range(15): + with self.subTest(task=task): + steps, answer, mutate = self.positive_case(task) + code, verdict = self.run_verifier(task, steps, answer, mutate) + self.assertEqual(0, code, verdict) + self.assertTrue(verdict["pass"], verdict) + + def test_all_no_op_runs_fail(self) -> None: + for task in range(15): + with self.subTest(task=task): + code, verdict = self.run_verifier(task, [navigate("/")], "") + self.assertNotEqual(0, code) + self.assertFalse(verdict["pass"]) + + def test_wrong_task_identity_fails_all(self) -> None: + for task in range(15): + with self.subTest(task=task): + steps, answer, mutate = self.positive_case(task) + code, verdict = self.run_verifier( + task, steps, answer, mutate, task_id="Discogs--999" + ) + self.assertNotEqual(0, code) + self.assertFalse(verdict["pass"]) + + def test_correct_outcome_without_navigation_fails_all(self) -> None: + for task in range(15): + with self.subTest(task=task): + _, answer, mutate = self.positive_case(task) + code, verdict = self.run_verifier(task, [], answer, mutate) + self.assertNotEqual(0, code) + self.assertFalse(verdict["pass"]) + + def test_read_only_wrong_answers_fail(self) -> None: + for task in range(7): + with self.subTest(task=task): + steps, _, _ = self.positive_case(task) + code, verdict = self.run_verifier( + task, steps, "I compared the pages, but every requested value is different." + ) + self.assertNotEqual(0, code) + self.assertFalse(verdict["pass"]) + + def test_foreign_origin_path_spoof_fails_all(self) -> None: + for task in range(15): + with self.subTest(task=task): + steps, answer, mutate = self.positive_case(task) + spoofed = rewrite_origin(steps, FOREIGN_ORIGIN) + code, verdict = self.run_verifier(task, spoofed, answer, mutate) + self.assertNotEqual(0, code) + self.assertFalse(verdict["pass"]) + + def test_alternate_loopback_port_is_accepted(self) -> None: + """A run published on another host port grades exactly like the default one.""" + for task in range(15): + with self.subTest(task=task): + steps, answer, mutate = self.positive_case(task) + moved = rewrite_origin(steps, ALT_ORIGIN) + code, verdict = self.run_verifier( + task, moved, answer, mutate, base_url=ALT_ORIGIN + ) + self.assertEqual(0, code, verdict) + self.assertTrue(verdict["pass"], verdict) + + def test_mixed_origin_trajectory_fails_all(self) -> None: + """A run that wanders onto a second local origin must not grade. + + Dropping the hard-coded port is what makes this test the real control: any one + loopback origin is accepted, but a trajectory that straddles two of them is + stitched together from different services and is rejected. Appending the extra + origin works for every task, including the ones whose path is a single step. + """ + for task in range(15): + with self.subTest(task=task): + steps, answer, mutate = self.positive_case(task) + mixed = steps + rewrite_origin(steps[-1:], ALT_ORIGIN) + code, verdict = self.run_verifier(task, mixed, answer, mutate) + self.assertNotEqual(0, code, verdict) + self.assertFalse(verdict["pass"], verdict) + self.assertEqual("single_origin", verdict["reason"], verdict) + + def test_expected_origin_pin_is_enforced_when_set(self) -> None: + """WHR_EXPECTED_ORIGIN lets a grader pin one origin without hard-coding it.""" + steps, answer, mutate = self.positive_case(0) + code, verdict = self.run_verifier( + 0, steps, answer, mutate, env={"WHR_EXPECTED_ORIGIN": BASE_URL} + ) + self.assertEqual(0, code, verdict) + self.assertTrue(verdict["pass"], verdict) + + code, verdict = self.run_verifier( + 0, steps, answer, mutate, env={"WHR_EXPECTED_ORIGIN": ALT_ORIGIN} + ) + self.assertNotEqual(0, code, verdict) + self.assertFalse(verdict["pass"], verdict) + self.assertEqual("origin_matches_expected", verdict["reason"]) + + def test_state_tasks_require_frozen_snapshots(self) -> None: + for task in range(7, 15): + with self.subTest(task=task): + steps, answer, _ = self.positive_case(task) + code, verdict = self.run_verifier( + task, steps, answer, include_snapshots=False + ) + self.assertNotEqual(0, code) + self.assertFalse(verdict["pass"]) + + def test_read_only_tasks_require_frozen_snapshots(self) -> None: + for task in range(7): + with self.subTest(task=task): + steps, answer, _ = self.positive_case(task) + code, verdict = self.run_verifier( + task, steps, answer, include_snapshots=False + ) + self.assertNotEqual(0, code) + self.assertFalse(verdict["pass"]) + + def test_state_tasks_reject_self_report_when_database_is_unchanged(self) -> None: + for task in range(7, 15): + with self.subTest(task=task): + steps, answer, _ = self.positive_case(task) + code, verdict = self.run_verifier(task, steps, answer) + self.assertNotEqual(0, code) + self.assertFalse(verdict["pass"]) + + def test_state_tasks_reject_unrelated_extra_changes(self) -> None: + for task in range(7, 15): + with self.subTest(task=task): + steps, answer, positive_mutation = self.positive_case(task) + + def mutate(connection, positive_mutation=positive_mutation): + positive_mutation(connection) + connection.execute( + "UPDATE users SET bio='Unrequested mutation' WHERE username='alice_crate'" + ) + + code, verdict = self.run_verifier(task, steps, answer, mutate) + self.assertNotEqual(0, code) + self.assertFalse(verdict["pass"]) + + def test_counter_tasks_reject_extra_release_mutations(self) -> None: + for task in (7, 8, 10, 13, 14): + with self.subTest(task=task): + steps, answer, positive_mutation = self.positive_case(task) + + def mutate(connection, positive_mutation=positive_mutation): + positive_mutation(connection) + connection.execute( + "UPDATE releases SET have_count=have_count+1 WHERE discogs_id=8031582" + ) + + code, verdict = self.run_verifier(task, steps, answer, mutate) + self.assertNotEqual(0, code) + self.assertFalse(verdict["pass"]) + + def test_state_tasks_reject_wrong_objects(self) -> None: + def collection_wrong(connection: sqlite3.Connection) -> None: + self.mutate_collection_add(connection) + connection.execute( + "UPDATE collection_items SET release_id=? WHERE id=(SELECT MAX(id) FROM collection_items)", + (release_id(connection, 8031582),), + ) + + def wantlist_wrong(connection: sqlite3.Connection) -> None: + self.mutate_wantlist_add(connection) + connection.execute( + "UPDATE wantlist_items SET release_id=? WHERE id=(SELECT MAX(id) FROM wantlist_items)", + (release_id(connection, 8031582),), + ) + + def list_wrong(connection: sqlite3.Connection) -> None: + self.mutate_list_create(connection) + connection.execute( + "UPDATE lists SET user_id=? WHERE id=(SELECT MAX(id) FROM lists)", + (user_id(connection, "alice_crate"),), + ) + + def listing_wrong(connection: sqlite3.Connection) -> None: + self.mutate_listing_add(connection) + connection.execute( + "UPDATE listings SET release_id=? WHERE id=(SELECT MAX(id) FROM listings)", + (release_id(connection, 9732909),), + ) + + def post_wrong(connection: sqlite3.Connection) -> None: + self.mutate_forum_post(connection) + connection.execute( + "UPDATE posts SET thread_id=1 WHERE id=(SELECT MAX(id) FROM posts)" + ) + + def registration_wrong(connection: sqlite3.Connection) -> None: + self.mutate_registration(connection) + connection.execute( + "UPDATE users SET username='wrong_runner' WHERE id=(SELECT MAX(id) FROM users)" + ) + + def removal_wrong(connection: sqlite3.Connection) -> None: + original = connection.execute( + "SELECT * FROM collection_items WHERE user_id=? AND release_id=?", + ( + user_id(connection, "alice_crate"), + release_id(connection, 20271358), + ), + ).fetchone() + self.mutate_collection_remove(connection) + connection.execute( + """INSERT INTO collection_items + (id,user_id,release_id,folder,media_condition,sleeve_condition,notes,added_at) + VALUES(?,?,?,?,?,?,?,?)""", + tuple(original), + ) + connection.execute( + "UPDATE releases SET have_count=have_count+1 WHERE discogs_id=20271358" + ) + other = connection.execute( + """SELECT ci.id,r.id FROM collection_items ci + JOIN releases r ON r.id=ci.release_id + WHERE ci.user_id=? AND r.discogs_id<>20271358 LIMIT 1""", + (user_id(connection, "alice_crate"),), + ).fetchone() + connection.execute("DELETE FROM collection_items WHERE id=?", (other[0],)) + connection.execute( + "UPDATE releases SET have_count=have_count-1 WHERE id=?", (other[1],) + ) + + def rating_wrong(connection: sqlite3.Connection) -> None: + self.mutate_rating_review(connection) + wrong = release_id(connection, 1845) + connection.execute( + "UPDATE ratings SET release_id=? WHERE id=(SELECT MAX(id) FROM ratings)", + (wrong,), + ) + connection.execute( + "UPDATE reviews SET release_id=? WHERE id=(SELECT MAX(id) FROM reviews)", + (wrong,), + ) + + mutations = { + 7: collection_wrong, + 8: wantlist_wrong, + 9: list_wrong, + 10: listing_wrong, + 11: post_wrong, + 12: registration_wrong, + 13: removal_wrong, + 14: rating_wrong, + } + for task, mutate in mutations.items(): + with self.subTest(task=task): + steps, answer, _ = self.positive_case(task) + code, verdict = self.run_verifier(task, steps, answer, mutate) + self.assertNotEqual(0, code) + self.assertFalse(verdict["pass"]) + + def test_read_only_tasks_reject_database_mutation(self) -> None: + def mutate(connection: sqlite3.Connection) -> None: + connection.execute( + "UPDATE users SET bio='Unrequested mutation' WHERE username='alice_crate'" + ) + + for task in range(7): + with self.subTest(task=task): + steps, answer, _ = self.positive_case(task) + code, verdict = self.run_verifier(task, steps, answer, mutate) + self.assertNotEqual(0, code) + self.assertFalse(verdict["pass"]) + + def test_schema_mutation_fails(self) -> None: + def mutate(connection: sqlite3.Connection) -> None: + connection.execute("CREATE TABLE injected_state(id INTEGER PRIMARY KEY)") + + steps, answer, _ = self.positive_case(0) + code, verdict = self.run_verifier(0, steps, answer, mutate) + self.assertNotEqual(0, code) + self.assertFalse(verdict["pass"]) + + def test_read_only_answers_cannot_negate_the_required_facts(self) -> None: + cases = { + 0: "The answer is not 1966, not 242 or RS 9242, and Well, You Needn't is not 11:24.", + 1: "It is not the 1986 CD CP32-5244; Bellarosa is not 4:15.", + 2: "The Japan 2007 UCCO-9038 release does not have 7 tracks and Joe Tarantino is not credited.", + 4: "Colin Greenwood is not the bass player, and the last track is not As The Waters Cover The Sea.", + 5: "Kelly Blue by Wynton Kelly is not the USD 8.27 kosmische listing; SMJ-6114 and SRS-6059 are wrong.", + } + for task, answer in cases.items(): + with self.subTest(task=task): + steps, _, _ = self.positive_case(task) + code, verdict = self.run_verifier(task, steps, answer) + self.assertNotEqual(0, code) + self.assertFalse(verdict["pass"]) + + def test_task_zero_requires_both_distinct_catalog_numbers(self) -> None: + steps, _, _ = self.positive_case(0) + answer = "The 1966 edition uses RS 9242, and Well, You Needn't is 11:24." + code, verdict = self.run_verifier(0, steps, answer) + self.assertNotEqual(0, code) + self.assertFalse(verdict["pass"]) + + def test_task_three_rejects_swapped_identifier_binding(self) -> None: + steps, _, _ = self.positive_case(3) + # Every requested token is present, but the extra identifier is attached to the + # wrong edition. The binding check must still reject it. + answer = ( + "Release 8837214 lists the extra Impuesto de lujo entry Num. 6649. " + "The other edition is release 9732909. Both editions share " + "Deposito Legal B. 9417-1978." + ) + code, verdict = self.run_verifier(3, steps, answer) + self.assertNotEqual(0, code) + self.assertFalse(verdict["pass"]) + + def test_task_three_rejects_results_page_only_facts(self) -> None: + steps, _, _ = self.positive_case(3) + # The format descriptors are readable from the search results page, so an answer + # built only from them no longer satisfies the task. + answer = ( + "Release 9732909 is the Dolby System edition; release 8837214 is the " + "Repress. They share D 57.352 and D-57352." + ) + code, verdict = self.run_verifier(3, steps, answer) + self.assertNotEqual(0, code) + self.assertFalse(verdict["pass"]) + + def test_forum_task_requires_return_to_thread_after_login(self) -> None: + steps, answer, mutate = self.positive_case(11) + code, verdict = self.run_verifier(task=11, steps=steps[:-1], answer=answer, mutate=mutate) + self.assertNotEqual(0, code) + self.assertFalse(verdict["pass"]) + + def test_registration_task_requires_new_credentials_and_login_success(self) -> None: + steps, answer, mutate = self.positive_case(12) + for shortened in (steps[:4], steps[:-1]): + with self.subTest(step_count=len(shortened)): + code, verdict = self.run_verifier(12, shortened, answer, mutate) + self.assertNotEqual(0, code) + self.assertFalse(verdict["pass"]) + + def test_read_only_natural_equivalent_phrasings_pass(self) -> None: + alternatives = { + 0: "It is the 1966 release. The two catalogue identifiers are RS 9242 and 242, and ‘Well, You Needn’t’ runs 11 minutes 24 seconds (11:24).", + 2: "Japan's 2007 UCCO-9038 version is the one with seven tracks; digital remastering is credited to Joe Tarantino.", + 3: "The extra Impuesto de lujo line, Num. 6649, is printed on release 9732909. " + "Release 8837214 stops at two identifiers. Both carry Deposito Legal B. 9417-1978.", + 5: "At US$8.27, kosmische's Kelly Blue by Wynton Kelly was cheapest. The detail page lists SRS-6059 plus SMJ-6114.", + } + for task, answer in alternatives.items(): + with self.subTest(task=task): + steps, _, _ = self.positive_case(task) + code, verdict = self.run_verifier(task, steps, answer) + self.assertEqual(0, code, verdict) + self.assertTrue(verdict["pass"], verdict) + + +if __name__ == "__main__": + unittest.main() diff --git a/sites/discogs/verify/verify_0.py b/sites/discogs/verify/verify_0.py new file mode 100644 index 000000000..ea1ddd045 --- /dev/null +++ b/sites/discogs/verify/verify_0.py @@ -0,0 +1,3 @@ +#!/usr/bin/env python3 +from verify_lib import run +if __name__ == "__main__": run(0) diff --git a/sites/discogs/verify/verify_1.py b/sites/discogs/verify/verify_1.py new file mode 100644 index 000000000..f2ff8e345 --- /dev/null +++ b/sites/discogs/verify/verify_1.py @@ -0,0 +1,3 @@ +#!/usr/bin/env python3 +from verify_lib import run +if __name__ == "__main__": run(1) diff --git a/sites/discogs/verify/verify_10.py b/sites/discogs/verify/verify_10.py new file mode 100644 index 000000000..1d0ea201c --- /dev/null +++ b/sites/discogs/verify/verify_10.py @@ -0,0 +1,3 @@ +#!/usr/bin/env python3 +from verify_lib import run +if __name__ == "__main__": run(10) diff --git a/sites/discogs/verify/verify_11.py b/sites/discogs/verify/verify_11.py new file mode 100644 index 000000000..638684593 --- /dev/null +++ b/sites/discogs/verify/verify_11.py @@ -0,0 +1,3 @@ +#!/usr/bin/env python3 +from verify_lib import run +if __name__ == "__main__": run(11) diff --git a/sites/discogs/verify/verify_12.py b/sites/discogs/verify/verify_12.py new file mode 100644 index 000000000..80ac59528 --- /dev/null +++ b/sites/discogs/verify/verify_12.py @@ -0,0 +1,3 @@ +#!/usr/bin/env python3 +from verify_lib import run +if __name__ == "__main__": run(12) diff --git a/sites/discogs/verify/verify_13.py b/sites/discogs/verify/verify_13.py new file mode 100644 index 000000000..12ae5d5f8 --- /dev/null +++ b/sites/discogs/verify/verify_13.py @@ -0,0 +1,3 @@ +#!/usr/bin/env python3 +from verify_lib import run +if __name__ == "__main__": run(13) diff --git a/sites/discogs/verify/verify_14.py b/sites/discogs/verify/verify_14.py new file mode 100644 index 000000000..807bcc650 --- /dev/null +++ b/sites/discogs/verify/verify_14.py @@ -0,0 +1,3 @@ +#!/usr/bin/env python3 +from verify_lib import run +if __name__ == "__main__": run(14) diff --git a/sites/discogs/verify/verify_2.py b/sites/discogs/verify/verify_2.py new file mode 100644 index 000000000..0ead8259d --- /dev/null +++ b/sites/discogs/verify/verify_2.py @@ -0,0 +1,3 @@ +#!/usr/bin/env python3 +from verify_lib import run +if __name__ == "__main__": run(2) diff --git a/sites/discogs/verify/verify_3.py b/sites/discogs/verify/verify_3.py new file mode 100644 index 000000000..5761db4bd --- /dev/null +++ b/sites/discogs/verify/verify_3.py @@ -0,0 +1,3 @@ +#!/usr/bin/env python3 +from verify_lib import run +if __name__ == "__main__": run(3) diff --git a/sites/discogs/verify/verify_4.py b/sites/discogs/verify/verify_4.py new file mode 100644 index 000000000..b5844de07 --- /dev/null +++ b/sites/discogs/verify/verify_4.py @@ -0,0 +1,3 @@ +#!/usr/bin/env python3 +from verify_lib import run +if __name__ == "__main__": run(4) diff --git a/sites/discogs/verify/verify_5.py b/sites/discogs/verify/verify_5.py new file mode 100644 index 000000000..57bad97c1 --- /dev/null +++ b/sites/discogs/verify/verify_5.py @@ -0,0 +1,3 @@ +#!/usr/bin/env python3 +from verify_lib import run +if __name__ == "__main__": run(5) diff --git a/sites/discogs/verify/verify_6.py b/sites/discogs/verify/verify_6.py new file mode 100644 index 000000000..489e5e53f --- /dev/null +++ b/sites/discogs/verify/verify_6.py @@ -0,0 +1,3 @@ +#!/usr/bin/env python3 +from verify_lib import run +if __name__ == "__main__": run(6) diff --git a/sites/discogs/verify/verify_7.py b/sites/discogs/verify/verify_7.py new file mode 100644 index 000000000..ab81758b1 --- /dev/null +++ b/sites/discogs/verify/verify_7.py @@ -0,0 +1,3 @@ +#!/usr/bin/env python3 +from verify_lib import run +if __name__ == "__main__": run(7) diff --git a/sites/discogs/verify/verify_8.py b/sites/discogs/verify/verify_8.py new file mode 100644 index 000000000..953b03a06 --- /dev/null +++ b/sites/discogs/verify/verify_8.py @@ -0,0 +1,3 @@ +#!/usr/bin/env python3 +from verify_lib import run +if __name__ == "__main__": run(8) diff --git a/sites/discogs/verify/verify_9.py b/sites/discogs/verify/verify_9.py new file mode 100644 index 000000000..f9199617a --- /dev/null +++ b/sites/discogs/verify/verify_9.py @@ -0,0 +1,3 @@ +#!/usr/bin/env python3 +from verify_lib import run +if __name__ == "__main__": run(9) diff --git a/sites/discogs/verify/verify_lib.py b/sites/discogs/verify/verify_lib.py new file mode 100644 index 000000000..0aaf7fb37 --- /dev/null +++ b/sites/discogs/verify/verify_lib.py @@ -0,0 +1,700 @@ +#!/usr/bin/env python3 +"""Deterministic grading helpers for the Discogs task set. + +Read-only tasks require both the relevant on-site navigation and a grounded +final answer. Stateful tasks additionally require frozen SQLite snapshots in +the run directory (``initial_state.db`` and ``after_state.db``); live mutable +container state is deliberately never used as grading evidence. +""" +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import re +import sqlite3 +import sys +import unicodedata +from urllib.parse import parse_qs, urlparse + + +ALLOWED_HOSTS = {"localhost", "127.0.0.1"} +# The mirror is served from a loopback origin. Which host port the operator publishes +# is an operational detail (the repository's own guide runs the same sites on 41000+), +# so grading must not depend on it. What must hold is that every recorded step points at +# one loopback origin: that is what stops a trajectory from citing an outside site or +# mixing the mirror with another local service. An exact origin can still be pinned with +# the WHR_EXPECTED_ORIGIN environment variable when a grader wants that. + +READ_ONLY_SPECS = { + 0: { + "paths": ("/release/8031582", "/release/3376357"), + "tokens": ("1966", "242", "rs 9242", "11:24"), + }, + 1: { + "paths": ("/release/7852399", "/release/7251385"), + "tokens": ("1986", "cd", "cp32-5244", "bellarosa", "4:15"), + }, + 2: { + "paths": ("/release/4337598", "/release/2379219"), + "tokens": ("japan", "2007", "ucco-9038", "joe tarantino"), + "number": 7, + }, + 3: { + "paths": ("/release/9732909", "/release/8837214"), + # The live-faithful search card shows the full format description, so the + # Dolby System / Repress split is readable from the results page. The task now + # asks for identifier facts that only the detail pages carry. + "tokens": ("9732909", "8837214", "impuesto de lujo", "6649", "9417-1978"), + "attribution": (("6649", "9732909", "8837214"),), + }, + 4: { + "paths": ("/release/35846581",), + "tokens": ("colin greenwood", "as the waters cover the sea"), + }, + 5: { + "paths": ("/marketplace", "/release/19878259"), + "tokens": ("kelly blue", "wynton kelly", "kosmische", "8.27", ("usd", "us$"), "smj-6114", "srs-6059"), + "marketplace_filters": True, + }, + 6: { + "paths": ("/lists", "/list/3", "/release/4627265"), + "tokens": ("jeff beck", "italy", "igda 1063/64"), + }, +} + + +class Judge: + def __init__(self, task_id: str): + self.task_id = task_id + self.ok = True + self.reason = "" + self.evidence: list[str] = [] + + def check(self, name: str, condition: bool, evidence: str = "") -> bool: + if condition: + self.evidence.append(f"[PASS] {name}: {evidence}") + else: + self.ok = False + if not self.reason: + self.reason = name + self.evidence.append(f"[FAIL] {name}: {evidence}") + return bool(condition) + + def emit(self) -> None: + print(json.dumps({ + "task_id": self.task_id, + "pass": self.ok, + "reason": self.reason, + "evidence": self.evidence, + }, indent=2)) + raise SystemExit(0 if self.ok else 1) + + +def normalize(value: str) -> str: + value = unicodedata.normalize("NFKD", value or "") + value = "".join(char for char in value if not unicodedata.combining(char)) + value = value.casefold().replace("’", "'").replace("–", "-").replace("—", "-") + return re.sub(r"\s+", " ", value).strip() + + +def trajectory_urls(trajectory: dict) -> list[str]: + urls = [] + for step in trajectory.get("steps", []): + for key in ("url", "url_after"): + if step.get(key): + urls.append(step[key]) + return urls + + +def is_allowed_url(url: str) -> bool: + try: + parsed = urlparse(url) + return ( + parsed.scheme == "http" + and parsed.hostname in ALLOWED_HOSTS + and parsed.username is None + and parsed.password is None + ) + except ValueError: + return False + + +def visited_path(trajectory: dict, expected: str) -> bool: + return any( + is_allowed_url(url) and urlparse(url).path == expected + for url in trajectory_urls(trajectory) + ) + + +def visited_paths_in_order(trajectory: dict, expected_paths: tuple[str, ...]) -> bool: + urls = trajectory_urls(trajectory) + cursor = 0 + for expected in expected_paths: + for index in range(cursor, len(urls)): + url = urls[index] + if is_allowed_url(url) and urlparse(url).path == expected: + cursor = index + 1 + break + else: + return False + return True + + +def action_mentions(trajectory: dict, text: str) -> bool: + needle = normalize(text) + return any(needle in normalize(json.dumps({ + "action": step.get("action"), + "params": step.get("params"), + "result": step.get("action_result"), + }, ensure_ascii=False)) for step in trajectory.get("steps", [])) + + +def final_answer(trajectory: dict) -> str: + return (trajectory.get("final_answer") or "").strip() + + +NEGATION_WORDS = { + "not", "no", "never", "without", "isn't", "isnt", "aren't", "arent", + "wasn't", "wasnt", "doesn't", "doesnt", "didn't", "didnt", +} + + +def preceding_clause_is_negated(value: str, start: int) -> bool: + prefix = value[:start] + clause = re.split(r"(?:[.!?;:\n]+|\b(?:and|but|however|instead)\b)", prefix)[-1] + words = re.findall(r"[a-z0-9]+(?:'[a-z]+)?", clause)[-6:] + return any(word in NEGATION_WORDS for word in words) + + +def affirmed(text: str, token: str) -> bool: + value = normalize(text) + target = normalize(token) + pattern = re.escape(target) + if target and target[0].isalnum(): + pattern = rf"(? bool: + value = normalize(text) + patterns = [rf"(? bool: + value = normalize(text) + left, right = normalize(left), normalize(right) + starts_left = [match.start() for match in re.finditer(re.escape(left), value)] + candidates = [ + (normalize(candidate), match.start()) + for _, candidate in associations + for match in re.finditer(re.escape(normalize(candidate)), value) + ] + for left_start in starts_left: + if not candidates: + continue + distance, nearest = min( + (abs(left_start - right_start), candidate) + for candidate, right_start in candidates + ) + if nearest == right and distance <= 100: + return True + return False + + +def attributed_to(text: str, value: str, target: str, other: str) -> bool: + """True when `value` is attributed to `target` rather than to `other`. + + Natural phrasing names the edition before the fact ("release X lists ... 6649"), + so the nearest identifier *preceding* the value decides the binding; if none + precedes, the nearest one following it is used. A swapped attribution fails even + though both identifiers appear in the answer. + """ + body = normalize(text) + value, target, other = normalize(value), normalize(target), normalize(other) + value_starts = [m.start() for m in re.finditer(re.escape(value), body)] + target_starts = [m.start() for m in re.finditer(re.escape(target), body)] + other_starts = [m.start() for m in re.finditer(re.escape(other), body)] + if not value_starts or not target_starts: + return False + for start in value_starts: + before = ([(start - s, True) for s in target_starts if s < start] + + [(start - s, False) for s in other_starts if s < start]) + if before: + if min(before)[1]: + return True + continue + after = ([(s - start, True) for s in target_starts if s > start] + + [(s - start, False) for s in other_starts if s > start]) + if after and min(after)[1]: + return True + return False + + +def step_origins(trajectory: dict) -> list[str]: + """Scheme://host:port for every recorded step URL, in order.""" + origins = [] + for url in trajectory_urls(trajectory): + parsed = urlparse(url) + if parsed.scheme or parsed.netloc: + origins.append(f"{parsed.scheme}://{parsed.netloc}") + return origins + + +def check_origin_discipline(trajectory: dict, judge: Judge) -> None: + """Every step must sit on one loopback origin, and none may sit off it.""" + urls = trajectory_urls(trajectory) + judge.check("trajectory_has_urls", bool(urls), f"count={len(urls)}") + foreign = sorted({u for u in urls if not is_allowed_url(u)}) + judge.check("no_foreign_origins", not foreign, f"foreign={foreign[:5]}") + origins = sorted(set(step_origins(trajectory))) + judge.check("single_origin", len(origins) == 1, f"origins={origins}") + expected = os.environ.get("WHR_EXPECTED_ORIGIN", "").rstrip("/") + if expected: + judge.check("origin_matches_expected", origins == [expected], + f"expected={expected} actual={origins}") + + +def marketplace_filters_used(trajectory: dict) -> bool: + for url in trajectory_urls(trajectory): + if not is_allowed_url(url): + continue + parsed = urlparse(url) + if parsed.path != "/marketplace": + continue + query = parse_qs(parsed.query) + if (query.get("media") == ["Near Mint (NM or M-)"] + and query.get("currency", ["USD"]) == ["USD"] + and query.get("sort", ["price_asc"]) == ["price_asc"]): + return True + return False + + +def verify_read_only(index: int, trajectory: dict, judge: Judge) -> None: + spec = READ_ONLY_SPECS[index] + answer = final_answer(trajectory) + judge.check("final_answer_present", bool(answer), f"length={len(answer)}") + for path in spec["paths"]: + judge.check(f"visited_{path}", visited_path(trajectory, path), path) + missing = [] + for token in spec["tokens"]: + alternatives = token if isinstance(token, tuple) else (token,) + if not any(affirmed(answer, option) for option in alternatives): + missing.append(" | ".join(alternatives)) + judge.check("grounded_answer_fields", not missing, f"missing={missing}") + if "number" in spec: + judge.check("requested_count", contains_number(answer, spec["number"]), f"expected={spec['number']}") + associations = spec.get("associations", ()) + for left, right in associations: + judge.check( + f"bind_{left}_{right}", + associated(answer, left, right, associations), + "expected descriptor is nearest to its release ID", + ) + for value, target, other in spec.get("attribution", ()): + judge.check( + f"bind_{target}_{value}", + attributed_to(answer, value, target, other), + f"'{value}' must be attributed to {target}, not {other}", + ) + if spec.get("marketplace_filters"): + judge.check("marketplace_filters", marketplace_filters_used(trajectory), + "USD + Near Mint + price ascending") + + +def open_db(path: Path) -> sqlite3.Connection: + connection = sqlite3.connect(f"file:{path}?mode=ro", uri=True) + connection.row_factory = sqlite3.Row + return connection + + +def resolve_snapshot(run_dir: Path, explicit: str, names: tuple[str, ...]) -> Path | None: + if explicit: + path = Path(explicit) + return path if path.is_file() else None + for name in names: + path = run_dir / name + if path.is_file(): + return path + return None + + +def table_map(db: sqlite3.Connection, table: str) -> dict[int, dict]: + rows = db.execute(f"SELECT * FROM {table} ORDER BY id").fetchall() + return {row["id"]: dict(row) for row in rows} + + +def database_schema(db: sqlite3.Connection) -> list[tuple]: + return [ + tuple(row) + for row in db.execute( + """SELECT type,name,tbl_name,sql FROM sqlite_master + WHERE name NOT LIKE 'sqlite_%' + ORDER BY type,name""" + ).fetchall() + ] + + +def database_tables(db: sqlite3.Connection) -> set[str]: + return { + row[0] + for row in db.execute( + """SELECT name FROM sqlite_master + WHERE type='table' AND name NOT LIKE 'sqlite_%'""" + ).fetchall() + } + + +def quoted(identifier: str) -> str: + return '"' + identifier.replace('"', '""') + '"' + + +def table_rows(db: sqlite3.Connection, table: str) -> list[tuple]: + columns = [row[1] for row in db.execute(f"PRAGMA table_info({quoted(table)})")] + order = ",".join(quoted(column) for column in columns) + query = f"SELECT * FROM {quoted(table)}" + if order: + query += f" ORDER BY {order}" + return [tuple(row) for row in db.execute(query).fetchall()] + + +def table_delta(before: sqlite3.Connection, after: sqlite3.Connection, table: str) -> dict: + old, new = table_map(before, table), table_map(after, table) + return { + "added": [new[key] for key in new.keys() - old.keys()], + "removed": [old[key] for key in old.keys() - new.keys()], + "changed": [(old[key], new[key]) for key in old.keys() & new.keys() if old[key] != new[key]], + } + + +def changed_tables(before: sqlite3.Connection, after: sqlite3.Connection) -> set[str]: + before_tables = database_tables(before) + after_tables = database_tables(after) + common = before_tables & after_tables + changed = { + table + for table in common + if table_rows(before, table) != table_rows(after, table) + } + return changed | (before_tables ^ after_tables) + + +def one(db: sqlite3.Connection, sql: str, params: tuple = ()) -> dict | None: + row = db.execute(sql, params).fetchone() + return dict(row) if row else None + + +def changed_columns(old: dict, new: dict) -> set[str]: + return {key for key in old if old[key] != new[key]} + + +def check_navigation(trajectory: dict, judge: Judge, paths: tuple[str, ...]) -> None: + for path in paths: + judge.check(f"visited_{path}", visited_path(trajectory, path), path) + + +def check_allowed_tables(before: sqlite3.Connection, after: sqlite3.Connection, + judge: Judge, allowed: set[str]) -> None: + schema_matches = database_schema(before) == database_schema(after) + judge.check("database_schema_unchanged", schema_matches, "complete sqlite_master") + changed = changed_tables(before, after) + judge.check("no_unrequested_table_changes", changed <= allowed, + f"changed={sorted(changed)} allowed={sorted(allowed)}") + + +def check_release_counter(before: sqlite3.Connection, after: sqlite3.Connection, + judge: Judge, discogs_id: int, field: str, delta: int) -> None: + old = one(before, "SELECT * FROM releases WHERE discogs_id=?", (discogs_id,)) + new = one(after, "SELECT * FROM releases WHERE discogs_id=?", (discogs_id,)) + judge.check("target_release_exists", bool(old and new), f"discogs_id={discogs_id}") + if not old or not new: + return + judge.check("release_counter_delta", new[field] == old[field] + delta, + f"{field}: {old[field]} -> {new[field]}") + judge.check("release_only_expected_counter_changed", changed_columns(old, new) == {field}, + f"columns={sorted(changed_columns(old, new))}") + release_delta = table_delta(before, after, "releases") + only_target_changed = ( + not release_delta["added"] + and not release_delta["removed"] + and len(release_delta["changed"]) == 1 + and release_delta["changed"][0][0]["discogs_id"] == discogs_id + and release_delta["changed"][0][1]["discogs_id"] == discogs_id + ) + judge.check("no_other_release_changes", only_target_changed, str(release_delta)) + + +def verify_collection_add(before, after, trajectory, judge): + check_navigation(trajectory, judge, ("/login", "/release/3376357")) + user = one(after, "SELECT id FROM users WHERE username='alice_crate'") + release = one(after, "SELECT id FROM releases WHERE discogs_id=3376357") + rows = after.execute("""SELECT c.* FROM collection_items c + JOIN users u ON u.id=c.user_id JOIN releases r ON r.id=c.release_id + WHERE u.username='alice_crate' AND r.discogs_id=3376357""").fetchall() + old_rows = before.execute("""SELECT c.id FROM collection_items c + JOIN users u ON u.id=c.user_id JOIN releases r ON r.id=c.release_id + WHERE u.username='alice_crate' AND r.discogs_id=3376357""").fetchall() + delta = table_delta(before, after, "collection_items") + judge.check("precondition_absent", len(old_rows) == 0, f"rows={len(old_rows)}") + judge.check("one_collection_row_added", len(delta["added"]) == 1 and not delta["removed"] and not delta["changed"], str(delta)) + good = len(rows) == 1 and rows[0]["folder"] == "Vinyl" and rows[0]["media_condition"] == "Near Mint (NM or M-)" + judge.check("collection_row_matches", good, f"user={user} release={release}") + check_release_counter(before, after, judge, 3376357, "have_count", 1) + check_allowed_tables(before, after, judge, {"collection_items", "releases"}) + + +def verify_wantlist_add(before, after, trajectory, judge): + check_navigation(trajectory, judge, ("/login", "/release/11554424")) + rows = after.execute("""SELECT w.* FROM wantlist_items w + JOIN users u ON u.id=w.user_id JOIN releases r ON r.id=w.release_id + WHERE u.username='bob_vinyl' AND r.discogs_id=11554424""").fetchall() + old_rows = before.execute("""SELECT w.id FROM wantlist_items w + JOIN users u ON u.id=w.user_id JOIN releases r ON r.id=w.release_id + WHERE u.username='bob_vinyl' AND r.discogs_id=11554424""").fetchall() + delta = table_delta(before, after, "wantlist_items") + judge.check("precondition_absent", len(old_rows) == 0, f"rows={len(old_rows)}") + judge.check("one_wantlist_row_added", len(delta["added"]) == 1 and not delta["removed"] and not delta["changed"], str(delta)) + judge.check("wantlist_row_matches", len(rows) == 1 and rows[0]["min_grade"] == "Very Good (VG)", f"rows={len(rows)}") + check_release_counter(before, after, judge, 11554424, "want_count", 1) + check_allowed_tables(before, after, judge, {"wantlist_items", "releases"}) + + +def verify_list_create(before, after, trajectory, judge): + check_navigation(trajectory, judge, ("/login", "/release/3376357", "/release/2379219", "/list/new")) + user = one(after, "SELECT id FROM users WHERE username='dave_techno'") + old = before.execute("SELECT id FROM lists WHERE title='Jazz Reissue Reference'").fetchall() + rows = after.execute("SELECT * FROM lists WHERE title='Jazz Reissue Reference'").fetchall() + list_delta = table_delta(before, after, "lists") + item_delta = table_delta(before, after, "list_items") + judge.check("precondition_absent", not old, f"rows={len(old)}") + judge.check("one_list_added", len(list_delta["added"]) == 1 and not list_delta["removed"] and not list_delta["changed"], str(list_delta)) + good_list = (len(rows) == 1 and user and rows[0]["user_id"] == user["id"] and rows[0]["is_public"] == 1 + and rows[0]["description"] == "Compare these two reissues before the next listening session.") + judge.check("list_fields_match", bool(good_list), f"rows={len(rows)}") + if rows: + items = after.execute("""SELECT li.position, li.comment, r.discogs_id FROM list_items li + JOIN releases r ON r.id=li.release_id WHERE li.list_id=? ORDER BY li.position""", (rows[0]["id"],)).fetchall() + expected = [(1, "1966 US stereo edition", 3376357), (2, "2007 Japan CD edition", 2379219)] + actual = [(row["position"], row["comment"], row["discogs_id"]) for row in items] + judge.check("ordered_items_match", actual == expected, f"actual={actual}") + else: + judge.check("ordered_items_match", False, "new list missing") + judge.check("two_list_items_added", len(item_delta["added"]) == 2 and not item_delta["removed"] and not item_delta["changed"], str(item_delta)) + check_allowed_tables(before, after, judge, {"lists", "list_items"}) + + +def verify_listing_add(before, after, trajectory, judge): + check_navigation(trajectory, judge, ("/login", "/release/5741659", "/sell")) + delta = table_delta(before, after, "listings") + judge.check("one_listing_added", len(delta["added"]) == 1 and not delta["removed"] and not delta["changed"], str(delta)) + if delta["added"]: + row = delta["added"][0] + user = one(after, "SELECT username FROM users WHERE id=?", (row["user_id"],)) + release = one(after, "SELECT discogs_id FROM releases WHERE id=?", (row["release_id"],)) + good = (user and user["username"] == "bob_vinyl" and release and release["discogs_id"] == 5741659 + and row["price"] == 42.0 and row["currency"] == "GBP" + and row["media_condition"] == "Very Good Plus (VG+)" + and row["sleeve_condition"] == "Near Mint (NM or M-)" + and row["shipping_from"] == "United Kingdom" and row["allow_offers"] == 1 + and row["comments"] == "Clean Spanish reissue; play-graded." and row["status"] == "For Sale") + judge.check("listing_fields_match", bool(good), f"listing_id={row['id']}") + else: + judge.check("listing_fields_match", False, "listing missing") + check_release_counter(before, after, judge, 5741659, "num_for_sale", 1) + user_delta = table_delta(before, after, "users") + one_seller_change = ( + not user_delta["added"] + and not user_delta["removed"] + and len(user_delta["changed"]) == 1 + and user_delta["changed"][0][0]["username"] == "bob_vinyl" + and user_delta["changed"][0][1]["username"] == "bob_vinyl" + and changed_columns(*user_delta["changed"][0]) == {"is_seller"} + and user_delta["changed"][0][1]["is_seller"] == 1 + ) + judge.check("seller_flag_only", one_seller_change, str(user_delta)) + check_allowed_tables(before, after, judge, {"listings", "releases", "users"}) + + +def verify_forum_post(before, after, trajectory, judge): + check_navigation(trajectory, judge, ("/forum/vinyl", "/thread/12", "/login")) + judge.check( + "returned_to_thread_after_login", + visited_paths_in_order( + trajectory, ("/forum/vinyl", "/thread/12", "/login", "/thread/12") + ), + "forum -> thread -> login -> thread", + ) + delta = table_delta(before, after, "posts") + judge.check("one_post_added", len(delta["added"]) == 1 and not delta["removed"] and not delta["changed"], str(delta)) + if delta["added"]: + row = delta["added"][0] + user = one(after, "SELECT username FROM users WHERE id=?", (row["user_id"],)) + good = (user and user["username"] == "carol_jazz" and row["thread_id"] == 12 + and row["body"] == "For acoustic jazz, I prefer MC for the extra detail in cymbals and upper mids.") + judge.check("post_fields_match", bool(good), f"post_id={row['id']}") + else: + judge.check("post_fields_match", False, "post missing") + check_allowed_tables(before, after, judge, {"posts"}) + + +def verify_registration(before, after, trajectory, judge): + check_navigation(trajectory, judge, ("/register", "/settings", "/login")) + judge.check("logout_action", action_mentions(trajectory, "log out"), "logout visible action recorded") + judge.check( + "new_credentials_entered", + action_mentions(trajectory, "craterunner99") + and action_mentions(trajectory, "discogs2026"), + "new username and password appear in recorded input actions", + ) + judge.check( + "login_succeeded_after_logout", + visited_paths_in_order( + trajectory, ("/register", "/settings", "/", "/login", "/") + ), + "registration -> settings -> logout -> login -> authenticated destination", + ) + delta = table_delta(before, after, "users") + judge.check("one_user_added", len(delta["added"]) == 1 and not delta["removed"] and not delta["changed"], str(delta)) + if delta["added"]: + row = delta["added"][0] + good = (row["username"] == "craterunner99" and row["email"] == "craterunner99@test.com" + and row["location"] == "Portland, USA" and row["real_name"] == "Casey Runner" + and row["bio"] == "Digging since 2010." and row["password_hash"] + and row["password_hash"] != "discogs2026") + judge.check("user_fields_match", bool(good), f"username={row['username']}") + else: + judge.check("user_fields_match", False, "user missing") + check_allowed_tables(before, after, judge, {"users"}) + + +def verify_collection_remove(before, after, trajectory, judge): + check_navigation(trajectory, judge, ("/login", "/user/alice_crate/collection")) + folder_ok = any(urlparse(url).path == "/user/alice_crate/collection" + and parse_qs(urlparse(url).query).get("folder") == ["Vinyl"] + for url in trajectory_urls(trajectory)) + judge.check("vinyl_folder_used", folder_ok, "folder=Vinyl") + delta = table_delta(before, after, "collection_items") + judge.check("one_collection_row_removed", len(delta["removed"]) == 1 and not delta["added"] and not delta["changed"], str(delta)) + if delta["removed"]: + row = delta["removed"][0] + user = one(before, "SELECT username FROM users WHERE id=?", (row["user_id"],)) + release = one(before, "SELECT discogs_id FROM releases WHERE id=?", (row["release_id"],)) + judge.check("removed_target_matches", bool(user and user["username"] == "alice_crate" + and release and release["discogs_id"] == 20271358), f"item_id={row['id']}") + else: + judge.check("removed_target_matches", False, "removed row missing") + check_release_counter(before, after, judge, 20271358, "have_count", -1) + check_allowed_tables(before, after, judge, {"collection_items", "releases"}) + + +def verify_rating_review(before, after, trajectory, judge): + check_navigation(trajectory, judge, ("/login", "/release/35846581")) + rating_delta = table_delta(before, after, "ratings") + review_delta = table_delta(before, after, "reviews") + judge.check("one_rating_added", len(rating_delta["added"]) == 1 and not rating_delta["removed"] and not rating_delta["changed"], str(rating_delta)) + judge.check("one_review_added", len(review_delta["added"]) == 1 and not review_delta["removed"] and not review_delta["changed"], str(review_delta)) + user = one(after, "SELECT id FROM users WHERE username='dave_techno'") + release = one(after, "SELECT id FROM releases WHERE discogs_id=35846581") + if rating_delta["added"] and review_delta["added"] and user and release: + rating, review = rating_delta["added"][0], review_delta["added"][0] + judge.check("rating_fields_match", rating["user_id"] == user["id"] and rating["release_id"] == release["id"] and rating["value"] == 4, f"rating_id={rating['id']}") + judge.check("review_fields_match", review["user_id"] == user["id"] and review["release_id"] == release["id"] and review["rating"] == 4 and review["body"] == "The live arrangements reward a focused listen from start to finish.", f"review_id={review['id']}") + else: + judge.check("rating_fields_match", False, "required rows missing") + judge.check("review_fields_match", False, "required rows missing") + old_release = one(before, "SELECT * FROM releases WHERE discogs_id=35846581") + new_release = one(after, "SELECT * FROM releases WHERE discogs_id=35846581") + changes = changed_columns(old_release, new_release) + aggregate = after.execute("SELECT avg(value), count(*) FROM ratings WHERE release_id=?", (new_release["id"],)).fetchone() + judge.check("rating_aggregate_updated", changes == {"avg_rating", "rating_count"} + and abs(new_release["avg_rating"] - aggregate[0]) < 1e-9 + and new_release["rating_count"] == aggregate[1], f"columns={sorted(changes)}") + release_delta = table_delta(before, after, "releases") + only_target_changed = ( + not release_delta["added"] + and not release_delta["removed"] + and len(release_delta["changed"]) == 1 + and release_delta["changed"][0][0]["discogs_id"] == 35846581 + and release_delta["changed"][0][1]["discogs_id"] == 35846581 + ) + judge.check("no_other_release_changes", only_target_changed, str(release_delta)) + check_allowed_tables(before, after, judge, {"ratings", "reviews", "releases"}) + + +STATE_VERIFIERS = { + 7: verify_collection_add, + 8: verify_wantlist_add, + 9: verify_list_create, + 10: verify_listing_add, + 11: verify_forum_post, + 12: verify_registration, + 13: verify_collection_remove, + 14: verify_rating_review, +} + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--run_dir", required=True) + parser.add_argument("--initial_db", default="") + parser.add_argument("--after_db", default="") + parser.add_argument("--container", default="") # accepted for interface compatibility; never read live + parser.add_argument("--no_llm", nargs="?", const="true", default="false") + return parser.parse_args() + + +def run(index: int) -> None: + args = parse_args() + task_id = f"Discogs--{index}" + judge = Judge(task_id) + run_dir = Path(args.run_dir) + try: + trajectory = json.loads((run_dir / "trajectory.json").read_text()) + except Exception as error: + judge.check("trajectory_load", False, str(error)) + judge.emit() + judge.check("task_identity", trajectory.get("task_id") == task_id, + f"actual={trajectory.get('task_id')!r}") + check_origin_discipline(trajectory, judge) + if index in READ_ONLY_SPECS: + verify_read_only(index, trajectory, judge) + initial = resolve_snapshot(run_dir, args.initial_db, ("initial_state.db", "before.db")) + after = resolve_snapshot(run_dir, args.after_db, ("after_state.db", "after.db")) + judge.check("frozen_initial_db", initial is not None, str(initial) if initial else "missing") + judge.check("frozen_after_db", after is not None, str(after) if after else "missing") + if initial is not None and after is not None: + try: + with open_db(initial) as before_db, open_db(after) as after_db: + check_allowed_tables(before_db, after_db, judge, set()) + except Exception as error: + judge.check("state_verification", False, f"{type(error).__name__}: {error}") + judge.emit() + + answer = final_answer(trajectory) + judge.check("final_answer_present", bool(answer), f"length={len(answer)}") + initial = resolve_snapshot(run_dir, args.initial_db, ("initial_state.db", "before.db")) + after = resolve_snapshot(run_dir, args.after_db, ("after_state.db", "after.db")) + judge.check("frozen_initial_db", initial is not None, str(initial) if initial else "missing") + judge.check("frozen_after_db", after is not None, str(after) if after else "missing") + if initial is None or after is None: + judge.emit() + try: + with open_db(initial) as before_db, open_db(after) as after_db: + STATE_VERIFIERS[index](before_db, after_db, trajectory, judge) + except Exception as error: + judge.check("state_verification", False, f"{type(error).__name__}: {error}") + judge.emit() diff --git a/websyn_start.sh b/websyn_start.sh index 941bf3231..c891c4c01 100644 --- a/websyn_start.sh +++ b/websyn_start.sh @@ -7,7 +7,7 @@ SITES=(allrecipes amazon apple arxiv bbc_news booking github google_flights google_map google_search huggingface wolfram_alpha cambridge_dictionary coursera espn merriam_webster ikea phys_org target ted osu rotten_tomatoes compass walmart_careers - fedex webmd_doctor healthline kaggle nvidia) + fedex webmd_doctor healthline kaggle nvidia discogs) BASE_PORT=40000 SITE_COUNT=${#SITES[@]} PID_DIR=/tmp/websyn_pids