Replace free-text genre field with structured style dropdowns

The customer request form previously had an open 'Style / genre / mood'
text field, which produced inconsistent genre descriptions and confused
Suno. Replace it with three dropdowns:

- Decade / era (required) — loaded from /mnt/Storage/Decades.txt
- Basic style (required) — loaded from /mnt/Storage/Music Genres.txt
- Additional style (optional) — same genre list

The combined value is stored in the existing style_genre column as a
comma-separated string (e.g. '1980's, Pop, Funk'), so no schema change
is required. The admin request page exposes the same dropdowns for
corrections, and the copy-to-Hermes prompt formats the style as a clean
sentence like '1980's-era Pop with Funk influences'.

The canonical lists live in /mnt/Storage; bundled copies under lists/
act as a fallback when that mount is unavailable in the container.

Bumps version to 0.4.6 and updates README.
This commit is contained in:
Troll (Hermes Agent) 2026-08-05 17:03:28 +00:00
parent d79c58691a
commit 10ee182558
7 changed files with 381 additions and 16 deletions

View file

@ -1,6 +1,6 @@
# Theme Song Booth
**Version:** `v0.4.5`
**Version:** `v0.4.6`
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 Suno prompts, upload MP3 previews, collect payment, and deliver final songs by email.
@ -38,7 +38,7 @@ A Flask web application for running a convention booth where visitors request a
| Page | Path | Purpose |
|------|------|---------|
| Request form | `/request` | Visitors enter name, email, hobbies, notable facts, preferred style/genre, vocal gender, and extra requests. Rate limited to 5 submissions per minute per IP. |
| Request form | `/request` | Visitors enter name, email, 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. |
| 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. |
| Order status | `/status` | Customers enter their email to see all their requests and statuses. |
@ -46,6 +46,16 @@ A Flask web application for running a convention booth where visitors request a
| Private player | `/play/<token>` | Secret link emailed to the customer. Streams Version A and B, lets them approve or request revisions, and later download delivered files / stems. |
| Kiosk | `/kiosk` | Public full-screen display for a booth tablet. Cycles between a QR code for `/request` and the configured price list. Updates automatically when pricing or booth state changes. |
### Style selection
The request form no longer has a free-text genre field. Instead, customers choose:
1. **Decade / era** — required (e.g. `1980's`).
2. **Basic style** — required (e.g. `Pop`).
3. **Additional style** — optional (e.g. `Funk`).
These are stored together in the `style_genre` column as a comma-separated string (e.g. `1980's, Pop, Funk`) so no database schema change is needed. The admin request page shows the same dropdowns and a live preview of the combined value. The copy-to-Hermes prompt formats this as a clean sentence like "1980's-era Pop with Funk influences" for better Suno results.
---
## Operator / admin pages

View file

@ -1 +1 @@
0.4.5
0.4.6

90
app.py
View file

@ -64,6 +64,60 @@ from models import SCHEMA, get_requests_by_email, log_revision, list_revision_hi
from mutagen.mp3 import MP3
from mutagen.easyid3 import EasyID3
# ---------------------------------------------------------------------------
# Genre / decade helpers
# ---------------------------------------------------------------------------
# Load external lists once at import time. These are shared by the customer
# request form and the admin request editor so the dropdowns stay in sync.
# In production the canonical files live under /mnt/Storage. We also bundle
# copies in the repo so the container still works if that mount is missing.
_GENRES_PATH = Path('/mnt/Storage/Music Genres.txt')
_DECADES_PATH = Path('/mnt/Storage/Decades.txt')
_FALLBACK_GENRES_PATH = Path(__file__).parent / 'lists' / 'music_genres.txt'
_FALLBACK_DECADES_PATH = Path(__file__).parent / 'lists' / 'decades.txt'
def _load_lines(path: Path) -> list[str]:
"""Load a text file and return non-empty stripped lines."""
if not path.exists():
return []
lines = path.read_text(encoding='utf-8').splitlines()
return [line.strip() for line in lines if line.strip()]
def _load_list(primary: Path, fallback: Path) -> list[str]:
"""Load from the primary path, falling back to the bundled copy."""
lines = _load_lines(primary)
if lines:
return lines
return _load_lines(fallback)
MUSIC_GENRES = _load_list(_GENRES_PATH, _FALLBACK_GENRES_PATH)
DECADES = _load_list(_DECADES_PATH, _FALLBACK_DECADES_PATH)
def parse_style_genre(style_genre: str | None) -> dict:
"""
Split a stored combined style string into decade, basic, and additional.
The stored format is 'Decade, Basic, Additional' (additional may be empty).
"""
parts = [p.strip() for p in (style_genre or '').split(',') if p.strip()]
return {
'decade': parts[0] if len(parts) > 0 else '',
'basic_style': parts[1] if len(parts) > 1 else '',
'additional_style': ', '.join(parts[2:]) if len(parts) > 2 else '',
}
def build_style_genre(decade: str, basic_style: str, additional_style: str) -> str:
"""Build the combined style_genre string stored in the database."""
parts = [p.strip() for p in [decade, basic_style, additional_style] if p.strip()]
return ', '.join(parts)
# ---------------------------------------------------------------------------
# App setup
# ---------------------------------------------------------------------------
@ -472,18 +526,27 @@ def request_form():
return render_template('closed.html')
if request.method == 'POST':
decade = request.form.get('decade', '').strip()
basic_style = request.form.get('basic_style', '').strip()
additional_style = request.form.get('additional_style', '').strip()
if not decade:
flash('Please select a decade.', 'error')
return render_template('request.html', form=request.form, decades=DECADES, genres=MUSIC_GENRES), 400
if not basic_style:
flash('Please select a basic style.', 'error')
return render_template('request.html', form=request.form, decades=DECADES, genres=MUSIC_GENRES), 400
form_data = {
'name': request.form.get('name', '').strip(),
'email': request.form.get('email', '').strip().lower(),
'hobbies': request.form.get('hobbies', '').strip(),
'notable_facts': request.form.get('notable_facts', '').strip(),
'style_genre': request.form.get('style_genre', '').strip(),
'style_genre': build_style_genre(decade, basic_style, additional_style),
'extra_requests': request.form.get('extra_requests', '').strip(),
'vocal_gender': request.form.get('vocal_gender', '').strip(),
}
if not is_valid_email(form_data['email']):
flash('Please enter a valid email address.', 'error')
return render_template('request.html', form=form_data), 400
return render_template('request.html', form=form_data, decades=DECADES, genres=MUSIC_GENRES), 400
rid = create_request(**form_data)
# Send confirmation email with a summary of what the customer asked for.
@ -514,7 +577,7 @@ def request_form():
flash('Your request has been submitted! Check your email soon.', 'success')
return redirect(url_for('thanks', rid=rid))
return render_template('request.html', form=None)
return render_template('request.html', form=None, decades=DECADES, genres=MUSIC_GENRES)
@app.route('/api/ping', methods=['GET'])
@ -1010,12 +1073,17 @@ def admin_request(rid):
if not is_valid_email(new_email):
flash('Please enter a valid email address.', 'error')
return redirect(url_for('admin_request', rid=rid))
style_genre = build_style_genre(
request.form.get('decade', '').strip(),
request.form.get('basic_style', '').strip(),
request.form.get('additional_style', '').strip()
)
update_request(rid,
email=new_email,
name=request.form.get('name', '').strip(),
hobbies=request.form.get('hobbies', '').strip(),
notable_facts=request.form.get('notable_facts', '').strip(),
style_genre=request.form.get('style_genre', '').strip(),
style_genre=style_genre,
vocal_gender=request.form.get('vocal_gender', '').strip(),
extra_requests=request.form.get('extra_requests', '').strip()
)
@ -1132,7 +1200,19 @@ def admin_request(rid):
return redirect(url_for('admin_request', rid=rid))
return render_template('admin/request.html', req=req, statuses=STATUS_LABELS, file_exists=file_exists, basename=basename, extra_files=extra_files, callback_url=build_prompt_callback_url(rid), revision_history=list_revision_history(rid))
return render_template(
'admin/request.html',
req=req,
style_parts=parse_style_genre(req.get('style_genre')),
decades=DECADES,
genres=MUSIC_GENRES,
statuses=STATUS_LABELS,
file_exists=file_exists,
basename=basename,
extra_files=extra_files,
callback_url=build_prompt_callback_url(rid),
revision_history=list_revision_history(rid)
)
@app.route('/admin/request/<int:rid>/delete', methods=['POST'])

10
lists/decades.txt Executable file
View file

@ -0,0 +1,10 @@
Early 20th Centrury
1940's
1950's
1960's
1970's
1980's
1990's
2000's
2010's
Modern

190
lists/music_genres.txt Executable file
View file

@ -0,0 +1,190 @@
A Cappella
Abstract
Acid
Acid Jazz
Acid Punk
Acoustic
Afro-Punk
Alternative
Alternative Rock
Ambient
Anime
Art Rock
Avantgarde
Ballad
Baroque
Bass
Beat
Bebop
Bhangra
Big Band
Big Beat
Black Metal
Bluegrass
Blues
Booty Bass
Breakbeat
BritPop
Cabaret
Celtic
Chamber Music
Chanson
Chillout
Chorus
Christian Gangsta Rap
Christian Rap
Christian Rock
Classic Rock
Classical
Club
Club-House
Comedy
Contemporary Christian
Country
Crossover
Cult
Dance
Dance Hall
Darkwave
Death Metal
Disco
Downtempo
Dream
Drum & Bass
Drum Solo
Dub
Dubstep
Duet
Easy Listening
EBM
Eclectic
Electro
Electroclash
Electronic
Emo
Ethnic
Euro-House
Euro-Techno
Eurodance
Experimental
Fast Fusion
Folk
Folk-Rock
Folklore
Freestyle
Funk
Fusion
G-Funk
Game
Gangsta
Garage
Garage Rock
Global
Goa
Gospel
Gothic
Gothic Rock
Grunge
Hard Rock
Hardcore
Heavy Metal
Hip-Hop
House
Humour
IDM
Illbient
Indie
Indie Rock
Industrial
Industro-Goth
Instrumental
Instrumental Pop
Instrumental Rock
Jam Band
Jazz
Jazz & Funk
JPop
Jungle
Krautrock
Latin
Leftfield
Lo-Fi
Lounge
Math Rock
Mariachi
Meditative
Merengue
Metal
Musical
National Folk
Native American
Neoclassical
Neue Deutsche Welle
New Age
New Romantic
New Wave
Noise
Nu-Breakz
Oldies
Opera
Podcast
Polka
Polsk Punk
Pop
Pop-Folk
Pop/Funk
Porn Groove
Post-Punk
Post-Rock
Power Ballad
Pranks
Primus
Progressive Rock
Psybient
Psychedelic
Psychedelic Rock
Psytrance
Punk
Punk Rock
Rap
Rave
Reggae
Retro
Revival
Rhythm and Blues
Rhythmic Soul
Rock
Rock & Roll
Salsa
Samba
Satire
Shoegaze
Showtunes
Ska
Slow Jam
Slow Rock
Sonata
Soul
Sound Clip
Soundtrack
Southern Rock
Space
Space Rock
Speech
Swing
Symphonic Rock
Symphony
Synthpop
Tango
Techno
Techno-Industrial
Terror
Thrash Metal
Top 40
Trailer
Trance
Tribal
Trip-Hop
Trop Rock
Vocal
World Music

View file

@ -182,8 +182,31 @@
<label for="customer_notable_facts">Notable things</label>
<textarea id="customer_notable_facts" name="notable_facts" rows="3">{{ req.notable_facts or '' }}</textarea>
<label for="customer_style_genre">Style / genre / mood</label>
<input type="text" id="customer_style_genre" name="style_genre" value="{{ req.style_genre or '' }}">
<label for="customer_style_genre_decade">Decade / era <span style="color:#f87171">*</span></label>
<select id="customer_style_genre_decade" name="decade" required>
<option value="" {% if not style_parts.decade %}selected{% endif %}>— Select decade —</option>
{% for d in decades %}
<option value="{{ d }}" {% if style_parts.decade == d %}selected{% endif %}>{{ d }}</option>
{% endfor %}
</select>
<label for="customer_style_genre_basic">Basic style <span style="color:#f87171">*</span></label>
<select id="customer_style_genre_basic" name="basic_style" required>
<option value="" {% if not style_parts.basic_style %}selected{% endif %}>— Select style —</option>
{% for g in genres %}
<option value="{{ g }}" {% if style_parts.basic_style == g %}selected{% endif %}>{{ g }}</option>
{% endfor %}
</select>
<label for="customer_style_genre_additional">Additional style (optional)</label>
<select id="customer_style_genre_additional" name="additional_style">
<option value="" {% if not style_parts.additional_style %}selected{% endif %}>— None —</option>
{% for g in genres %}
<option value="{{ g }}" {% if style_parts.additional_style == g %}selected{% endif %}>{{ g }}</option>
{% endfor %}
</select>
<p style="font-size:.85rem;color:#9ca3af;margin-top:.3rem">Stored style: {{ req.style_genre or '—' }}</p>
<label for="customer_vocal_gender">Preferred singer voice / gender</label>
<input type="text" id="customer_vocal_gender" name="vocal_gender" value="{{ req.vocal_gender or '' }}">
@ -407,17 +430,23 @@
<script>
function copyPromptForHermes() {
const callbackUrl = {{ callback_url | tojson }};
const styleGenre = {{ req.style_genre | tojson }};
const styleParts = styleGenre ? styleGenre.split(', ') : [];
const decade = styleParts[0] || '';
const basic = styleParts[1] || '';
const additional = styleParts.slice(2).join(', ') || '';
const styleSentence = [decade && `${decade}-era`, basic, additional && `with ${additional} influences`].filter(Boolean).join(' ');
const data = {
request_id: {{ req.id | tojson }},
email: {{ req.email | tojson }},
name: {{ req.name | tojson }},
hobbies: {{ req.hobbies | tojson }},
notable_facts: {{ req.notable_facts | tojson }},
style_genre: {{ req.style_genre | tojson }},
style_genre: styleGenre,
vocal_gender: {{ req.vocal_gender | tojson }},
extra_requests: {{ req.extra_requests | tojson }}
};
const text = `Booth request: ${data.request_id}\\nCustomer email: ${data.email}\\nCallback URL: ${callbackUrl}\\n\\nGenerate a Suno Custom Mode prompt for this customer and POST it back to the Callback URL as JSON.\\n\\nCustomer data:\\nName: ${data.name}\\nHobbies: ${data.hobbies || '-'}\\nNotable facts: ${data.notable_facts || '-'}\\nStyle / genre: ${data.style_genre || '-'}\\nPreferred singer voice / gender: ${data.vocal_gender || 'No preference'}\\nExtra requests: ${data.extra_requests || '-'}\\n\\nExpected JSON response format:\\n{\\n \\"request_id\\": ${data.request_id},\\n \\"email\\": \\"${data.email}\\",\\n \\"suno_title\\": \\"...\\",\\n \\"suno_style\\": \\"...\\",\\n \\"suno_lyrics\\": \\"...\\"\\n}`;
const text = `Booth request: ${data.request_id}\\nCustomer email: ${data.email}\\nCallback URL: ${callbackUrl}\\n\\nGenerate a Suno Custom Mode prompt for this customer and POST it back to the Callback URL as JSON.\\n\\nCustomer data:\\nName: ${data.name}\\nHobbies: ${data.hobbies || '-'}\\nNotable facts: ${data.notable_facts || '-'}\\nStyle / genre: ${styleSentence || styleGenre || '-'}\\nPreferred singer voice / gender: ${data.vocal_gender || 'No preference'}\\nExtra requests: ${data.extra_requests || '-'}\\n\\nExpected JSON response format:\\n{\\n \\"request_id\\": ${data.request_id},\\n \\"email\\": \\"${data.email}\\",\\n \\"suno_title\\": \\"...\\",\\n \\"suno_style\\": \\"...\\",\\n \\"suno_lyrics\\": \\"...\\"\\n}`;
navigator.clipboard.writeText(text).then(() => alert("Copied! Paste into Hermes. Hermes will POST the generated prompt back to the Callback URL."));
}
@ -435,17 +464,23 @@
const currentLyrics = {{ req.suno_lyrics | tojson }};
const revisionNote = {{ req.revision_note | tojson }};
const revisionCount = {{ req.revision_count | tojson }};
const styleGenre = {{ req.style_genre | tojson }};
const styleParts = styleGenre ? styleGenre.split(', ') : [];
const decade = styleParts[0] || '';
const basic = styleParts[1] || '';
const additional = styleParts.slice(2).join(', ') || '';
const styleSentence = [decade && `${decade}-era`, basic, additional && `with ${additional} influences`].filter(Boolean).join(' ');
const data = {
request_id: {{ req.id | tojson }},
email: {{ req.email | tojson }},
name: {{ req.name | tojson }},
hobbies: {{ req.hobbies | tojson }},
notable_facts: {{ req.notable_facts | tojson }},
style_genre: {{ req.style_genre | tojson }},
style_genre: styleGenre,
vocal_gender: {{ req.vocal_gender | tojson }},
extra_requests: {{ req.extra_requests | tojson }}
};
const text = `Booth request: ${data.request_id}\\nCustomer email: ${data.email}\\nCallback URL: ${callbackUrl}\\n\\nThis is REVISION #${revisionCount || 1} for this customer.\\n\\nGenerate a NEW Suno Custom Mode prompt that addresses the following revision request, while keeping the same overall theme/persona and matching the customer's original brief as closely as possible.\\n\\nOriginal brief:\\nName: ${data.name}\\nHobbies: ${data.hobbies || '-'}\\nNotable facts: ${data.notable_facts || '-'}\\nStyle / genre: ${data.style_genre || '-'}\\nPreferred singer voice / gender: ${data.vocal_gender || 'No preference'}\\nExtra requests: ${data.extra_requests || '-'}\\n\\nPreviously generated prompt (do not copy verbatim; adapt and improve):\\nTitle: ${currentTitle || '-'}\\nStyle: ${currentStyle || '-'}\\nLyrics:\\n${currentLyrics || '-'}\\n\\nRevision request from customer:\\n${revisionNote || '-'}\\n\\nPOST the new prompt back to the Callback URL as JSON with this shape:\\n{\\n \\"request_id\\": ${data.request_id},\\n \\"email\\": \\"${data.email}\\",\\n \\"suno_title\\": \\"...\\",\\n \\"suno_style\\": \\"...\\",\\n \\"suno_lyrics\\": \\"...\\"\\n}`;
const text = `Booth request: ${data.request_id}\\nCustomer email: ${data.email}\\nCallback URL: ${callbackUrl}\\n\\nThis is REVISION #${revisionCount || 1} for this customer.\\n\\nGenerate a NEW Suno Custom Mode prompt that addresses the following revision request, while keeping the same overall theme/persona and matching the customer's original brief as closely as possible.\\n\\nOriginal brief:\\nName: ${data.name}\\nHobbies: ${data.hobbies || '-'}\\nNotable facts: ${data.notable_facts || '-'}\\nStyle / genre: ${styleSentence || styleGenre || '-'}\\nPreferred singer voice / gender: ${data.vocal_gender || 'No preference'}\\nExtra requests: ${data.extra_requests || '-'}\\n\\nPreviously generated prompt (do not copy verbatim; adapt and improve):\\nTitle: ${currentTitle || '-'}\\nStyle: ${currentStyle || '-'}\\nLyrics:\\n${currentLyrics || '-'}\\n\\nRevision request from customer:\\n${revisionNote || '-'}\\n\\nPOST the new prompt back to the Callback URL as JSON with this shape:\\n{\\n \\"request_id\\": ${data.request_id},\\n \\"email\\": \\"${data.email}\\",\\n \\"suno_title\\": \\"...\\",\\n \\"suno_style\\": \\"...\\",\\n \\"suno_lyrics\\": \\"...\\"\\n}`;
navigator.clipboard.writeText(text).then(() => alert("Revision prompt copied! Paste into Hermes. Hermes will POST the revised prompt back to the Callback URL."));
}
</script>

