diff --git a/README.md b/README.md index 97f295c..9541a2e 100644 --- a/README.md +++ b/README.md @@ -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/` | 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/` | 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 diff --git a/VERSION b/VERSION index 0bfccb0..ef52a64 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.5 +0.4.6 diff --git a/app.py b/app.py index 2c0f3b9..2a9b99e 100644 --- a/app.py +++ b/app.py @@ -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//delete', methods=['POST']) diff --git a/lists/decades.txt b/lists/decades.txt new file mode 100755 index 0000000..4423cda --- /dev/null +++ b/lists/decades.txt @@ -0,0 +1,10 @@ +Early 20th Centrury +1940's +1950's +1960's +1970's +1980's +1990's +2000's +2010's +Modern diff --git a/lists/music_genres.txt b/lists/music_genres.txt new file mode 100755 index 0000000..9a894a4 --- /dev/null +++ b/lists/music_genres.txt @@ -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 \ No newline at end of file diff --git a/templates/admin/request.html b/templates/admin/request.html index eb89ab3..fe61dd6 100644 --- a/templates/admin/request.html +++ b/templates/admin/request.html @@ -182,8 +182,31 @@ - - + + + + + + + + + +

Stored style: {{ req.style_genre or '—' }}

@@ -407,17 +430,23 @@ diff --git a/templates/request.html b/templates/request.html index 53f71c7..192f147 100644 --- a/templates/request.html +++ b/templates/request.html @@ -146,8 +146,48 @@ - - + + + + + + + + + +

Selected style: {% 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 %}

+ +