From 28adc99d9bd003fffa9d40620a8aec9a44f9adc9 Mon Sep 17 00:00:00 2001
From: Builder
Date: Tue, 26 May 2026 16:17:33 +0000
Subject: [PATCH 01/10] Add Discogs mirror site (port 40015)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds the 16th WebHarbor mirror at https://discogs.com — the world's
largest music release database and marketplace. Real catalogue of 7,042
releases / 5,031 artists / 3,938 labels / 6,825 master records pulled
from the Discogs public API + MusicBrainz, plus 323 real album covers
from Wikipedia. Backed by a benchmark community of 29 users with seeded
ratings, reviews, collections, wantlists, lists, marketplace listings,
and forum threads.
Registered as the 16th site at port 40015. Image bumps to 16 mirrors
total; EXPOSE 40000-40015.
## Site features
- Release / master / artist / label / genre / style / format pages
- Token-overlap scored search across releases, artists, labels with
genre / style / format / year / country facets
- Marketplace with media-condition + genre filters, per-listing
comments, grades, currencies, sellers
- User collection (5 folders: Uncategorized / All / Vinyl / CD /
Wishlist Bought) with media + sleeve grades per item
- Wantlist with min-grade preferences
- User-curated public lists (CRUD)
- Forums (10 topical boards) with threaded replies
- Rating (1-5) + Review submission with helpful counts
- Auth (Flask-Login + bcrypt + CSRF), register / settings / logout
- 20 WebVoyager-format tasks in sites/discogs/tasks.jsonl
## Data scale
- releases: 7,042 (3,522 from Discogs API + 3,520 from MusicBrainz)
- artists: 5,031, labels: 3,938, masters: 6,825
- ratings: 86,714, reviews: 3,253
- collection_items: 4,207, wantlist_items: 1,374
- lists: 40, listings: 2,640, threads: 43, posts: 333
- benchmark users: alice_crate, bob_vinyl, carol_jazz, dave_techno
(passwords: alice12345 / bob123456 / carol12345 / dave12345)
- + 25 collector-style users with realistic locations & seller status
## Determinism work
- MIRROR_REFERENCE_DATE = datetime(2026,5,26) pins all date fields so
re-seeding from scraped_data/ is bit-for-bit reproducible
- random.Random(42) seed for the community generator
- Idempotent gates on every seed_*() function (count() > 0 → early
return); byte-identical reset verified
## Verification
- Docker build green; all 16 sites return 200
- POST /reset/discogs keeps DB byte-identical to seed
- All 20 tasks pass when walked via Playwright (Chromium)
## Paired Hugging Face assets
- Heavy assets shipped via the ChilleD/WebHarbor HF dataset:
- sites/discogs/instance_seed/discogs.db (13 MB)
- sites/discogs/static/images/release/*.jpg (323 covers, 32 MB)
- .assets-revision is left at `revision: main` so the HF merge will
roll in automatically (same approach as TED / Phys.org PRs).
---
Dockerfile | 4 +-
control_server.py | 2 +-
sites/discogs/_health.py | 3 +
sites/discogs/app.py | 1124 ++++++++++++++++++++
sites/discogs/requirements.txt | 9 +
sites/discogs/seed_data.py | 762 +++++++++++++
sites/discogs/static/css/.gitkeep | 0
sites/discogs/static/css/site.css | 205 ++++
sites/discogs/static/icons/.gitkeep | 0
sites/discogs/static/icons/no-cover.svg | 8 +
sites/discogs/static/js/.gitkeep | 0
sites/discogs/tasks.jsonl | 20 +
sites/discogs/templates/.gitkeep | 0
sites/discogs/templates/403.html | 9 +
sites/discogs/templates/404.html | 9 +
sites/discogs/templates/_macros.html | 42 +
sites/discogs/templates/artist.html | 62 ++
sites/discogs/templates/base.html | 100 ++
sites/discogs/templates/collection.html | 61 ++
sites/discogs/templates/explore.html | 33 +
sites/discogs/templates/format.html | 11 +
sites/discogs/templates/forum.html | 30 +
sites/discogs/templates/forum_index.html | 13 +
sites/discogs/templates/genre.html | 30 +
sites/discogs/templates/index.html | 91 ++
sites/discogs/templates/label.html | 39 +
sites/discogs/templates/list.html | 42 +
sites/discogs/templates/list_new.html | 14 +
sites/discogs/templates/lists.html | 23 +
sites/discogs/templates/login.html | 17 +
sites/discogs/templates/marketplace.html | 52 +
sites/discogs/templates/master.html | 28 +
sites/discogs/templates/register.html | 20 +
sites/discogs/templates/release.html | 197 ++++
sites/discogs/templates/search.html | 94 ++
sites/discogs/templates/sell.html | 44 +
sites/discogs/templates/settings.html | 17 +
sites/discogs/templates/style.html | 11 +
sites/discogs/templates/thread.html | 39 +
sites/discogs/templates/thread_new.html | 13 +
sites/discogs/templates/user.html | 66 ++
sites/discogs/templates/user_feedback.html | 10 +
sites/discogs/templates/user_lists.html | 18 +
sites/discogs/templates/user_reviews.html | 22 +
sites/discogs/templates/wantlist.html | 52 +
websyn_start.sh | 4 +-
46 files changed, 3445 insertions(+), 5 deletions(-)
create mode 100644 sites/discogs/_health.py
create mode 100644 sites/discogs/app.py
create mode 100644 sites/discogs/requirements.txt
create mode 100644 sites/discogs/seed_data.py
create mode 100644 sites/discogs/static/css/.gitkeep
create mode 100644 sites/discogs/static/css/site.css
create mode 100644 sites/discogs/static/icons/.gitkeep
create mode 100644 sites/discogs/static/icons/no-cover.svg
create mode 100644 sites/discogs/static/js/.gitkeep
create mode 100644 sites/discogs/tasks.jsonl
create mode 100644 sites/discogs/templates/.gitkeep
create mode 100644 sites/discogs/templates/403.html
create mode 100644 sites/discogs/templates/404.html
create mode 100644 sites/discogs/templates/_macros.html
create mode 100644 sites/discogs/templates/artist.html
create mode 100644 sites/discogs/templates/base.html
create mode 100644 sites/discogs/templates/collection.html
create mode 100644 sites/discogs/templates/explore.html
create mode 100644 sites/discogs/templates/format.html
create mode 100644 sites/discogs/templates/forum.html
create mode 100644 sites/discogs/templates/forum_index.html
create mode 100644 sites/discogs/templates/genre.html
create mode 100644 sites/discogs/templates/index.html
create mode 100644 sites/discogs/templates/label.html
create mode 100644 sites/discogs/templates/list.html
create mode 100644 sites/discogs/templates/list_new.html
create mode 100644 sites/discogs/templates/lists.html
create mode 100644 sites/discogs/templates/login.html
create mode 100644 sites/discogs/templates/marketplace.html
create mode 100644 sites/discogs/templates/master.html
create mode 100644 sites/discogs/templates/register.html
create mode 100644 sites/discogs/templates/release.html
create mode 100644 sites/discogs/templates/search.html
create mode 100644 sites/discogs/templates/sell.html
create mode 100644 sites/discogs/templates/settings.html
create mode 100644 sites/discogs/templates/style.html
create mode 100644 sites/discogs/templates/thread.html
create mode 100644 sites/discogs/templates/thread_new.html
create mode 100644 sites/discogs/templates/user.html
create mode 100644 sites/discogs/templates/user_feedback.html
create mode 100644 sites/discogs/templates/user_lists.html
create mode 100644 sites/discogs/templates/user_reviews.html
create mode 100644 sites/discogs/templates/wantlist.html
diff --git a/Dockerfile b/Dockerfile
index 991e5ab60..1e86b1d04 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,5 +1,5 @@
# WebHarbor — slim, self-contained image.
-# 15 Flask mirror sites + control plane on :8101.
+# 16 Flask mirror sites + control plane on :8101.
FROM python:3.12-slim-bookworm
@@ -33,6 +33,6 @@ COPY control_server.py /opt/control_server.py
COPY site_runner.py /opt/site_runner.py
RUN chmod +x /opt/websyn_start.sh
-EXPOSE 8101 40000-40014
+EXPOSE 8101 40000-40015
CMD ["/opt/websyn_start.sh"]
diff --git a/control_server.py b/control_server.py
index c255253c6..9d504ebb7 100644
--- a/control_server.py
+++ b/control_server.py
@@ -26,7 +26,7 @@
'allrecipes', 'amazon', 'apple', 'arxiv', 'bbc_news', 'booking',
'github', 'google_flights', 'google_map', 'google_search',
'huggingface', 'wolfram_alpha', 'cambridge_dictionary',
- 'coursera', 'espn',
+ 'coursera', 'espn', '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..047f0ecd9
--- /dev/null
+++ b/sites/discogs/app.py
@@ -0,0 +1,1124 @@
+"""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. Data ships in instance_seed/discogs.db and is seeded from
+scraped_data/releases.json via seed_data.py.
+"""
+import os
+import re
+import math
+import json
+import random
+from datetime import datetime, timedelta
+from functools import wraps
+from collections import defaultdict
+
+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
+
+BASE_DIR = os.path.dirname(os.path.abspath(__file__))
+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),
+)
+
+
+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)
+ 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)
+ 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)
+ 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_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="Correct")
+ 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")
+ 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.position")
+ 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):
+ return self.formats[0].name if self.formats else ""
+
+ @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 = f"images/release/{self.discogs_id or self.id}.jpg"
+ full = os.path.join(BASE_DIR, "static", path)
+ if os.path.exists(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="")
+
+
+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)
+
+
+COLLECTION_FOLDERS = ["Uncategorized", "All", "Vinyl", "CD", "Wishlist Bought"]
+
+
+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)"]
+
+
+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))
+
+
+@app.context_processor
+def inject_globals():
+ return {
+ "csrf_token": generate_csrf,
+ "now": datetime.utcnow(),
+ "grades": GRADES,
+ "folders": COLLECTION_FOLDERS,
+ }
+
+
+@app.template_filter("price")
+def fmt_price(v):
+ if v is None:
+ return "—"
+ return f"${v:,.2f}"
+
+
+@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 f"{s//60} min ago"
+ if s < 86400: return f"{s//3600} hours ago"
+ if s < 86400 * 30: return f"{s//86400} days ago"
+ if s < 86400 * 365: return f"{s//(86400*30)} months ago"
+ return f"{s//(86400*365)} years ago"
+
+
+@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 search_releases(q, genre=None, style=None, format_=None, year=None, country=None,
+ sort="relevance", page=1, per_page=25):
+ qs = Release.query
+ if q:
+ terms = [t for t in re.split(r"\s+", q.strip()) if t]
+ if terms:
+ anyclause = or_(*[
+ Release.title.ilike(f"%{t}%") for t in terms
+ ] + [
+ Artist.name.ilike(f"%{t}%") for t in terms
+ ])
+ qs = qs.join(Artist, Release.artist_id == Artist.id).filter(anyclause)
+ 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))
+ if 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:
+ qs = qs.order_by(Release.have_count.desc())
+ return paginate(qs.distinct(), page, per_page)
+
+
+# ──────────────────────────────────────────────
+# Routes — public
+# ──────────────────────────────────────────────
+
+@app.route("/")
+def index():
+ new_arrivals = Release.query.order_by(Release.added_at.desc()).limit(8).all()
+ top_rated = Release.query.filter(Release.rating_count >= 3) \
+ .order_by(Release.avg_rating.desc(), Release.rating_count.desc()) \
+ .limit(8).all()
+ most_collected = Release.query.order_by(Release.have_count.desc()).limit(8).all()
+ most_wanted = Release.query.order_by(Release.want_count.desc()).limit(8).all()
+ recent_lists = List.query.filter_by(is_public=True).order_by(List.created_at.desc()).limit(6).all()
+ forums = Forum.query.order_by(Forum.id).limit(8).all()
+ return render_template("index.html",
+ new_arrivals=new_arrivals,
+ top_rated=top_rated,
+ most_collected=most_collected,
+ most_wanted=most_wanted,
+ recent_lists=recent_lists,
+ forums=forums)
+
+
+@app.route("/search")
+def search():
+ q = request.args.get("q", "").strip()
+ type_ = request.args.get("type", "release")
+ sort = request.args.get("sort", "relevance")
+ page = int(request.args.get("page", 1))
+ 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"),
+ }
+ results = None
+ artists = labels = []
+ if type_ == "artist" and q:
+ 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])
+ artists = Artist.query.filter(clause).order_by(Artist.in_collection.desc()).limit(50).all()
+ elif type_ == "label" and q:
+ 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])
+ labels = Label.query.filter(clause).order_by(Label.name.asc()).limit(50).all()
+ else:
+ results = search_releases(q, sort=sort, page=page, **filters)
+
+ facet_genres = Genre.query.order_by(Genre.name).all()
+ facet_styles = Style.query.order_by(Style.name).limit(40).all()
+ facet_formats = Format.query.order_by(Format.name).all()
+ return render_template("search.html",
+ q=q, type_=type_, sort=sort,
+ results=results, artists=artists, labels=labels,
+ filters=filters,
+ facet_genres=facet_genres,
+ facet_styles=facet_styles,
+ facet_formats=facet_formats)
+
+
+@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
+ return render_template("release.html",
+ r=r, reviews=reviews, rating_hist=rating_hist,
+ listings=listings, other_versions=other_versions,
+ 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 = int(request.args.get("page", 1))
+ q = a.releases
+ 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 = int(request.args.get("page", 1))
+ 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 = int(request.args.get("page", 1))
+ 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 = int(request.args.get("page", 1))
+ 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 = int(request.args.get("page", 1))
+ 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 = int(request.args.get("page", 1))
+ q = List.query.filter_by(is_public=True).order_by(List.created_at.desc())
+ pag = paginate(q, page, 20)
+ return render_template("lists.html", pag=pag)
+
+
+@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]
+ if rid and Release.query.get(rid):
+ pos = (lst.items.count() or 0) + 1
+ db.session.add(ListItem(list_id=lid, release_id=rid, 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 = int(request.args.get("page", 1))
+ sort = request.args.get("sort", "price_asc")
+ media = request.args.get("media", "") # e.g. "Near Mint (NM or M-)"
+ genre = request.args.get("genre", "")
+ q = Listing.query.filter_by(status="For Sale").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 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()
+ return render_template("marketplace.html", pag=pag, sort=sort,
+ media=media, genre=genre, grades=GRADES, genres=genres)
+
+
+@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 = float(request.form.get("price"))
+ except (TypeError, ValueError):
+ flash("Price must be a number.", "error")
+ return redirect(url_for("sell"))
+ l = Listing(user_id=current_user.id, release_id=release.id,
+ media_condition=request.form.get("media_condition", "Very Good Plus (VG+)"),
+ sleeve_condition=request.form.get("sleeve_condition", "Very Good Plus (VG+)"),
+ comments=request.form.get("comments", "")[:600],
+ price=price,
+ currency=request.form.get("currency", "USD"),
+ shipping_from=request.form.get("shipping_from", "United States"),
+ allow_offers=bool(request.form.get("allow_offers")))
+ current_user.is_seller = True
+ db.session.add(l)
+ db.session.commit()
+ 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").scalar()
+ db.session.commit()
+ flash("Listing posted to the marketplace.", "success")
+ return redirect(url_for("marketplace"))
+ 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", "All")
+ page = int(request.args.get("page", 1))
+ q = u.collection_items.join(Release)
+ if folder != "All":
+ 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)
+
+
+@app.route("/user//wantlist")
+def user_wantlist(username):
+ u = User.query.filter_by(username=username).first_or_404()
+ page = int(request.args.get("page", 1))
+ 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()
+ lists = u.lists.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 redirect(request.referrer or url_for("index"))
+ 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=request.form.get("folder", "Uncategorized"),
+ media_condition=request.form.get("media_condition", "Near Mint (NM or M-)"),
+ sleeve_condition=request.form.get("sleeve_condition", "Near Mint (NM or M-)"),
+ 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 redirect(request.referrer or 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 redirect(request.referrer or 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 redirect(request.referrer or url_for("index"))
+ 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=request.form.get("min_grade", "Very Good Plus (VG+)"),
+ 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 redirect(request.referrer or 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 redirect(request.referrer or 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 redirect(request.referrer or 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 redirect(request.referrer or 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 redirect(request.referrer or url_for("index"))
+ rv = Review(user_id=current_user.id, release_id=rid, body=body[:4000],
+ rating=rating if rating and 1 <= rating <= 5 else None)
+ db.session.add(rv)
+ db.session.commit()
+ flash("Review posted.", "success")
+ r = Release.query.get(rid)
+ 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()
+ return render_template("forum_index.html", forums=forums)
+
+
+@app.route("/forum/")
+def forum_view(slug):
+ f = Forum.query.filter_by(slug=slug).first_or_404()
+ page = int(request.args.get("page", 1))
+ 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()
+ return render_template("thread.html", t=t, posts=posts)
+
+
+@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 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(request.args.get("next") or 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", "")
+ if not (3 <= len(username) <= 40 and "@" in email and len(pw) >= 6):
+ flash("Username 3-40 chars; valid email; password ≥6.", "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", "GET"])
+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")
+
+
+# ──────────────────────────────────────────────
+# 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()
+ 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/requirements.txt b/sites/discogs/requirements.txt
new file mode 100644
index 000000000..c3bbdb803
--- /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.1
+Flask-Bcrypt==1.0.1
+WTForms==3.1.2
+SQLAlchemy==2.0.36
+Werkzeug==3.1.3
+bcrypt==4.2.1
diff --git a/sites/discogs/seed_data.py b/sites/discogs/seed_data.py
new file mode 100644
index 000000000..e076ace7b
--- /dev/null
+++ b/sites/discogs/seed_data.py
@@ -0,0 +1,762 @@
+"""Idempotent seed loader for the Discogs mirror.
+
+Pulls real catalog metadata from scraped_data/releases.json (Discogs API)
+and scraped_data/mb_releases.json (MusicBrainz), plus Wikipedia
+descriptions/cover URLs from scraped_data/wikipedia.json. Generates a
+benchmark community on top — users, ratings, reviews, collections,
+wantlists, lists, marketplace listings, forums.
+
+Every seed_*() function is gated by an existence check so it is a no-op
+on a populated DB. That is the contract that keeps /reset/discogs
+byte-identical.
+"""
+import json
+import os
+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,
+)
+
+BASE_DIR = os.path.dirname(os.path.abspath(__file__))
+SCRAPED = os.path.join(BASE_DIR, "scraped_data")
+
+# 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"
+
+
+def _load_json(name):
+ p = os.path.join(SCRAPED, name)
+ if not os.path.exists(p):
+ return None
+ try:
+ with open(p) as f:
+ return json.load(f)
+ except Exception as e:
+ print(f"[seed] failed to read {name}: {e}")
+ return None
+
+
+# ──────────────────────────────────────────────
+# 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 _get_or_create_artist(name, cache):
+ name = (name or "Unknown").strip() or "Unknown"
+ if name in cache:
+ return cache[name]
+ base_slug = slugify(name)
+ slug = base_slug
+ i = 1
+ while Artist.query.filter_by(slug=slug).first() is not None:
+ i += 1
+ slug = f"{base_slug}-{i}"
+ a = Artist(name=name[:200], slug=slug[:220])
+ db.session.add(a)
+ db.session.flush()
+ cache[name] = a
+ return a
+
+
+def _get_or_create_label(name, cache):
+ name = (name or "").strip()
+ if not name or name in cache:
+ return cache.get(name)
+ base_slug = slugify(name)
+ slug = base_slug
+ i = 1
+ while Label.query.filter_by(slug=slug).first() is not None:
+ i += 1
+ slug = f"{base_slug}-{i}"
+ l = Label(name=name[:200], slug=slug[:220])
+ db.session.add(l)
+ db.session.flush()
+ cache[name] = l
+ return l
+
+
+def _get_or_create_named(model, name, cache):
+ name = (name or "").strip()
+ if not name:
+ return None
+ if name in cache:
+ return cache[name]
+ base_slug = slugify(name)
+ slug = base_slug
+ i = 1
+ while model.query.filter_by(slug=slug).first() is not None:
+ i += 1
+ slug = f"{base_slug}-{i}"
+ obj = model(name=name[:80], slug=slug[:80])
+ db.session.add(obj)
+ db.session.flush()
+ cache[name] = obj
+ return obj
+
+
+def _build_tracklist_for(release_id, n=8, base_title=""):
+ """Plausible-looking placeholder tracklist when we have none."""
+ rng = random.Random(release_id * 13 + 7)
+ tracks = []
+ for i in range(1, n + 1):
+ side = "A" if i <= n / 2 else "B"
+ pos = f"{side}{i if i <= n/2 else i - int(n/2)}"
+ title = f"Track {i}"
+ dur_sec = rng.randint(150, 360)
+ tracks.append((pos, title, f"{dur_sec//60}:{dur_sec%60:02d}"))
+ return tracks
+
+
+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 _release_image_exists(rid):
+ return os.path.exists(os.path.join(BASE_DIR, "static", "images", "release", f"{rid}.jpg"))
+
+
+def seed_database():
+ if Release.query.count() > 0:
+ return
+ print("[seed] catalogue (releases / artists / labels / tracks)")
+
+ seed_taxonomy()
+ seed_forums()
+
+ artist_cache = {}
+ label_cache = {}
+ genre_cache = {g.name: g for g in Genre.query.all()}
+ style_cache = {}
+ format_cache = {f.name: f for f in Format.query.all()}
+ master_cache = {}
+
+ wp_cache = _load_json("wikipedia.json") or {}
+ discogs_data = _load_json("releases.json") or []
+ mb_data = _load_json("mb_releases.json") or []
+
+ next_synth_id = 90_000_001 # synthetic discogs_id for non-Discogs sources
+
+ seen_keys = set()
+
+ # ── Discogs source ──────────────────────────
+ for d in discogs_data:
+ title = (d.get("title") or "").strip()
+ artist_name = (d.get("artist") or "").strip() or "Various"
+ if not title:
+ continue
+ key = f"discogs-{d['id']}"
+ if key in seen_keys:
+ continue
+ seen_keys.add(key)
+
+ artist = _get_or_create_artist(artist_name, artist_cache)
+ year = None
+ if d.get("year"):
+ try: year = int(d["year"])
+ except (TypeError, ValueError): pass
+
+ master_key = (artist.id, title.lower())
+ if master_key not in master_cache:
+ m = Master(title=title[:300], artist_id=artist.id, year=year)
+ db.session.add(m); db.session.flush()
+ master_cache[master_key] = m
+ master = master_cache[master_key]
+
+ # Wikipedia extract
+ wp = wp_cache.get(key, {})
+ notes = wp.get("extract") or ""
+
+ r = Release(
+ discogs_id=int(d["id"]),
+ title=title[:300],
+ artist_id=artist.id,
+ master_id=master.id,
+ year=year,
+ country=(d.get("country") or "")[:80],
+ notes=notes,
+ barcode=(d.get("barcode")[0] if d.get("barcode") else "")[:80],
+ catno=(d.get("catno") or "")[:80],
+ image_path=f"images/release/{d['id']}.jpg" if _release_image_exists(d['id']) else "",
+ have_count=(d.get("community") or {}).get("have", 0) or random.randint(20, 500),
+ want_count=(d.get("community") or {}).get("want", 0) or random.randint(5, 200),
+ added_at=NOW - timedelta(days=random.randint(1, 800)),
+ )
+ db.session.add(r); db.session.flush()
+
+ # Genres
+ for gn in (d.get("genre") or []):
+ g = genre_cache.get(gn) or _get_or_create_named(Genre, gn, genre_cache)
+ if g and g not in r.genres:
+ r.genres.append(g)
+ # Styles
+ for sn in (d.get("style") or []):
+ s = _get_or_create_named(Style, sn, style_cache)
+ if s and s not in r.styles:
+ r.styles.append(s)
+ # Formats
+ for fn in (d.get("format") or [])[:4]:
+ f = format_cache.get(fn) or _get_or_create_named(Format, fn, format_cache)
+ if f and f not in r.formats:
+ r.formats.append(f)
+ # Labels (first 3)
+ for ln in (d.get("label") or [])[:3]:
+ l = _get_or_create_label(ln, label_cache)
+ if l and l not in r.labels:
+ r.labels.append(l)
+
+ # Tracks (placeholder if none)
+ for pos, title_t, dur in _build_tracklist_for(r.discogs_id):
+ db.session.add(Track(release_id=r.id, position=pos, title=title_t, duration=dur))
+
+ db.session.commit()
+
+ # ── MusicBrainz source ──────────────────────
+ for d in mb_data:
+ title = (d.get("title") or "").strip()
+ if not title:
+ continue
+ artists = d.get("artists") or []
+ artist_name = (artists[0].get("name") if artists else "Various Artists").strip() or "Various"
+ key = f"mb-{d['id']}"
+ if key in seen_keys:
+ continue
+ seen_keys.add(key)
+
+ artist = _get_or_create_artist(artist_name, artist_cache)
+
+ year = None
+ d_str = d.get("first_release_date") or ""
+ if d_str:
+ try: year = int(d_str[:4])
+ except (ValueError, TypeError): pass
+
+ master_key = (artist.id, title.lower())
+ if master_key not in master_cache:
+ m = Master(title=title[:300], artist_id=artist.id, year=year)
+ db.session.add(m); db.session.flush()
+ master_cache[master_key] = m
+ master = master_cache[master_key]
+
+ wp = wp_cache.get(key, {})
+ notes = wp.get("extract") or ""
+
+ synth_id = next_synth_id
+ next_synth_id += 1
+
+ # Pick a plausible format spread.
+ fmt_choices = random.choice([
+ ["Vinyl", "LP", "Album"],
+ ["CD", "Album"],
+ ["Vinyl", "LP", "Album", "Reissue"],
+ ["Cassette", "Album"],
+ ["CD", "Album", "Compilation"],
+ ["File", "FLAC", "Album"],
+ ])
+
+ r = Release(
+ discogs_id=synth_id,
+ title=title[:300],
+ artist_id=artist.id,
+ master_id=master.id,
+ year=year,
+ released=d_str,
+ country=random.choice(["US", "UK", "Germany", "Japan", "France", "Netherlands",
+ "Italy", "Brazil", "Canada", "Australia", "Sweden", ""]),
+ notes=notes,
+ image_path="", # MB releases don't have an image_path; cover_url checks at request time
+ have_count=random.randint(10, 800),
+ want_count=random.randint(2, 350),
+ added_at=NOW - timedelta(days=random.randint(1, 1200)),
+ )
+ db.session.add(r); db.session.flush()
+
+ # Use the source tag as a genre fallback.
+ tag = (d.get("tag_query") or "").strip()
+ gname_map = {
+ "hip hop": "Hip Hop", "techno": "Electronic", "house": "Electronic",
+ "ambient": "Electronic", "drum and bass": "Electronic", "dubstep": "Electronic",
+ "trance": "Electronic", "downtempo": "Electronic", "trip hop": "Electronic",
+ "soul": "Funk / Soul", "funk": "Funk / Soul", "disco": "Funk / Soul",
+ "country": "Folk, World, & Country", "folk": "Folk, World, & Country",
+ "blues": "Blues", "classical": "Classical", "jazz": "Jazz",
+ "reggae": "Reggae", "dub": "Reggae", "ska": "Reggae",
+ "pop": "Pop", "k-pop": "Pop", "j-pop": "Pop", "city pop": "Pop",
+ "metal": "Rock", "death metal": "Rock", "black metal": "Rock",
+ "thrash metal": "Rock", "punk": "Rock", "hardcore": "Rock",
+ "post-hardcore": "Rock", "alternative rock": "Rock",
+ "garage rock": "Rock", "psychedelic rock": "Rock",
+ "progressive rock": "Rock", "krautrock": "Rock", "post-rock": "Rock",
+ "math rock": "Rock", "indie pop": "Pop", "synth-pop": "Pop",
+ "post-punk": "Rock", "shoegaze": "Rock", "experimental": "Electronic",
+ "noise": "Electronic", "drone": "Electronic", "industrial": "Electronic",
+ "minimal": "Electronic", "lo-fi": "Pop", "salsa": "Latin",
+ "bossa nova": "Latin", "latin": "Latin", "afrobeat": "Funk / Soul",
+ "world": "Folk, World, & Country", "electronic": "Electronic",
+ "boom bap": "Hip Hop", "trap": "Hip Hop", "gangsta rap": "Hip Hop",
+ "conscious hip hop": "Hip Hop", "soundtrack": "Stage & Screen",
+ }
+ gname = gname_map.get(tag, "Electronic" if not tag else "Rock")
+ if gname not in genre_cache:
+ genre_cache[gname] = _get_or_create_named(Genre, gname, genre_cache)
+ g = genre_cache[gname]
+ if g and g not in r.genres:
+ r.genres.append(g)
+ # The tag itself becomes a style.
+ if tag and tag.title() != gname:
+ s = _get_or_create_named(Style, tag.title(), style_cache)
+ if s and s not in r.styles:
+ r.styles.append(s)
+
+ for fn in fmt_choices:
+ f = format_cache.get(fn) or _get_or_create_named(Format, fn, format_cache)
+ if f and f not in r.formats:
+ r.formats.append(f)
+
+ # Tracks
+ for pos, title_t, dur in _build_tracklist_for(r.id, n=random.choice([8, 10, 12])):
+ db.session.add(Track(release_id=r.id, position=pos, title=title_t, duration=dur))
+
+ db.session.commit()
+
+ # Aggregate artist.in_collection (used to sort artist search).
+ for a in Artist.query.all():
+ a.in_collection = a.releases.count()
+ db.session.commit()
+
+ print(f"[seed] inserted {Release.query.count()} releases / {Artist.query.count()} artists "
+ f"/ {Label.query.count()} labels / {Master.query.count()} masters")
+
+
+# ──────────────────────────────────────────────
+# 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() or r.have_count
+ r.want_count = WantlistItem.query.filter_by(release_id=r.id).count() or r.want_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..e746b32ec
--- /dev/null
+++ b/sites/discogs/static/css/site.css
@@ -0,0 +1,205 @@
+/* Discogs mirror — global stylesheet */
+
+* { box-sizing: border-box; }
+html, body { margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI",
+ Roboto, Helvetica, Arial, sans-serif;
+ color: #1a1a1a; background: #f4f4f1; line-height: 1.42; }
+a { color: #105e9c; text-decoration: none; }
+a:hover { text-decoration: underline; color: #073961; }
+img { max-width: 100%; height: auto; display: block; }
+button, .btn { font: inherit; border: 1px solid #ccc; background: #fafafa; color: #1a1a1a;
+ padding: 6px 14px; border-radius: 3px; cursor: pointer; }
+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 */
+.site-header { background: #2c2c2c; color: #fff; }
+.site-header .top { display: flex; align-items: center; padding: 10px 20px; gap: 20px; }
+.site-header .logo { font-size: 1.7rem; font-weight: 900; color: #fff;
+ letter-spacing: -0.02em; font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; }
+.site-header .logo:hover { text-decoration: none; color: #ff8c00; }
+.site-header .nav { display: flex; gap: 18px; flex-wrap: wrap; font-size: 0.95em; }
+.site-header .nav a { color: #ddd; }
+.site-header .nav a:hover { color: #fff; text-decoration: none; }
+.site-header .search { flex: 1; max-width: 620px; display: flex; }
+.site-header .search input { background: #fff; color: #000; border-radius: 3px 0 0 3px; }
+.site-header .search select { width: auto; border-radius: 0; border-left: 0; border-right: 0; max-width: 120px; }
+.site-header .search button { border-radius: 0 3px 3px 0; background: #ff8c00; border-color: #cc6f00;
+ color: #fff; padding: 7px 16px; }
+.site-header .user-area { display: flex; gap: 12px; align-items: center; }
+.site-header .user-area a { color: #ddd; }
+.site-header .user-area .avatar { width: 30px; height: 30px; border-radius: 50%;
+ display: inline-block; vertical-align: middle; }
+.site-header .subnav { background: #1f1f1f; padding: 7px 20px;
+ display: flex; gap: 22px; font-size: 0.88em; flex-wrap: wrap; }
+.site-header .subnav a { color: #aaa; }
+.site-header .subnav a:hover, .site-header .subnav a.active { color: #fff; text-decoration: none; }
+
+/* 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; }
+.card { background: #fff; border: 1px solid #e0e0e0; border-radius: 3px; padding: 16px;
+ margin-bottom: 18px; }
+.card h2 { margin-top: 0; }
+
+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; }
+.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(160px, 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 0 320px; 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; 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 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; }
+
+.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; 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 */
+.site-footer { background: #1f1f1f; color: #999; padding: 28px 20px; margin-top: 50px;
+ font-size: 0.85em; }
+.site-footer .inner { max-width: 1200px; margin: 0 auto; display: flex;
+ flex-wrap: wrap; gap: 30px; }
+.site-footer a { color: #ccc; }
+.site-footer h4 { color: #fff; font-size: 0.92em; margin: 0 0 8px; text-transform: uppercase;
+ letter-spacing: 0.07em; }
+.site-footer ul { list-style: none; padding: 0; margin: 0; }
+.site-footer li { padding: 2px 0; }
+
+/* Misc */
+.section-header { display: flex; justify-content: space-between; align-items: baseline; }
+.section-header a { font-size: 0.85em; }
+.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; }
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/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/tasks.jsonl b/sites/discogs/tasks.jsonl
new file mode 100644
index 000000000..29bc926ae
--- /dev/null
+++ b/sites/discogs/tasks.jsonl
@@ -0,0 +1,20 @@
+{"web_name": "Discogs", "id": "Discogs--0", "ques": "Search for the album 'Outlandos d'Amour' by The Police on Discogs. Open the release page and report how many users have it in their collection (the 'Have' count).", "web": "http://localhost:40023/", "upstream_url": "https://www.discogs.com/"}
+{"web_name": "Discogs", "id": "Discogs--1", "ques": "Go to the Jazz genre page and sort by 'Highest Rated'. What is the title and artist of the top release on the first page?", "web": "http://localhost:40023/", "upstream_url": "https://www.discogs.com/"}
+{"web_name": "Discogs", "id": "Discogs--2", "ques": "Open the Marketplace and sort listings by price (cheapest first). What is the title of the cheapest release for sale, and which seller is offering it?", "web": "http://localhost:40023/", "upstream_url": "https://www.discogs.com/"}
+{"web_name": "Discogs", "id": "Discogs--3", "ques": "Log in as alice_crate (password: alice12345). Open the release page for 'Outlandos d'Amour' by The Police and add it to your collection in the 'Vinyl' folder with media condition 'Near Mint (NM or M-)'.", "web": "http://localhost:40023/", "upstream_url": "https://www.discogs.com/"}
+{"web_name": "Discogs", "id": "Discogs--4", "ques": "Log in as bob_vinyl (password: bob123456). Add the release '1984' by Van Halen (year 1984) to your wantlist with the minimum acceptable grade set to 'Very Good (VG)'.", "web": "http://localhost:40023/", "upstream_url": "https://www.discogs.com/"}
+{"web_name": "Discogs", "id": "Discogs--5", "ques": "Browse to the Miles Davis artist page and sort his releases by 'Year ↑' (oldest first). What is the title of the very first (oldest) release listed?", "web": "http://localhost:40023/", "upstream_url": "https://www.discogs.com/"}
+{"web_name": "Discogs", "id": "Discogs--6", "ques": "Open the Vinyl Collectors forum and find the thread titled 'Cartridges: MM vs MC for jazz'. Log in as carol_jazz (password: carol12345) and post a reply with body 'I'd lean toward MC for jazz — better detail in the upper mids.'", "web": "http://localhost:40023/", "upstream_url": "https://www.discogs.com/"}
+{"web_name": "Discogs", "id": "Discogs--7", "ques": "On Discogs, browse the Columbia label page. How many releases in total are catalogued on Columbia (the number shown in the 'releases on' subtitle)?", "web": "http://localhost:40023/", "upstream_url": "https://www.discogs.com/"}
+{"web_name": "Discogs", "id": "Discogs--8", "ques": "Log in as dave_techno (password: dave12345). Create a new public list titled 'Techno Bangers for the Club' with description 'My go-to selectors for peak time.' Then return its list URL (e.g. /list/41).", "web": "http://localhost:40023/", "upstream_url": "https://www.discogs.com/"}
+{"web_name": "Discogs", "id": "Discogs--9", "ques": "Find the user page for alice_crate. How many items does she have in her Collection, and how many in her Wantlist (report both numbers)?", "web": "http://localhost:40023/", "upstream_url": "https://www.discogs.com/"}
+{"web_name": "Discogs", "id": "Discogs--10", "ques": "Use search to find releases by 'Bob Marley'. Filter the results to only the Reggae genre. How many results are returned in total?", "web": "http://localhost:40023/", "upstream_url": "https://www.discogs.com/"}
+{"web_name": "Discogs", "id": "Discogs--11", "ques": "Register a brand new Discogs account with username 'craterunner99', email 'craterunner99@test.com', password 'discogs2026', and location 'Portland, USA'. After registering, go to Settings and add the bio 'Digging since 2010.'", "web": "http://localhost:40023/", "upstream_url": "https://www.discogs.com/"}
+{"web_name": "Discogs", "id": "Discogs--12", "ques": "Open the home page and look at the 'Most Collected' section. Report the title and artist of the third release in that section.", "web": "http://localhost:40023/", "upstream_url": "https://www.discogs.com/"}
+{"web_name": "Discogs", "id": "Discogs--13", "ques": "Log in as bob_vinyl (password: bob123456). Go to the Marketplace 'Sell' page and list release ID 793593 ('Outlandos d'Amour' by The Police) for sale at $42.00 USD, media condition 'Very Good Plus (VG+)', sleeve condition 'Very Good Plus (VG+)', shipping from 'United Kingdom', with the comment 'First UK pressing, plays cleanly throughout.'", "web": "http://localhost:40023/", "upstream_url": "https://www.discogs.com/"}
+{"web_name": "Discogs", "id": "Discogs--14", "ques": "Find the release 'Live-Evil' by Miles Davis (1971, US). How many tracks are on its tracklist?", "web": "http://localhost:40023/", "upstream_url": "https://www.discogs.com/"}
+{"web_name": "Discogs", "id": "Discogs--15", "ques": "On the Discogs Explore page, find the Electronic genre tile and click into it. Then click the Techno style sub-tag. What is the title of the very first (top) release shown after navigating into Techno?", "web": "http://localhost:40023/", "upstream_url": "https://www.discogs.com/"}
+{"web_name": "Discogs", "id": "Discogs--16", "ques": "Log in as alice_crate (password: alice12345). Open her own Collection page, navigate to the 'Vinyl' folder, and remove the release with title 'HOUSE NATION - Aquamarine' from the Collection.", "web": "http://localhost:40023/", "upstream_url": "https://www.discogs.com/"}
+{"web_name": "Discogs", "id": "Discogs--17", "ques": "Open dave_techno's profile page. He has multiple public lists — which one should I share with a friend who is going to a listening bar tonight? Open the most relevant list and report its title and the number of items in it.", "web": "http://localhost:40023/", "upstream_url": "https://www.discogs.com/"}
+{"web_name": "Discogs", "id": "Discogs--18", "ques": "Filter the Marketplace by lowest price and find the cheapest listing whose media condition is 'Near Mint (NM or M-)'. What is its price (with currency) and what release is being sold?", "web": "http://localhost:40023/", "upstream_url": "https://www.discogs.com/"}
+{"web_name": "Discogs", "id": "Discogs--19", "ques": "Browse to the General Discussion forum. Open the thread 'Favourite album opener of all time'. Log in as dave_techno (password: dave12345) and post a reply with body 'For me it has to be the opener of 'Selected Ambient Works Volume II'.'", "web": "http://localhost:40023/", "upstream_url": "https://www.discogs.com/"}
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/_macros.html b/sites/discogs/templates/_macros.html
new file mode 100644
index 000000000..73418a0cb
--- /dev/null
+++ b/sites/discogs/templates/_macros.html
@@ -0,0 +1,42 @@
+{% macro release_card(r) %}
+
+
+ {{ r.title }}
+ {{ r.artist.name }}
+ {{ r.year or '' }} · {{ r.primary_format or '' }}{% if r.country %} · {{ r.country }}{% endif %}
+ {% if r.lowest_price %}from ${{ '%.2f'|format(r.lowest_price) }}
{% endif %}
+
+{% 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 %}
+
+ {% 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
+
+
+
+
+
+
+
+
+ {% 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..58688b316
--- /dev/null
+++ b/sites/discogs/templates/base.html
@@ -0,0 +1,100 @@
+
+
+
+
+ {% block title %}Discogs — Music Database and Marketplace{% endblock %}
+
+
+
+
+
+
+
+
+ {% with messages = get_flashed_messages(with_categories=true) %}
+ {% if messages %}
+
+ {% for cat, msg in messages %}
+ {{ msg }}
+ {% endfor %}
+
+ {% endif %}
+ {% endwith %}
+
+ {% block content %}{% endblock %}
+
+
+
+
+
diff --git a/sites/discogs/templates/collection.html b/sites/discogs/templates/collection.html
new file mode 100644
index 000000000..868d2e720
--- /dev/null
+++ b/sites/discogs/templates/collection.html
@@ -0,0 +1,61 @@
+{% 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 ['All'] + folders %}
+
{{ f }}
+ {% endfor %}
+
+
+
+
+
+
+
+ Release
+ Label
+ Format
+ Condition
+ Added
+ {% if current_user.is_authenticated and current_user.id == u.id %} {% endif %}
+
+
+
+ {% for c in pag.items %}
+
+
+
+
+
+
+
+ {{ c.release.title }}
+ {{ c.release.artist.name }} · {{ 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 current_user.is_authenticated and current_user.id == u.id %}
+
+
+
+ {% endif %}
+
+ {% endfor %}
+
+
+ {% 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/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
+
+
+
+
+
+
+
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..b2827b8da
--- /dev/null
+++ b/sites/discogs/templates/forum.html
@@ -0,0 +1,30 @@
+{% extends "base.html" %}
+{% import "_macros.html" as m %}
+{% block title %}{{ f.name }} | Discogs Forum{% endblock %}
+{% block content %}
+{{ f.name }}
+{{ f.description }}
+{% if current_user.is_authenticated %}
+ + Start a new thread
+{% endif %}
+
+
+
Title
Replies
Author
Last activity
+
+ {% 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..b5dd04f65
--- /dev/null
+++ b/sites/discogs/templates/forum_index.html
@@ -0,0 +1,13 @@
+{% extends "base.html" %}
+{% block title %}Forum | Discogs{% endblock %}
+{% block content %}
+Discogs Forum
+
+{% for f in forums %}
+
+
+
{{ f.description }} · {{ f.threads.count() }} threads
+
+{% endfor %}
+
+{% 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 %}
+
+{% endif %}
+
+
+
+
{% 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/index.html b/sites/discogs/templates/index.html
new file mode 100644
index 000000000..2c7d83665
--- /dev/null
+++ b/sites/discogs/templates/index.html
@@ -0,0 +1,91 @@
+{% extends "base.html" %}
+{% block title %}Discogs — Music Database{% endblock %}
+
+{% macro release_card(r) %}
+
+
+ {{ r.title }}
+ {{ r.artist.name }}
+ {{ r.year or '' }} · {{ r.primary_format or '' }}{% if r.country %} · {{ r.country }}{% endif %}
+ {% if r.lowest_price %}from ${{ '%.2f'|format(r.lowest_price) }}
{% endif %}
+
+{% endmacro %}
+
+{% block content %}
+
+
Welcome to Discogs
+
+ The largest music database and marketplace in the world. Catalog your collection,
+ find rare records, and connect with collectors worldwide.
+
+
Join free
+
Explore the database
+
+
+
+
+
+ {% for r in new_arrivals %}{{ release_card(r) }}{% endfor %}
+
+
+
+
+
+
+ {% for r in most_collected %}{{ release_card(r) }}{% endfor %}
+
+
+
+
+
+
+
+
+ {% for r in top_rated %}{{ release_card(r) }}{% endfor %}
+
+
+
+
+
+ {% for r in most_wanted %}{{ release_card(r) }}{% endfor %}
+
+
+
+
+
+
Lists
+ {% for l in recent_lists %}
+
+
{{ l.title }}
+
by {{ l.user.username }} · {{ l.created_at|relative }}
+
+ {% else %}
+
No public lists yet.
+ {% endfor %}
+
Browse all lists →
+
+
+
+
Forum
+ {% for f in forums %}
+
+ {% endfor %}
+
+
+
+{% 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 %}
+
+{% 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..66543294b
--- /dev/null
+++ b/sites/discogs/templates/list.html
@@ -0,0 +1,42 @@
+{% extends "base.html" %}
+{% import "_macros.html" as m %}
+{% block title %}{{ lst.title }} | Discogs Lists{% endblock %}
+{% block content %}
+{{ lst.title }}
+by {{ lst.user.username }}
+ · {{ lst.items.count() }} items · {{ lst.created_at|relative }}
+ {% if not lst.is_public %} · private{% endif %}
+{% if lst.description %}{{ lst.description }}
{% endif %}
+
+{% if current_user.is_authenticated and current_user.id == lst.user_id %}
+
+
Add a release
+
+
+{% endif %}
+
+
+{% 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..1c21eb518
--- /dev/null
+++ b/sites/discogs/templates/lists.html
@@ -0,0 +1,23 @@
+{% extends "base.html" %}
+{% import "_macros.html" as m %}
+{% block title %}Lists | Discogs{% endblock %}
+{% block content %}
+User Lists
+{{ pag.total }} public lists
+{% if current_user.is_authenticated %}
+ + Create new list
+{% endif %}
+
+{% for l in pag.items %}
+
+
+
by {{ l.user.username }}
+ · {{ l.items.count() }} items · {{ l.created_at|relative }}
+ {% if l.description %}
{{ l.description[:200] }}{% if l.description|length > 200 %}…{% endif %}
{% endif %}
+
+{% else %}
+
No public lists yet.
+{% endfor %}
+
+{{ m.pagination(pag, 'lists_index') }}
+{% endblock %}
diff --git a/sites/discogs/templates/login.html b/sites/discogs/templates/login.html
new file mode 100644
index 000000000..4a4469c63
--- /dev/null
+++ b/sites/discogs/templates/login.html
@@ -0,0 +1,17 @@
+{% extends "base.html" %}
+{% block title %}Log in to Discogs{% endblock %}
+{% block content %}
+
+{% endblock %}
diff --git a/sites/discogs/templates/marketplace.html b/sites/discogs/templates/marketplace.html
new file mode 100644
index 000000000..7148303c9
--- /dev/null
+++ b/sites/discogs/templates/marketplace.html
@@ -0,0 +1,52 @@
+{% extends "base.html" %}
+{% import "_macros.html" as m %}
+{% block title %}Marketplace | Discogs{% endblock %}
+{% block content %}
+Marketplace
+{{ pag.total }} items for sale
+
+
+ Sort:
+
+ {% for v, label in [('price_asc','Price ↑'), ('price_desc','Price ↓'), ('newest','Newest listed')] %}
+ {{ label }}
+ {% endfor %}
+
+ Media:
+
+ All conditions
+ {% for g in grades %}
+ {{ g }}
+ {% endfor %}
+
+ Genre:
+
+ All genres
+ {% for g in genres %}
+ {{ g.name }}
+ {% endfor %}
+
+ {% if current_user.is_authenticated %}
Sell on Discogs {% endif %}
+
+
+
+ {% for l in pag.items %}
+
+
+
{{ l.shipping_from }}
+
${{ '%.2f'|format(l.price) }} {{ l.currency }}
+
{{ l.posted_at|relative }}
+
+
+ {% else %}
+
No active listings.
+ {% endfor %}
+
+{{ m.pagination(pag, 'marketplace', params={'sort': sort, 'media': media, 'genre': genre}) }}
+{% endblock %}
diff --git a/sites/discogs/templates/master.html b/sites/discogs/templates/master.html
new file mode 100644
index 000000000..ecaaa2142
--- /dev/null
+++ b/sites/discogs/templates/master.html
@@ -0,0 +1,28 @@
+{% 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 }})
+
+
+ Cat# Format
+ Country Year Have
+
+
+ {% for v in versions %}
+
+ {{ v.catno or '—' }}
+ {{ v.primary_format }}
+ {{ v.country }}
+ {{ v.year or '' }}
+ {{ v.have_count }}
+
+ {% endfor %}
+
+
+
+{% endblock %}
diff --git a/sites/discogs/templates/register.html b/sites/discogs/templates/register.html
new file mode 100644
index 000000000..784736584
--- /dev/null
+++ b/sites/discogs/templates/register.html
@@ -0,0 +1,20 @@
+{% extends "base.html" %}
+{% block title %}Register | Discogs{% endblock %}
+{% block content %}
+
+{% endblock %}
diff --git a/sites/discogs/templates/release.html b/sites/discogs/templates/release.html
new file mode 100644
index 000000000..0d9587adb
--- /dev/null
+++ b/sites/discogs/templates/release.html
@@ -0,0 +1,197 @@
+{% extends "base.html" %}
+{% import "_macros.html" as m %}
+{% block title %}{{ r.artist.name }} – {{ r.title }} | Discogs{% endblock %}
+
+{% block content %}
+
+
+
+ {% if r.image_path %}
Image courtesy Wikipedia
{% endif %}
+
+
+
+
+{% if r.tracks.count() %}
+
+
Tracklist
+
+ # Title Length
+
+ {% for t in r.tracks %}
+
+ {{ t.position }}
+
+ {{ t.title }}
+ {% if t.artist_credit %}{{ t.artist_credit }}
{% endif %}
+
+ {{ t.duration }}
+
+ {% endfor %}
+
+
+
+{% endif %}
+
+{% if other_versions %}
+
+
Other Versions ({{ other_versions|length }})
+
{% for v in other_versions %}{{ m.release_card(v) }}{% endfor %}
+
+{% endif %}
+
+{% if listings %}
+
+
+ {% for l in listings %}
+
+
+
{{ l.user.username }}
+
Media: {{ l.media_condition }} / Sleeve: {{ l.sleeve_condition }}
+ {% if l.comments %}
{{ l.comments }}
{% endif %}
+
+
{{ l.shipping_from }}
+
${{ '%.2f'|format(l.price) }}
+
{{ l.currency }}
+
+
+ {% endfor %}
+
+{% endif %}
+
+
+
Ratings
+ {% set max_count = (rating_hist.values()|max) if rating_hist else 1 %}
+ {% for v in [5,4,3,2,1] %}
+
+ {{ v }} ★
+
+ {{ rating_hist[v] or 0 }}
+
+ {% endfor %}
+
+
+
+
Reviews ({{ reviews|length }})
+ {% if current_user.is_authenticated %}
+
+ Write a review
+
+
+
+ Rating
+
+ {% for v in [5,4,3,2,1] %}{{ v }} ★ {% endfor %}
+
+ Review
+
+ Post 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 }}
+
+ {% else %}
+
No reviews yet — be the first to write one.
+ {% endfor %}
+
+
+{% if related %}
+
+
More by {{ r.artist.name }}
+
{% for v in related %}{{ m.release_card(v) }}{% endfor %}
+
+{% endif %}
+
+{% endblock %}
diff --git a/sites/discogs/templates/search.html b/sites/discogs/templates/search.html
new file mode 100644
index 000000000..f65d0002d
--- /dev/null
+++ b/sites/discogs/templates/search.html
@@ -0,0 +1,94 @@
+{% extends "base.html" %}
+{% import "_macros.html" as m %}
+{% block title %}Search{% if q %} — {{ q }}{% endif %} | Discogs{% endblock %}
+
+{% block content %}
+{% if q %}"{{ q }}"{% else %}Browse{% endif %}
+
+
+
+
+
+
+
Sort
+
+ {% for v, label in [('relevance','Relevance'), ('have','Most Collected'), ('want','Most Wanted'),
+ ('rating','Highest Rated'), ('year_desc','Year ↓'), ('year_asc','Year ↑'),
+ ('title','Title A–Z')] %}
+ {{ label }}
+ {% endfor %}
+
+
+
Genre
+
+ {% for g in facet_genres %}
+
+ {{ g.name }}
+
+ {% endfor %}
+
+
+
Style
+
+ {% for s in facet_styles %}
+
+ {{ s.name }}
+
+ {% endfor %}
+
+
+
Format
+
+ {% for f in facet_formats %}
+
+ {{ f.name }}
+
+ {% endfor %}
+
+
+
+
+
+ {% if type_ == 'artist' %}
+ {% 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 %}
+ {% elif type_ == 'label' %}
+ {% if labels %}
+
+ {% for l in labels %}
+
+ {% endfor %}
+
+ {% else %}
+ No labels found.
+ {% endif %}
+ {% else %}
+ {{ results.total }} releases
+
+ {% for r in results.items %}{{ m.release_card(r) }}{% endfor %}
+
+ {% if not results.items %}No releases match your search.
{% endif %}
+ {{ 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}) }}
+ {% endif %}
+
+
+{% endblock %}
diff --git a/sites/discogs/templates/sell.html b/sites/discogs/templates/sell.html
new file mode 100644
index 000000000..364c3f8e8
--- /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
+
+
+ Release ID (Discogs ID)
+
+ Find a release page and use its numeric ID.
+
+
+
+ Media condition
+
+ {% for g in grades %}{{ g }} {% endfor %}
+
+
+
+ Sleeve condition
+
+ {% for g in grades %}{{ g }} {% endfor %}
+
+
+
+
+ Price (USD)
+
+
+ Currency
+
+ USD EUR GBP JPY CAD
+
+
+ Ships from
+
+
+ Comments
+
+
+ Allow buyer offers
+
+ List for sale
+
+{% 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 %}
+
+{% 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..775be0bb6
--- /dev/null
+++ b/sites/discogs/templates/thread.html
@@ -0,0 +1,39 @@
+{% extends "base.html" %}
+{% import "_macros.html" as m %}
+{% block title %}{{ t.title }} | Discogs Forum{% endblock %}
+{% block content %}
+{{ t.title }}
+
+ in {{ t.forum.name }}
+ · started by {{ t.user.username }}
+ · {{ t.created_at|relative }}
+
+
+
+{% for p in posts %}
+
+
+ {{ m.avatar(p.user) }}
+
{{ p.user.username }}
+
posted {{ p.created_at|relative }}
+
+
{{ p.body }}
+
+{% endfor %}
+
+
+{% if current_user.is_authenticated and not t.locked %}
+
+
Reply
+
+
+
+ Post reply
+
+
+{% elif t.locked %}
+🔒 This thread is locked.
+{% else %}
+Log in to reply.
+{% 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 }}
+
+
+ Title
+
+ Body
+
+ Post thread
+
+{% endblock %}
diff --git a/sites/discogs/templates/user.html b/sites/discogs/templates/user.html
new file mode 100644
index 000000000..a47f5aaa1
--- /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
+
+
{{ u.reviews.count() }}
Reviews
+
{{ u.lists.count() }}
Lists
+
+
+
+
+
+
+
+
Recent Reviews
+ {% for rv in reviews %}
+
+
+
{{ rv.release.title }}
+
{{ rv.release.artist.name }}
+ {% 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..5cf119d90
--- /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.artist.name }}
+ {% 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..a578c9a8c
--- /dev/null
+++ b/sites/discogs/templates/wantlist.html
@@ -0,0 +1,52 @@
+{% 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
+
+
+
+
+
+
+ Release
+ Min Grade
+ Lowest Price
+ Added
+ {% if current_user.is_authenticated and current_user.id == u.id %} {% endif %}
+
+
+
+ {% for w in pag.items %}
+
+
+
+
+
+
+
+ {{ w.release.title }}
+ {{ w.release.artist.name }} · {{ w.release.year or '' }}
+
+ {{ w.min_grade }}
+ {{ w.release.lowest_price|price }}
+ {{ w.added_at|relative }}
+ {% if current_user.is_authenticated and current_user.id == u.id %}
+
+
+
+
+ Remove
+
+
+ {% endif %}
+
+ {% endfor %}
+
+
+ {% if not pag.items %}
Wantlist is empty.
{% endif %}
+ {{ m.pagination(pag, 'user_wantlist', params={'username': u.username}) }}
+
+{% endblock %}
diff --git a/websyn_start.sh b/websyn_start.sh
index 72defad8b..b892fa354 100644
--- a/websyn_start.sh
+++ b/websyn_start.sh
@@ -1,11 +1,11 @@
#!/bin/bash
-# WebSyn startup: launch all 12 mirror sites, then exec the original CMD.
+# WebSyn startup: launch all 16 mirror sites, then exec the original CMD.
# This preserves the base image's browser env server (port 8100) as PID 1.
set -e
SITES=(allrecipes amazon apple arxiv bbc_news booking github
google_flights google_map google_search huggingface wolfram_alpha
- cambridge_dictionary coursera espn)
+ cambridge_dictionary coursera espn discogs)
BASE_PORT=40000
PID_DIR=/tmp/websyn_pids
mkdir -p "$PID_DIR"
From 571f28648b3cc2d1b0bf166c0b9dc88d9da99ca0 Mon Sep 17 00:00:00 2001
From: JackJin <1037461232@qq.com>
Date: Thu, 10 Sep 2026 21:16:38 +0800
Subject: [PATCH 02/10] fix(discogs): validate state changes and protect
private lists
---
sites/discogs/app.py | 197 +++++++++++++++++---------
sites/discogs/templates/base.html | 7 +-
sites/discogs/templates/search.html | 11 +-
sites/discogs/tests/test_app.py | 205 ++++++++++++++++++++++++++++
4 files changed, 349 insertions(+), 71 deletions(-)
create mode 100644 sites/discogs/tests/test_app.py
diff --git a/sites/discogs/app.py b/sites/discogs/app.py
index 047f0ecd9..aa3e6aa33 100644
--- a/sites/discogs/app.py
+++ b/sites/discogs/app.py
@@ -13,6 +13,8 @@
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)
@@ -21,10 +23,10 @@
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
+from sqlalchemy import or_, and_, func, desc, asc, case
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
-INSTANCE_DIR = os.path.join(BASE_DIR, "instance")
+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)
@@ -396,10 +398,41 @@ def inject_globals():
@app.template_filter("price")
-def fmt_price(v):
+def fmt_price(v, currency="USD"):
if v is None:
return "—"
- return f"${v:,.2f}"
+ 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"}}
+ params.update(changes)
+ return url_for("search", **{key: value for key, value in params.items() if value is not None})
@app.template_filter("relative")
@@ -438,15 +471,14 @@ def paginate(query, page, per_page=25):
def search_releases(q, genre=None, style=None, format_=None, year=None, country=None,
sort="relevance", page=1, per_page=25):
qs = Release.query
+ relevance = None
if q:
terms = [t for t in re.split(r"\s+", q.strip()) if t]
if terms:
- anyclause = or_(*[
- Release.title.ilike(f"%{t}%") for t in terms
- ] + [
- Artist.name.ilike(f"%{t}%") for t in terms
- ])
- qs = qs.join(Artist, Release.artist_id == Artist.id).filter(anyclause)
+ matches = [or_(Release.title.ilike(f"%{t}%"), 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:
@@ -473,8 +505,10 @@ def search_releases(q, genre=None, style=None, format_=None, year=None, country=
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.distinct(), page, per_page)
+ return paginate(qs.order_by(Release.id).distinct(), page, per_page)
# ──────────────────────────────────────────────
@@ -505,7 +539,7 @@ def search():
q = request.args.get("q", "").strip()
type_ = request.args.get("type", "release")
sort = request.args.get("sort", "relevance")
- page = int(request.args.get("page", 1))
+ page = request.args.get("page", 1, type=int)
filters = {
"genre": request.args.get("genre"),
"style": request.args.get("style"),
@@ -515,16 +549,23 @@ def search():
}
results = None
artists = labels = []
- if type_ == "artist" and q:
+ 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])
- artists = Artist.query.filter(clause).order_by(Artist.in_collection.desc()).limit(50).all()
- elif type_ == "label" and q:
+ 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])
- labels = Label.query.filter(clause).order_by(Label.name.asc()).limit(50).all()
+ 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)
@@ -534,6 +575,7 @@ def search():
return render_template("search.html",
q=q, type_=type_, sort=sort,
results=results, artists=artists, labels=labels,
+ entity_pagination=entity_pagination,
filters=filters,
facet_genres=facet_genres,
facet_styles=facet_styles,
@@ -581,7 +623,7 @@ def master_detail(mid):
def artist_detail(aid, slug=None):
a = Artist.query.get_or_404(aid)
sort = request.args.get("sort", "year_desc")
- page = int(request.args.get("page", 1))
+ page = request.args.get("page", 1, type=int)
q = a.releases
if sort == "year_asc":
q = q.order_by(Release.year.asc().nullslast())
@@ -599,7 +641,7 @@ def artist_detail(aid, slug=None):
@app.route("/label//")
def label_detail(lid, slug=None):
l = Label.query.get_or_404(lid)
- page = int(request.args.get("page", 1))
+ 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)
@@ -610,7 +652,7 @@ def label_detail(lid, slug=None):
@app.route("/genre/")
def genre_detail(slug):
g = Genre.query.filter_by(slug=slug).first_or_404()
- page = int(request.args.get("page", 1))
+ 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":
@@ -629,7 +671,7 @@ def genre_detail(slug):
@app.route("/style/")
def style_detail(slug):
s = Style.query.filter_by(slug=slug).first_or_404()
- page = int(request.args.get("page", 1))
+ 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)
@@ -639,7 +681,7 @@ def style_detail(slug):
@app.route("/format/")
def format_detail(slug):
f = Format.query.filter_by(slug=slug).first_or_404()
- page = int(request.args.get("page", 1))
+ 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)
@@ -664,7 +706,7 @@ def explore():
@app.route("/lists")
def lists_index():
- page = int(request.args.get("page", 1))
+ page = request.args.get("page", 1, type=int)
q = List.query.filter_by(is_public=True).order_by(List.created_at.desc())
pag = paginate(q, page, 20)
return render_template("lists.html", pag=pag)
@@ -719,7 +761,7 @@ def list_add_item(lid):
@app.route("/marketplace")
def marketplace():
- page = int(request.args.get("page", 1))
+ 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", "")
@@ -756,21 +798,32 @@ def sell():
flash("Pick a valid release.", "error")
return redirect(url_for("sell"))
try:
- price = float(request.form.get("price"))
- except (TypeError, ValueError):
- flash("Price must be a number.", "error")
+ 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 {"USD", "EUR", "GBP", "JPY", "CAD"}:
+ 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=request.form.get("media_condition", "Very Good Plus (VG+)"),
- sleeve_condition=request.form.get("sleeve_condition", "Very Good Plus (VG+)"),
+ media_condition=media, sleeve_condition=sleeve,
comments=request.form.get("comments", "")[:600],
- price=price,
- currency=request.form.get("currency", "USD"),
- shipping_from=request.form.get("shipping_from", "United States"),
+ 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.commit()
+ 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,
@@ -800,7 +853,7 @@ def user_profile(username):
def user_collection(username):
u = User.query.filter_by(username=username).first_or_404()
folder = request.args.get("folder", "All")
- page = int(request.args.get("page", 1))
+ page = request.args.get("page", 1, type=int)
q = u.collection_items.join(Release)
if folder != "All":
q = q.filter(CollectionItem.folder == folder)
@@ -813,7 +866,7 @@ def user_collection(username):
@app.route("/user//wantlist")
def user_wantlist(username):
u = User.query.filter_by(username=username).first_or_404()
- page = int(request.args.get("page", 1))
+ 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)
@@ -822,7 +875,10 @@ def user_wantlist(username):
@app.route("/user//lists")
def user_lists(username):
u = User.query.filter_by(username=username).first_or_404()
- lists = u.lists.order_by(List.created_at.desc()).all()
+ 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)
@@ -845,21 +901,25 @@ def collection_add():
rid = request.form.get("release_id", type=int)
r = Release.query.get(rid) if rid else None
if not r:
- return redirect(request.referrer or url_for("index"))
+ 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=request.form.get("folder", "Uncategorized"),
- media_condition=request.form.get("media_condition", "Near Mint (NM or M-)"),
- sleeve_condition=request.form.get("sleeve_condition", "Near Mint (NM or M-)"),
+ 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 redirect(request.referrer or url_for("release_detail", rid=r.discogs_id or r.id))
+ return return_to_page(url_for("release_detail", rid=r.discogs_id or r.id))
@app.route("/collection/remove", methods=["POST"])
@@ -874,7 +934,7 @@ def collection_remove():
db.session.delete(c)
db.session.commit()
flash("Removed from collection.", "success")
- return redirect(request.referrer or url_for("user_collection", username=current_user.username))
+ return return_to_page(url_for("user_collection", username=current_user.username))
@app.route("/wantlist/add", methods=["POST"])
@@ -883,18 +943,22 @@ def wantlist_add():
rid = request.form.get("release_id", type=int)
r = Release.query.get(rid) if rid else None
if not r:
- return redirect(request.referrer or url_for("index"))
+ 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=request.form.get("min_grade", "Very Good Plus (VG+)"),
+ 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 redirect(request.referrer or url_for("release_detail", rid=r.discogs_id or r.id))
+ return return_to_page(url_for("release_detail", rid=r.discogs_id or r.id))
@app.route("/wantlist/remove", methods=["POST"])
@@ -909,7 +973,7 @@ def wantlist_remove():
db.session.delete(w)
db.session.commit()
flash("Removed from wantlist.", "success")
- return redirect(request.referrer or url_for("user_wantlist", username=current_user.username))
+ return return_to_page(url_for("user_wantlist", username=current_user.username))
@app.route("/rate", methods=["POST"])
@@ -919,7 +983,7 @@ def rate():
val = request.form.get("rating", type=int)
if not (rid and val and 1 <= val <= 5):
flash("Bad rating.", "error")
- return redirect(request.referrer or url_for("index"))
+ 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:
@@ -932,7 +996,7 @@ def rate():
r.avg_rating = float(agg[0] or 0.0)
r.rating_count = int(agg[1] or 0)
db.session.commit()
- return redirect(request.referrer or url_for("release_detail", rid=r.discogs_id or r.id))
+ return return_to_page(url_for("release_detail", rid=r.discogs_id or r.id))
@app.route("/review", methods=["POST"])
@@ -943,13 +1007,13 @@ def review_post():
rating = request.form.get("rating", type=int)
if not rid or not body:
flash("Review body required.", "error")
- return redirect(request.referrer or url_for("index"))
- rv = Review(user_id=current_user.id, release_id=rid, body=body[:4000],
+ 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")
- r = Release.query.get(rid)
return redirect(url_for("release_detail", rid=r.discogs_id or r.id))
@@ -966,7 +1030,7 @@ def forum_index():
@app.route("/forum/")
def forum_view(slug):
f = Forum.query.filter_by(slug=slug).first_or_404()
- page = int(request.args.get("page", 1))
+ 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)
@@ -1029,7 +1093,7 @@ def login():
if u 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(request.args.get("next") or url_for("index"))
+ return redirect(local_return(request.args.get("next"), url_for("index")))
flash("Invalid credentials.", "error")
return render_template("login.html")
@@ -1042,8 +1106,10 @@ def register():
username = request.form.get("username", "").strip()
email = request.form.get("email", "").strip().lower()
pw = request.form.get("password", "")
- if not (3 <= len(username) <= 40 and "@" in email and len(pw) >= 6):
- flash("Username 3-40 chars; valid email; password ≥6.", "error")
+ 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"))
@@ -1060,7 +1126,7 @@ def register():
return render_template("register.html")
-@app.route("/logout", methods=["POST", "GET"])
+@app.route("/logout", methods=["POST"])
def logout():
logout_user()
flash("You have been signed out.", "info")
@@ -1107,16 +1173,17 @@ def forbidden(e):
with app.app_context():
db.create_all()
- 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 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__":
diff --git a/sites/discogs/templates/base.html b/sites/discogs/templates/base.html
index 58688b316..d162405a5 100644
--- a/sites/discogs/templates/base.html
+++ b/sites/discogs/templates/base.html
@@ -32,9 +32,12 @@
{{ current_user.username }}
Settings
- Log out
+
+
+ Log out
+
{% else %}
- Log in
+ Log in
Register
{% endif %}
diff --git a/sites/discogs/templates/search.html b/sites/discogs/templates/search.html
index f65d0002d..a94cc7fe3 100644
--- a/sites/discogs/templates/search.html
+++ b/sites/discogs/templates/search.html
@@ -19,7 +19,7 @@ Sort
{% for v, label in [('relevance','Relevance'), ('have','Most Collected'), ('want','Most Wanted'),
('rating','Highest Rated'), ('year_desc','Year ↓'), ('year_asc','Year ↑'),
('title','Title A–Z')] %}
- {{ label }}
+ {{ label }}
{% endfor %}
@@ -27,7 +27,7 @@ Genre
@@ -36,7 +36,7 @@ Style
@@ -45,7 +45,7 @@ Format
@@ -89,6 +89,9 @@ Format
'genre': filters.genre, 'style': filters.style,
'format': filters.format_, 'year': filters.year, 'country': filters.country}) }}
{% endif %}
+ {% if entity_pagination %}
+ {{ m.pagination(entity_pagination, 'search', params={'q': q, 'type': type_}) }}
+ {% endif %}
{% endblock %}
diff --git a/sites/discogs/tests/test_app.py b/sites/discogs/tests/test_app.py
new file mode 100644
index 000000000..0660f52fe
--- /dev/null
+++ b/sites/discogs/tests/test_app.py
@@ -0,0 +1,205 @@
+"""Behavioral regressions; use an isolated database and synthetic fixtures."""
+import importlib
+import os
+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.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_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()
+ self.assertEqual(self.listing(currency="GBP").status_code, 302)
+ 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_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_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_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_facets_preserve_other_filters(self):
+ html = self.client.get("/search?q=Bob&genre=reggae&year=1978").get_data(as_text=True)
+ 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)
+
+
+if __name__ == "__main__":
+ unittest.main()
From e45f959369489c75a9386f862b21da4df094a79d Mon Sep 17 00:00:00 2001
From: JackJin <1037461232@qq.com>
Date: Thu, 10 Sep 2026 21:46:31 +0800
Subject: [PATCH 03/10] fix(discogs): repair narrow layouts and align runtime
dependencies
---
sites/discogs/app.py | 2 +-
sites/discogs/requirements.txt | 6 +--
sites/discogs/static/css/site.css | 69 +++++++++++++++++++------
sites/discogs/static/icons/discogs.svg | 26 ++++++++++
sites/discogs/templates/base.html | 8 +--
sites/discogs/templates/collection.html | 2 +
sites/discogs/templates/index.html | 6 ++-
sites/discogs/templates/master.html | 2 +
sites/discogs/templates/release.html | 2 +
sites/discogs/templates/sell.html | 2 +-
sites/discogs/templates/wantlist.html | 2 +
sites/discogs/tests/test_app.py | 6 +++
12 files changed, 105 insertions(+), 28 deletions(-)
create mode 100644 sites/discogs/static/icons/discogs.svg
diff --git a/sites/discogs/app.py b/sites/discogs/app.py
index aa3e6aa33..c280adc9f 100644
--- a/sites/discogs/app.py
+++ b/sites/discogs/app.py
@@ -1090,7 +1090,7 @@ def login():
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 bcrypt.check_password_hash(u.password_hash, pw):
+ 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")))
diff --git a/sites/discogs/requirements.txt b/sites/discogs/requirements.txt
index c3bbdb803..07cdb42c6 100644
--- a/sites/discogs/requirements.txt
+++ b/sites/discogs/requirements.txt
@@ -1,9 +1,9 @@
Flask==3.1.0
Flask-SQLAlchemy==3.1.1
Flask-Login==0.6.3
-Flask-WTF==1.2.1
+Flask-WTF==1.2.2
Flask-Bcrypt==1.0.1
-WTForms==3.1.2
+WTForms==3.2.1
SQLAlchemy==2.0.36
Werkzeug==3.1.3
-bcrypt==4.2.1
+bcrypt==5.0.0
diff --git a/sites/discogs/static/css/site.css b/sites/discogs/static/css/site.css
index e746b32ec..866a89ad9 100644
--- a/sites/discogs/static/css/site.css
+++ b/sites/discogs/static/css/site.css
@@ -1,14 +1,14 @@
/* Discogs mirror — global stylesheet */
* { box-sizing: border-box; }
-html, body { margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI",
- Roboto, Helvetica, Arial, sans-serif;
- color: #1a1a1a; background: #f4f4f1; line-height: 1.42; }
+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; }
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; }
@@ -24,24 +24,27 @@ textarea { min-height: 110px; resize: vertical; }
label { display: block; font-weight: 600; margin: 12px 0 4px; font-size: 0.92em; color: #444; }
/* Header */
-.site-header { background: #2c2c2c; color: #fff; }
-.site-header .top { display: flex; align-items: center; padding: 10px 20px; gap: 20px; }
+.site-header { background: #111; color: #fff; }
+.site-header .top { display: flex; align-items: center; padding: 6px 24px; gap: 30px; }
.site-header .logo { font-size: 1.7rem; font-weight: 900; color: #fff;
letter-spacing: -0.02em; font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; }
.site-header .logo:hover { text-decoration: none; color: #ff8c00; }
+.site-header .logo { flex: 0 0 128px; }
.site-header .nav { display: flex; gap: 18px; flex-wrap: wrap; font-size: 0.95em; }
.site-header .nav a { color: #ddd; }
.site-header .nav a:hover { color: #fff; text-decoration: none; }
-.site-header .search { flex: 1; max-width: 620px; display: flex; }
-.site-header .search input { background: #fff; color: #000; border-radius: 3px 0 0 3px; }
+.site-header .search { flex: 1; min-width: 0; max-width: 800px; display: flex; }
+.site-header .search input { min-width: 0; height: 40px; background: #fff; color: #000; border-radius: 20px 0 0 20px; font-size: 16px; }
.site-header .search select { width: auto; border-radius: 0; border-left: 0; border-right: 0; max-width: 120px; }
-.site-header .search button { border-radius: 0 3px 3px 0; background: #ff8c00; border-color: #cc6f00;
- color: #fff; padding: 7px 16px; }
-.site-header .user-area { display: flex; gap: 12px; align-items: center; }
+.site-header .search button { border-radius: 0 20px 20px 0; background: #fff; border-color: #c4c4c4;
+ color: #111; padding: 7px 16px; }
+.site-header .user-area { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; justify-content: flex-end; min-width: 0; margin-left: auto; }
.site-header .user-area a { color: #ddd; }
+.site-header .user-area .btn { background: transparent; color: #fff; }
+.site-header .user-area button { border: 0; padding: 0; background: transparent; color: #ddd; }
.site-header .user-area .avatar { width: 30px; height: 30px; border-radius: 50%;
display: inline-block; vertical-align: middle; }
-.site-header .subnav { background: #1f1f1f; padding: 7px 20px;
+.site-header .subnav { background: #111; border-top: 1px solid #333; padding: 14px 24px;
display: flex; gap: 22px; font-size: 0.88em; flex-wrap: wrap; }
.site-header .subnav a { color: #aaa; }
.site-header .subnav a:hover, .site-header .subnav a.active { color: #fff; text-decoration: none; }
@@ -52,6 +55,10 @@ label { display: block; font-weight: 600; margin: 12px 0 4px; font-size: 0.92em;
.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; }
@@ -71,7 +78,7 @@ h3 { font-size: 1.1em; margin: 0 0 8px; }
.flashes .info { background: #e3eff7; border: 1px solid #87b8d7; color: #1f4d72; }
/* Release grid */
-.release-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
+.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; }
@@ -88,10 +95,10 @@ h3 { font-size: 1.1em; margin: 0 0 8px; }
/* Release detail */
.rel-hero { display: flex; gap: 28px; flex-wrap: wrap; }
-.rel-hero .cover { flex: 0 0 320px; background: #fff; border: 1px solid #e0e0e0;
+.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; min-width: 0; }
+.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; }
@@ -101,10 +108,10 @@ h3 { font-size: 1.1em; margin: 0 0 8px; }
.badge-style { background: #ece6f7; color: #4f2cab; }
.badge-format { background: #f7e6e6; color: #871c1c; }
-.kv { display: grid; grid-template-columns: 160px 1fr; row-gap: 6px; column-gap: 12px;
+.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; }
+.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; }
@@ -157,7 +164,7 @@ h3 { font-size: 1.1em; margin: 0 0 8px; }
.avatar-md { width: 56px; height: 56px; font-size: 1.4em; }
/* Pagination */
-.pagination { display: flex; gap: 6px; justify-content: center; margin: 22px 0; }
+.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; }
@@ -184,10 +191,12 @@ h3 { font-size: 1.1em; margin: 0 0 8px; }
letter-spacing: 0.07em; }
.site-footer ul { list-style: none; padding: 0; margin: 0; }
.site-footer li { padding: 2px 0; }
+.site-footer .footer-meta { flex: 1 1 240px; text-align: right; }
/* 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; }
@@ -203,3 +212,29 @@ h3 { font-size: 1.1em; margin: 0 0 8px; }
.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: 900px) {
+ .site-header .top { flex-wrap: wrap; gap: 12px; }
+ .site-header .search { order: 3; flex-basis: 100%; max-width: none; }
+ .site-header .user-area { flex: 1 1 200px; }
+ .col-side { flex: 1 1 240px; }
+}
+
+@media (max-width: 600px) {
+ .page, .page-narrow { padding: 16px 12px; }
+ .site-header .top { padding: 8px 12px; }
+ .site-header .subnav { padding: 10px 12px; gap: 12px 18px; }
+ .site-header .search select { max-width: 82px; }
+ .site-header .search button { padding: 7px 10px; }
+ .site-header .user-area { gap: 8px 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; }
+ .site-footer .inner > div { flex: 1 1 160px; min-width: 0; }
+ .site-footer .inner > .footer-meta { flex-basis: 100%; text-align: left; }
+}
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/templates/base.html b/sites/discogs/templates/base.html
index d162405a5..f71933ab1 100644
--- a/sites/discogs/templates/base.html
+++ b/sites/discogs/templates/base.html
@@ -10,11 +10,11 @@
- Join free
- Explore the database
+
diff --git a/sites/discogs/templates/master.html b/sites/discogs/templates/master.html
index ecaaa2142..52b912264 100644
--- a/sites/discogs/templates/master.html
+++ b/sites/discogs/templates/master.html
@@ -7,6 +7,7 @@
{{ m.title }} (master release)
· first released {{ m.year or '' }}
Versions ({{ versions|length }})
+
{% endblock %}
diff --git a/sites/discogs/templates/release.html b/sites/discogs/templates/release.html
index 0d9587adb..e5ebbab3b 100644
--- a/sites/discogs/templates/release.html
+++ b/sites/discogs/templates/release.html
@@ -93,6 +93,7 @@
{{ r.title }}
{% if r.tracks.count() %}
{% endif %}
diff --git a/sites/discogs/templates/sell.html b/sites/discogs/templates/sell.html
index 364c3f8e8..4d1dc9422 100644
--- a/sites/discogs/templates/sell.html
+++ b/sites/discogs/templates/sell.html
@@ -8,7 +8,7 @@
List an item for sale
Find a release page and use its numeric ID.
-