Group genre dropdowns into non-selectable optgroup sections

- Load grouped genre list (music_genres_grouped.txt) with section headings
- Render headings as <optgroup> labels (visible, not selectable)
- Add Disney under CHILDREN'S & HOLIDAY (was missing from grouped file)
- Bump version to 0.9.3
This commit is contained in:
Troll (Hermes Agent) 2026-09-20 16:11:42 +00:00
parent 07e92a522a
commit 868b59077f
7 changed files with 360 additions and 21 deletions

View file

@ -1,6 +1,6 @@
# Theme Song Booth (MusicGPT Edition) # Theme Song Booth (MusicGPT Edition)
![Version](https://img.shields.io/badge/version-v0.9.2-blue) ![Version](https://img.shields.io/badge/version-v0.9.3-blue)
A Flask web application for running a convention booth where visitors request a custom AI-generated theme song. Operators manage the queue from an admin dashboard, generate prompts via Hermes, queue generations through the MusicGPT API, and deliver final songs by email. A Flask web application for running a convention booth where visitors request a custom AI-generated theme song. Operators manage the queue from an admin dashboard, generate prompts via Hermes, queue generations through the MusicGPT API, and deliver final songs by email.
@ -42,7 +42,7 @@ The MusicGPT fork automates song generation: instead of manually creating songs
| Page | Path | Purpose | | Page | Path | Purpose |
|------|------|---------| |------|------|---------|
| Request form | `/request` | Visitors enter email, name, pronouns, hobbies, notable facts, and extra requests. The music style is picked from three dropdowns: **Decade** (required), **Basic style** (required), and **Additional style** (optional). Decades and genres are loaded from `/mnt/Storage/Decades.txt` and `/mnt/Storage/Music Genres.txt`. Rate limited to 5 submissions per minute per IP. | | Request form | `/request` | Visitors enter email, name, pronouns, hobbies, notable facts, and extra requests. The music style is picked from three dropdowns: **Decade** (required), **Basic style** (required), and **Additional style** (optional). Decades and genres are loaded from `/mnt/Storage/Decades.txt` and `/mnt/Storage/music_genres_grouped.txt` (a grouped file where section headings are rendered as non-selectable dropdown groups). Rate limited to 5 submissions per minute per IP. |
| Closed page | `/request` (when booth is closed) | Shows a friendly closed banner instead of the form when the operator marks the booth closed. | | Closed page | `/request` (when booth is closed) | Shows a friendly closed banner instead of the form when the operator marks the booth closed. |
| Thanks | `/thanks/<id>` | Confirmation page shown after a request is submitted. | | Thanks | `/thanks/<id>` | Confirmation page shown after a request is submitted. |
| Order status | `/status` | Customers enter their email to see all their requests and statuses. | | Order status | `/status` | Customers enter their email to see all their requests and statuses. |

View file

@ -1 +1 @@
0.9.2 0.9.3

14
app.py
View file

@ -60,7 +60,7 @@ from models import SCHEMA, get_requests_by_email, log_revision, list_revision_hi
# Helpers (extracted from app.py to keep the route file manageable) # Helpers (extracted from app.py to keep the route file manageable)
from helpers import ( from helpers import (
MUSIC_GENRES, DECADES, parse_style_genre, build_style_genre, GENRE_GROUPS, DECADES, parse_style_genre, build_style_genre,
is_admin, require_admin, admin_password_ok, is_valid_email, is_admin, require_admin, admin_password_ok, is_valid_email,
allowed_file, upload_path, save_upload, allowed_file, upload_path, save_upload,
apply_mp3_tags, apply_mp3_tags,
@ -186,13 +186,13 @@ def request_form():
pronouns = request.form.get('pronouns', '').strip() pronouns = request.form.get('pronouns', '').strip()
if not pronouns: if not pronouns:
flash('Please select your pronouns.', 'error') flash('Please select your pronouns.', 'error')
return render_template('request.html', form=request.form, decades=DECADES, genres=MUSIC_GENRES), 400 return render_template('request.html', form=request.form, decades=DECADES, genre_groups=GENRE_GROUPS), 400
if not decade: if not decade:
flash('Please select a decade.', 'error') flash('Please select a decade.', 'error')
return render_template('request.html', form=request.form, decades=DECADES, genres=MUSIC_GENRES), 400 return render_template('request.html', form=request.form, decades=DECADES, genre_groups=GENRE_GROUPS), 400
if not basic_style: if not basic_style:
flash('Please select a basic style.', 'error') flash('Please select a basic style.', 'error')
return render_template('request.html', form=request.form, decades=DECADES, genres=MUSIC_GENRES), 400 return render_template('request.html', form=request.form, decades=DECADES, genre_groups=GENRE_GROUPS), 400
form_data = { form_data = {
'name': request.form.get('name', '').strip(), 'name': request.form.get('name', '').strip(),
'email': request.form.get('email', '').strip().lower(), 'email': request.form.get('email', '').strip().lower(),
@ -206,7 +206,7 @@ def request_form():
} }
if not is_valid_email(form_data['email']): if not is_valid_email(form_data['email']):
flash('Please enter a valid email address.', 'error') flash('Please enter a valid email address.', 'error')
return render_template('request.html', form=form_data, decades=DECADES, genres=MUSIC_GENRES), 400 return render_template('request.html', form=form_data, decades=DECADES, genre_groups=GENRE_GROUPS), 400
rid = create_request(**form_data) rid = create_request(**form_data)
# Notify operator via ntfy when a new request comes in. # Notify operator via ntfy when a new request comes in.
@ -251,7 +251,7 @@ def request_form():
flash('Your request has been submitted! Check your email soon.', 'success') flash('Your request has been submitted! Check your email soon.', 'success')
return redirect(url_for('thanks', rid=rid)) return redirect(url_for('thanks', rid=rid))
return render_template('request.html', form=None, decades=DECADES, genres=MUSIC_GENRES) return render_template('request.html', form=None, decades=DECADES, genre_groups=GENRE_GROUPS)
@app.route('/api/ping', methods=['GET']) @app.route('/api/ping', methods=['GET'])
@ -1521,7 +1521,7 @@ def admin_request(rid):
req=req, req=req,
style_parts=parse_style_genre(req.get('style_genre')), style_parts=parse_style_genre(req.get('style_genre')),
decades=DECADES, decades=DECADES,
genres=MUSIC_GENRES, genre_groups=GENRE_GROUPS,
statuses=STATUS_LABELS, statuses=STATUS_LABELS,
file_exists=file_exists, file_exists=file_exists,
basename=basename, basename=basename,

View file

@ -50,9 +50,9 @@ from config import Config
# Genre / decade helpers # Genre / decade helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_GENRES_PATH = Path('/mnt/Storage/Music Genres.txt') _GENRES_PATH = Path('/mnt/Storage/music_genres_grouped.txt')
_DECADES_PATH = Path('/mnt/Storage/Decades.txt') _DECADES_PATH = Path('/mnt/Storage/Decades.txt')
_FALLBACK_GENRES_PATH = Path(__file__).parent / 'lists' / 'music_genres.txt' _FALLBACK_GENRES_PATH = Path(__file__).parent / 'lists' / 'music_genres_grouped.txt'
_FALLBACK_DECADES_PATH = Path(__file__).parent / 'lists' / 'decades.txt' _FALLBACK_DECADES_PATH = Path(__file__).parent / 'lists' / 'decades.txt'
@ -72,9 +72,38 @@ def _load_list(primary: Path, fallback: Path) -> list[str]:
return _load_lines(fallback) return _load_lines(fallback)
MUSIC_GENRES = _load_list(_GENRES_PATH, _FALLBACK_GENRES_PATH) def _load_genre_groups(primary: Path, fallback: Path) -> list[dict]:
"""Parse a grouped genre file into [{'label': str, 'genres': [str, ...]}, ...].
A blank line ends the current group; the first line after a blank line (or
the very first line) is the group heading, and every following line until
the next blank line is a genre under that heading. Headings are rendered as
non-selectable <optgroup> labels, so they are visible but not clickable.
"""
path = primary if primary.exists() else fallback
if not path.exists():
return []
groups = []
current = None
for raw in path.read_text(encoding='utf-8').splitlines():
line = raw.strip()
if not line:
current = None
continue
if current is None:
current = {'label': line, 'genres': []}
groups.append(current)
else:
current['genres'].append(line)
return groups
GENRE_GROUPS = _load_genre_groups(_GENRES_PATH, _FALLBACK_GENRES_PATH)
DECADES = _load_list(_DECADES_PATH, _FALLBACK_DECADES_PATH) DECADES = _load_list(_DECADES_PATH, _FALLBACK_DECADES_PATH)
# Flat list of every genre, derived from the grouped list so the two stay in sync.
MUSIC_GENRES = [g for group in GENRE_GROUPS for g in group['genres']]
def parse_style_genre(style_genre: str | None) -> dict: def parse_style_genre(style_genre: str | None) -> dict:
""" """

294
lists/music_genres_grouped.txt Executable file
View file

@ -0,0 +1,294 @@
ROCK
Alternative Rock
Art Rock
Blues Rock
Britpop
Classic Rock
Folk Rock
Garage Rock
Glam Rock
Gothic Rock
Grunge
Hard Rock
Indie
Indie Rock
J-Rock
Krautrock
Math Rock
Neue Deutsche Welle
New Romantic
New Wave
Post-Rock
Power Pop
Progressive Rock
Psychedelic Rock
Rock
Rock & Roll
Rockabilly
Shoegaze
Soft Rock
Southern Rock
Space Rock
Stoner Rock
Surf Rock
Symphonic Rock
Trop Rock
Yacht Rock
METAL
Black Metal
Death Metal
Deathcore
Doom Metal
Folk Metal
Glam Metal
Gothic Metal
Heavy Metal
Industrial Metal
Metal
Metalcore
Nu Metal
Power Metal
Progressive Metal
Symphonic Metal
Thrash Metal
PUNK & EMO
Afropunk
Emo
Hardcore Punk
Midwest Emo
Pop Punk
Post-Hardcore
Post-Punk
Punk Rock
Screamo
Ska Punk
POP
Adult Contemporary
Art Pop
Ballad
Baroque Pop
Bedroom Pop
Bubblegum Pop
C-Pop
Cantopop
City Pop
Dance Pop
Dream Pop
Electropop
Folk Pop
Indie Pop
J-Pop
K-Pop
Latin Pop
Piano Ballad
Pop
Pop Rock
Power Ballad
Synthpop
HIP-HOP & RAP
Boom Bap
Cloud Rap
Conscious Hip-Hop
Crunk
Drill
G-Funk
Gangsta Rap
Grime
Hip-Hop
Latin Trap
Lo-Fi Hip-Hop
Miami Bass
Phonk
Trap
UK Drill
R&B & SOUL
Alternative R&B
Doo-Wop
Funk
Motown
Neo-Soul
New Jack Swing
R&B
Slow Jam
Soul
ELECTRONIC & DANCE
Acid House
Afro House
Amapiano
Ambient
Bass Music
Big Beat
Big Room House
Breakbeat
Chicago House
Chillout
Chillwave
Chiptune
Dance
Dark Ambient
Darkwave
Deep House
Detroit Techno
Disco
Downtempo
Drum & Bass
Dubstep
EBM
Electro
Electro House
Electroclash
Electronic
Eurobeat
Eurodance
Future Bass
Future Funk
Goa Trance
Happy Hardcore
Hardstyle
House
Hyperpop
IDM
Industrial
Italo Disco
Jungle
Lo-Fi
Melodic Techno
Minimal Techno
Moombahton
New Age
Nu-Disco
Progressive House
Progressive Trance
Psybient
Psytrance
Synthwave
Tech House
Techno
Trance
Trip-Hop
Tropical House
UK Garage
Uplifting Trance
Vaporwave
JAZZ
Acid Jazz
Bebop
Big Band
Cool Jazz
Dixieland
Free Jazz
Jam Band
Jazz
Jazz Funk
Jazz Fusion
Latin Jazz
Ragtime
Smooth Jazz
Swing
BLUES
Blues
FOLK, COUNTRY & AMERICANA
Acoustic
Alt-Country
Americana
Bluegrass
Celtic
Country
Country Pop
Country Rock
Folk
Honky Tonk
Indie Folk
Irish Folk
Outlaw Country
Sea Shanty
Singer-Songwriter
Western Swing
Zydeco
WORLD & REGIONAL
Afrobeat
Afrobeats
Bhangra
Bollywood
Highlife
Klezmer
Native American
Polka
Qawwali
World Music
LATIN
Bachata
Bossa Nova
Corrido
Cumbia
Flamenco
Latin
Mariachi
Merengue
Norteño
Reggaeton
Regional Mexican
Salsa
Samba
Tango
Tejano
REGGAE & CARIBBEAN
Calypso
Dancehall
Dub
Reggae
Roots Reggae
Ska
Soca
CLASSICAL & ORCHESTRAL
Baroque
Chamber Music
Classical
Neoclassical
Opera
Orchestral
CHRISTIAN & GOSPEL
Christian Hip-Hop
Christian Rock
Contemporary Christian
Gospel
Worship
SOUNDTRACK, CINEMATIC & THEATRE
Anime
Cabaret
Cinematic
Musical Theatre
Soundtrack
CHILDREN'S & HOLIDAY
Children's Music
Christmas
Disney
Lullaby
EASY LISTENING & TRADITIONAL VOCAL
A Cappella
Barbershop
Chanson
Easy Listening
Lounge
OTHER / EXPERIMENTAL
Avant-Garde
Experimental
Noise

View file

@ -133,16 +133,24 @@
<label for="customer_style_genre_basic">Basic style <span class="required">*</span></label> <label for="customer_style_genre_basic">Basic style <span class="required">*</span></label>
<select id="customer_style_genre_basic" name="basic_style" required> <select id="customer_style_genre_basic" name="basic_style" required>
<option value="" {% if not style_parts.basic_style %}selected{% endif %}>— Select style —</option> <option value="" {% if not style_parts.basic_style %}selected{% endif %}>— Select style —</option>
{% for g in genres %} {% for group in genre_groups %}
<option value="{{ g }}" {% if style_parts.basic_style == g %}selected{% endif %}>{{ g }}</option> <optgroup label="{{ group.label }}">
{% for g in group.genres %}
<option value="{{ g }}" {% if style_parts.basic_style == g %}selected{% endif %}>{{ g }}</option>
{% endfor %}
</optgroup>
{% endfor %} {% endfor %}
</select> </select>
<label for="customer_style_genre_additional">Additional style (optional)</label> <label for="customer_style_genre_additional">Additional style (optional)</label>
<select id="customer_style_genre_additional" name="additional_style"> <select id="customer_style_genre_additional" name="additional_style">
<option value="" {% if not style_parts.additional_style %}selected{% endif %}>— None —</option> <option value="" {% if not style_parts.additional_style %}selected{% endif %}>— None —</option>
{% for g in genres %} {% for group in genre_groups %}
<option value="{{ g }}" {% if style_parts.additional_style == g %}selected{% endif %}>{{ g }}</option> <optgroup label="{{ group.label }}">
{% for g in group.genres %}
<option value="{{ g }}" {% if style_parts.additional_style == g %}selected{% endif %}>{{ g }}</option>
{% endfor %}
</optgroup>
{% endfor %} {% endfor %}
</select> </select>

View file

@ -62,16 +62,24 @@
<label for="basic_style">Basic style <span class="required">*</span></label> <label for="basic_style">Basic style <span class="required">*</span></label>
<select id="basic_style" name="basic_style" required> <select id="basic_style" name="basic_style" required>
<option value="" {% if not form or not form.basic_style %}selected{% endif %}>— Select style —</option> <option value="" {% if not form or not form.basic_style %}selected{% endif %}>— Select style —</option>
{% for g in genres %} {% for group in genre_groups %}
<option value="{{ g }}" {% if form and form.basic_style == g %}selected{% endif %}>{{ g }}</option> <optgroup label="{{ group.label }}">
{% for g in group.genres %}
<option value="{{ g }}" {% if form and form.basic_style == g %}selected{% endif %}>{{ g }}</option>
{% endfor %}
</optgroup>
{% endfor %} {% endfor %}
</select> </select>
<label for="additional_style">Additional style (optional)</label> <label for="additional_style">Additional style (optional)</label>
<select id="additional_style" name="additional_style"> <select id="additional_style" name="additional_style">
<option value="" {% if not form or not form.additional_style %}selected{% endif %}>— None —</option> <option value="" {% if not form or not form.additional_style %}selected{% endif %}>— None —</option>
{% for g in genres %} {% for group in genre_groups %}
<option value="{{ g }}" {% if form and form.additional_style == g %}selected{% endif %}>{{ g }}</option> <optgroup label="{{ group.label }}">
{% for g in group.genres %}
<option value="{{ g }}" {% if form and form.additional_style == g %}selected{% endif %}>{{ g }}</option>
{% endfor %}
</optgroup>
{% endfor %} {% endfor %}
</select> </select>