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

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'])