diff --git a/Dockerfile b/Dockerfile index 991e5ab6..1e86b1d0 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 c255253c..9d504ebb 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 00000000..b206e9e7 --- /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 00000000..047f0ecd --- /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 00000000..c3bbdb80 --- /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 00000000..e076ace7 --- /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 00000000..e69de29b diff --git a/sites/discogs/static/css/site.css b/sites/discogs/static/css/site.css new file mode 100644 index 00000000..e746b32e --- /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 00000000..e69de29b diff --git a/sites/discogs/static/icons/no-cover.svg b/sites/discogs/static/icons/no-cover.svg new file mode 100644 index 00000000..da8da329 --- /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 00000000..e69de29b diff --git a/sites/discogs/tasks.jsonl b/sites/discogs/tasks.jsonl new file mode 100644 index 00000000..29bc926a --- /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 00000000..e69de29b diff --git a/sites/discogs/templates/403.html b/sites/discogs/templates/403.html new file mode 100644 index 00000000..58b942c2 --- /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 00000000..1b44f9a6 --- /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 00000000..73418a0c --- /dev/null +++ b/sites/discogs/templates/_macros.html @@ -0,0 +1,42 @@ +{% macro release_card(r) %} + + {{ r.title }} +
{{ 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 00000000..1064cc80 --- /dev/null +++ b/sites/discogs/templates/artist.html @@ -0,0 +1,62 @@ +{% extends "base.html" %} +{% import "_macros.html" as m %} +{% block title %}{{ a.name }} | Discogs{% endblock %} + +{% block content %} +
+ {% if a.image_path %} + {{ a.name }} + {% else %} +
+ {{ a.name[0]|upper }} +
+ {% endif %} +
+

{{ a.name }}

+ {% if a.real_name %}

Real Name: {{ a.real_name }}

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

{{ a.profile }}

{% endif %} +

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

+
+
+ +
+
+
+
+

Releases

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

No releases.

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

Members

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

Links

+
    + {% for s in a.sites.split('\n') if s %}
  • {{ s }}
  • {% endfor %} +
+
+ {% endif %} +
+
+{% endblock %} diff --git a/sites/discogs/templates/base.html b/sites/discogs/templates/base.html new file mode 100644 index 00000000..58688b31 --- /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 00000000..868d2e72 --- /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 %} +
+ +
+ + + + + + + + + + {% if current_user.is_authenticated and current_user.id == u.id %}{% endif %} + + + + {% for c in pag.items %} + + + + + + + + {% if current_user.is_authenticated and current_user.id == u.id %} + + {% endif %} + + {% endfor %} + +
ReleaseLabelFormatConditionAdded
+ + + + + {{ c.release.title }} +
{{ c.release.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 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 00000000..7c529ad8 --- /dev/null +++ b/sites/discogs/templates/explore.html @@ -0,0 +1,33 @@ +{% extends "base.html" %} +{% block title %}Explore | Discogs{% endblock %} +{% block content %} +

Explore the Database

+ +
+

Genres

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

Formats

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

Decades

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

Countries

+
+ {% for c in countries %}{{ c }}{% endfor %} +
+
+{% endblock %} diff --git a/sites/discogs/templates/format.html b/sites/discogs/templates/format.html new file mode 100644 index 00000000..54aad3ed --- /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 00000000..b2827b8d --- /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 00000000..b5dd04f6 --- /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.name }}

+

{{ 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 00000000..b8d910b0 --- /dev/null +++ b/sites/discogs/templates/genre.html @@ -0,0 +1,30 @@ +{% extends "base.html" %} +{% import "_macros.html" as m %} +{% block title %}{{ g.name }} | Discogs Genre{% endblock %} + +{% block content %} +

{{ g.name }}

+

{{ pag.total }} releases in this genre

+ +{% if styles %} +
+

Styles

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

Releases

+ +
+
{% for r in pag.items %}{{ m.release_card(r) }}{% endfor %}
+ {{ m.pagination(pag, 'genre_detail', params={'slug': g.slug, 'sort': sort}) }} +
+{% endblock %} diff --git a/sites/discogs/templates/index.html b/sites/discogs/templates/index.html new file mode 100644 index 00000000..2c7d8366 --- /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.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 +
+ +
+
+

New Arrivals

+ View all → +
+
+ {% for r in new_arrivals %}{{ release_card(r) }}{% endfor %} +
+
+ +
+
+

Most Collected

+ View more → +
+
+ {% for r in most_collected %}{{ release_card(r) }}{% endfor %} +
+
+ +
+
+
+
+

Top Rated

+ View more → +
+
+ {% for r in top_rated %}{{ release_card(r) }}{% endfor %} +
+
+
+
+

Most Wanted

+ View more → +
+
+ {% 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 %} +
+ {{ f.name }} +
{{ f.description }}
+
+ {% endfor %} +
+
+
+{% endblock %} diff --git a/sites/discogs/templates/label.html b/sites/discogs/templates/label.html new file mode 100644 index 00000000..be529e35 --- /dev/null +++ b/sites/discogs/templates/label.html @@ -0,0 +1,39 @@ +{% extends "base.html" %} +{% import "_macros.html" as m %} +{% block title %}{{ l.name }} | Discogs Labels{% endblock %} + +{% block content %} +
+
+ {{ l.name[0]|upper }} +
+
+

{{ l.name }}

+ {% if l.parent_label %} +

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

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

{{ l.profile }}

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

{{ l.contact_info }}

{% endif %} +

{{ pag.total }} releases

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

Sublabels

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

Releases on {{ l.name }}

+
+ {% for r in pag.items %}{{ m.release_card(r) }}{% endfor %} +
+ {{ m.pagination(pag, 'label_detail', params={'lid': l.id, 'slug': l.slug}) }} +
+{% endblock %} diff --git a/sites/discogs/templates/list.html b/sites/discogs/templates/list.html new file mode 100644 index 00000000..66543294 --- /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 %} + +
+
    + {% for it in lst.items %} +
  1. + {% if it.release %} + {{ it.release.artist.name }} – {{ it.release.title }} +
    {{ it.release.year or '' }} {{ it.release.primary_format }}
    + {% elif it.artist %} + {{ it.artist.name }} + {% endif %} + {% if it.comment %}
    {{ it.comment }}
    {% endif %} +
  2. + {% else %} +

    No items yet.

    + {% endfor %} +
+
+{% endblock %} diff --git a/sites/discogs/templates/list_new.html b/sites/discogs/templates/list_new.html new file mode 100644 index 00000000..91290e29 --- /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 00000000..1c21eb51 --- /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 %} +
+

{{ l.title }}

+

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 00000000..4a4469c6 --- /dev/null +++ b/sites/discogs/templates/login.html @@ -0,0 +1,17 @@ +{% extends "base.html" %} +{% block title %}Log in to Discogs{% endblock %} +{% block content %} +
+

Log in

+
+ + + + + + +

+

No account? Create one.

+
+
+{% endblock %} diff --git a/sites/discogs/templates/marketplace.html b/sites/discogs/templates/marketplace.html new file mode 100644 index 00000000..7148303c --- /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: + + Media: + + Genre: + + {% if current_user.is_authenticated %}Sell on Discogs{% endif %} +
+ +
+ {% for l in pag.items %} +
+
+ + {{ l.release.artist.name }} – {{ l.release.title }} + +
{{ l.release.year or '' }} {{ l.release.primary_format }} · sold by {{ l.user.username }}
+
Media: {{ l.media_condition }} · Sleeve: {{ l.sleeve_condition }}
+
+
{{ 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 00000000..ecaaa214 --- /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 }})

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