View file

@ -146,8 +146,48 @@
<label for="notable_facts">Notable things about you</label>
<textarea id="notable_facts" name="notable_facts" placeholder="Anything fun, weird, or heroic we should mention">{{ form.notable_facts if form else '' }}</textarea>
<label for="style_genre">Style / genre / mood</label>
<input type="text" id="style_genre" name="style_genre" value="{{ form.style_genre if form else '' }}" placeholder="e.g. 80s power ballad, cinematic orchestral, lo-fi synthwave">
<label for="decade">Decade / era <span style="color:#f87171">*</span></label>
<select id="decade" name="decade" required>
<option value="" {% if not form or not form.decade %}selected{% endif %}>— Select decade —</option>
{% for d in decades %}
<option value="{{ d }}" {% if form and form.decade == d %}selected{% endif %}>{{ d }}</option>
{% endfor %}
</select>
<label for="basic_style">Basic style <span style="color:#f87171">*</span></label>
<select id="basic_style" name="basic_style" required>
<option value="" {% if not form or not form.basic_style %}selected{% endif %}>— Select style —</option>
{% for g in genres %}
<option value="{{ g }}" {% if form and form.basic_style == g %}selected{% endif %}>{{ g }}</option>
{% endfor %}
</select>
<label for="additional_style">Additional style (optional)</label>
<select id="additional_style" name="additional_style">
<option value="" {% if not form or not form.additional_style %}selected{% endif %}>— None —</option>
{% for g in genres %}
<option value="{{ g }}" {% if form and form.additional_style == g %}selected{% endif %}>{{ g }}</option>
{% endfor %}
</select>
<p style="font-size:.85rem;color:#9ca3af;margin-top:.3rem">Selected style: <span id="style-preview">{% if form %}{{ form.decade or '' }}{% if form.decade and form.basic_style %}, {% endif %}{{ form.basic_style or '' }}{% if form.basic_style and form.additional_style %}, {% endif %}{{ form.additional_style or '' }}{% else %}—{% endif %}</span></p>
<script>
// Live preview of the combined style string as the customer changes dropdowns.
(function(){
const decade = document.getElementById('decade');
const basic = document.getElementById('basic_style');
const additional = document.getElementById('additional_style');
const preview = document.getElementById('style-preview');
function updatePreview(){
const parts = [decade.value, basic.value, additional.value].filter(Boolean);
preview.textContent = parts.length ? parts.join(', ') : '—';
}
decade.addEventListener('change', updatePreview);
basic.addEventListener('change', updatePreview);
additional.addEventListener('change', updatePreview);
})();
</script>
<label for="vocal_gender">Preferred singer voice / gender</label>
<select id="vocal_gender" name="vocal_gender">