feat: add per-song MP3 download buttons to admin request page

Adds an admin download route (/admin/request/<rid>/download-song/<version>)
and Download buttons next to Version A and Version B in section 2 (Songs),
shown only when the MP3 exists on disk. Bumps version to 0.9.2.
This commit is contained in:
Troll (Hermes Agent) 2026-09-20 02:49:01 +00:00
parent 530451cfef
commit be85a88543
5 changed files with 34 additions and 3 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.1-blue) ![Version](https://img.shields.io/badge/version-v0.9.2-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.
@ -78,7 +78,7 @@ The selected pronouns are stored in the `pronouns` column and included in confir
|------|------|---------| |------|------|---------|
| Login | `/admin/login` | Simple session-based login. Password comes from the `ADMIN_PASSWORD` environment variable. | | Login | `/admin/login` | Simple session-based login. Password comes from the `ADMIN_PASSWORD` environment variable. |
| Dashboard | `/admin` | Main queue. Filter by status and auto-refresh at a configurable interval. Includes a **Refresh MusicGPT Status** button to manually poll all in-flight tasks. | | Dashboard | `/admin` | Main queue. Filter by status and auto-refresh at a configurable interval. Includes a **Refresh MusicGPT Status** button to manually poll all in-flight tasks. |
| Request detail | `/admin/request/<id>` | Full control of one request: edit customer info, save prompt, copy Hermes callback, queue MusicGPT generation, poll a single MusicGPT task, view status/cost, view revision history, upload MP3 overrides, send preview, record payment, deliver files (MP3/WAV/cover), add operator notes, cancel generation, and generate STEMS. | | Request detail | `/admin/request/<id>` | Full control of one request: edit customer info, save prompt, copy Hermes callback, queue MusicGPT generation, poll a single MusicGPT task, view status/cost, view revision history, upload MP3 overrides, download individual Version A/B MP3s, send preview, record payment, deliver files (MP3/WAV/cover), add operator notes, cancel generation, and generate STEMS. |
| Pricing | `/admin/pricing` | Configure fixed prices and up to 5 custom items. Also shows running MusicGPT cost totals in USD. | | Pricing | `/admin/pricing` | Configure fixed prices and up to 5 custom items. Also shows running MusicGPT cost totals in USD. |
| Sales | `/admin/sales` | Report of all delivered requests with customer details and Square payment references. | | Sales | `/admin/sales` | Report of all delivered requests with customer details and Square payment references. |
| Settings | `/admin/settings` | Database health, backups, SMTP config, MP3 metadata defaults, revision limit, auto-refresh interval, kiosk mode, booth open/closed switch, Hermes API key management, MusicGPT webhook URL, album cover delivery toggle, automatic MusicGPT polling toggle, and system reset. | | Settings | `/admin/settings` | Database health, backups, SMTP config, MP3 metadata defaults, revision limit, auto-refresh interval, kiosk mode, booth open/closed switch, Hermes API key management, MusicGPT webhook URL, album cover delivery toggle, automatic MusicGPT polling toggle, and system reset. |

View file

@ -1 +1 @@
0.9.1 0.9.2

18
app.py
View file

@ -922,6 +922,24 @@ def admin_download_stems(rid):
return send_file(buf, mimetype='application/zip', as_attachment=True, download_name=download_name) return send_file(buf, mimetype='application/zip', as_attachment=True, download_name=download_name)
@app.route('/admin/request/<int:rid>/download-song/<version>')
def admin_download_song(rid, version):
"""Download a single song MP3 (Version A or B) for a request."""
redir = require_admin()
if redir:
return redir
req = get_request_by_id(rid)
if not req:
abort(404)
if version not in ('a', 'b'):
abort(404)
path = req.get('song_a_path') if version == 'a' else req.get('song_b_path')
if not path or not Path(path).exists():
flash('Song file not found.', 'error')
return redirect(url_for('admin_request', rid=rid))
return send_file(path, as_attachment=True, download_name=Path(path).name)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Admin routes # Admin routes
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View file

@ -558,6 +558,13 @@
flex: 1; flex: 1;
} }
.booth-page .song-download {
flex: 0 0 auto;
align-self: center;
font-size: 0.85rem;
padding: var(--space-1) var(--space-3);
}
.booth-page .file-select { .booth-page .file-select {
background: var(--bg-input); background: var(--bg-input);
border: 1px solid var(--border); border: 1px solid var(--border);

View file

@ -313,6 +313,9 @@
<span class="status-badge missing">Not ready</span> <span class="status-badge missing">Not ready</span>
{% endif %} {% endif %}
</label> </label>
{% if req.song_a_path and file_exists(req.song_a_path) %}
<a href="{{ url_for('admin_download_song', rid=req.id, version='a') }}" class="button-link song-download">⬇ Download</a>
{% endif %}
</div> </div>
<div class="song-row"> <div class="song-row">
<input type="checkbox" id="delete_b" name="delete_song" value="b" {% if not (req.song_b_path and file_exists(req.song_b_path)) %}disabled{% endif %}> <input type="checkbox" id="delete_b" name="delete_song" value="b" {% if not (req.song_b_path and file_exists(req.song_b_path)) %}disabled{% endif %}>
@ -326,6 +329,9 @@
<span class="status-badge missing">Not ready</span> <span class="status-badge missing">Not ready</span>
{% endif %} {% endif %}
</label> </label>
{% if req.song_b_path and file_exists(req.song_b_path) %}
<a href="{{ url_for('admin_download_song', rid=req.id, version='b') }}" class="button-link song-download">⬇ Download</a>
{% endif %}
</div> </div>
<div class="actions"> <div class="actions">
<button type="submit" class="button-danger" onclick="return confirm('Delete selected song files? This will reset the request to Prompt Ready.')">Delete Selected Songs</button> <button type="submit" class="button-danger" onclick="return confirm('Delete selected song files? This will reset the request to Prompt Ready.')">Delete Selected Songs</button>