Create your account

+
+ + + + + + + + + +

+

Already have an account? Log in.

+
+
+{% endblock %} diff --git a/sites/discogs/templates/release.html b/sites/discogs/templates/release.html new file mode 100644 index 00000000..0d9587ad --- /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 %} +
+
+ {{ r.title }} + {% if r.image_path %}

Image courtesy Wikipedia

{% endif %} +
+
+

{{ r.title }}

+ + +
+ {% for g in r.genres %}{{ g.name }}{% endfor %} + {% for s in r.styles %}{{ s.name }}{% endfor %} + {% for f in r.formats %}{{ f.name }}{% endfor %} +
+ +
+ {% if r.labels %}
Label
+ {% for l in r.labels %}{{ l.name }}{% if not loop.last %} · {% endif %}{% endfor %} + {% if r.catno %} – {{ r.catno }}{% endif %} +
{% endif %} + {% if r.formats %}
Format
{% for f in r.formats %}{{ f.name }}{% if not loop.last %}, {% endif %}{% endfor %}
{% endif %} + {% if r.country %}
Country
{{ r.country }}
{% endif %} + {% if r.released %}
Released
{{ r.released }}
+ {% elif r.year %}
Year
{{ r.year }}
{% endif %} + {% if r.genres %}
Genre
{{ r.genre_str }}
{% endif %} + {% if r.styles %}
Style
{{ r.style_str }}
{% endif %} + {% if r.barcode %}
Barcode
{{ r.barcode }}
{% endif %} +
Have
{{ r.have_count }}
+
Want
{{ r.want_count }}
+
Avg Rating
{{ '%.2f'|format(r.avg_rating) }} / 5 ({{ r.rating_count }} ratings)
+
Submissions
{{ r.data_quality }}
+
+ +
+ {% if current_user.is_authenticated %} +
+ + + + + +
+
+ + + + +
+
+ + + + +
+ {% else %} + Sign in to collect + {% endif %} +
+ + {% if r.lowest_price %} +
+
${{ '%.2f'|format(r.lowest_price) }}
+
{{ r.num_for_sale }} for sale starting from
+
+ {% endif %} +
+
+ +{% if r.tracks.count() %} +
+

Tracklist

+ + + + {% for t in r.tracks %} + + + + + + {% endfor %} + +
#TitleLength
{{ t.position }} + {{ t.title }} + {% if t.artist_credit %}
{{ t.artist_credit }}
{% endif %} +
{{ t.duration }}
+
+{% endif %} + +{% if other_versions %} +
+

Other Versions ({{ other_versions|length }})

+
{% for v in other_versions %}{{ m.release_card(v) }}{% endfor %}
+
+{% endif %} + +{% if listings %} +
+
+

Marketplace — {{ listings|length }} for sale

+ All 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 +
+ + + + + + + +
+
+ {% 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 00000000..f65d0002 --- /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 %}

+ + + +
+ + +
+ {% 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 00000000..364c3f8e --- /dev/null +++ b/sites/discogs/templates/sell.html @@ -0,0 +1,44 @@ +{% extends "base.html" %} +{% block title %}Sell on Discogs{% endblock %} +{% block content %} +

List an item for sale

+
+ + + +

Find a release page and use its numeric ID.

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

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

Profile Settings

+
+ + + + + + + +

+
+
+{% endblock %} diff --git a/sites/discogs/templates/style.html b/sites/discogs/templates/style.html new file mode 100644 index 00000000..9f006f0c --- /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 00000000..775be0bb --- /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

+
+ + +

+
+
+{% 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 00000000..a0366a56 --- /dev/null +++ b/sites/discogs/templates/thread_new.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} +{% block title %}New Thread – {{ f.name }} | Discogs Forum{% endblock %} +{% block content %} +

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

+
+ + + + + +

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

{{ u.username }}

+ {% if u.real_name %}

{{ u.real_name }}

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

📍 {{ u.location }}

{% endif %} +

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

+ {% if u.bio %}

{{ u.bio }}

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

Recent Reviews

+ {% for rv in reviews %} +
+
+ {{ rv.release.title }} + {{ rv.release.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 00000000..285db649 --- /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 00000000..daae9b33 --- /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 00000000..5cf119d9 --- /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 00000000..a578c9a8 --- /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

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

Wantlist is empty.

{% endif %} + {{ m.pagination(pag, 'user_wantlist', params={'username': u.username}) }} +
+{% endblock %} diff --git a/websyn_start.sh b/websyn_start.sh index 72defad8..b892fa35 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"