Add MP3 metadata settings and auto-tag uploaded files with title, artist, album, year, genre, comment

This commit is contained in:
Troll (Hermes Agent) 2026-08-01 22:00:08 +00:00
parent 98b191ff3c
commit d74a715216
4 changed files with 130 additions and 30 deletions

126
app.py
View file

@ -44,6 +44,10 @@ from config import Config
from models import init_db, close_db, create_request, get_request_by_id, get_request_by_token, list_requests, update_request, now_utc, delete_request, reset_all_requests, get_db
from models import SCHEMA
# Audio metadata import
from mutagen.mp3 import MP3
from mutagen.easyid3 import EasyID3
# ---------------------------------------------------------------------------
# App setup
# ---------------------------------------------------------------------------
@ -98,13 +102,15 @@ def upload_path(request_id):
return p
def save_upload(request_id, file_obj, version):
def save_upload(request_id, file_obj, version, song_title=None):
"""
Save an uploaded MP3 file for a request, preserving the original filename
with a version prefix (e.g. A - MySong.mp3 / B - MySong.mp3).
Applies the configured metadata tags (title, artist, album, year, genre, comment).
:param request_id: database ID of the request
:param file_obj: Flask FileStorage from request.files
:param version: 'a' or 'b'
:param song_title: title to write into the MP3 title tag
:return: full filesystem path saved, or None on missing/invalid file
"""
if not file_obj or file_obj.filename == '':
@ -121,9 +127,75 @@ def save_upload(request_id, file_obj, version):
p = upload_path(request_id)
dest = p / filename
file_obj.save(dest)
apply_mp3_tags(str(dest), song_title)
return str(dest)
def apply_mp3_tags(path, title=None):
"""
Write or overwrite common ID3 tags on an MP3 file using values from
runtime booth settings. The saved Suno title is written to the Title tag.
"""
cfg = load_booth_settings()
try:
audio = MP3(path)
# Ensure EasyID3 wrapper is available for the file.
if audio.tags is None:
audio.add_tags()
if not isinstance(audio.tags, EasyID3):
audio.tags = EasyID3()
tags = audio.tags
if title:
tags['title'] = title
if cfg.get('artist'):
tags['artist'] = cfg['artist']
if cfg.get('album'):
tags['album'] = cfg['album']
if cfg.get('year'):
tags['date'] = str(cfg['year'])
if cfg.get('genre'):
tags['genre'] = cfg['genre']
audio.save()
# Comment requires a real ID3 frame, not EasyID3.
if cfg.get('comment'):
from mutagen.id3 import ID3, COMM
audio2 = MP3(path)
if audio2.tags is None:
audio2.add_tags()
audio2.tags["COMM"] = COMM(encoding=3, lang='eng', desc='Comment', text=cfg['comment'])
audio2.save()
except Exception as e:
# Don't fail the upload just because tagging failed; log/flash only if inside request context.
try:
flash(f'Warning: could not tag MP3: {e}', 'error')
except RuntimeError:
import logging
logging.getLogger('app').warning('Could not tag MP3 %s: %s', path, e)
def load_booth_settings():
"""Load persistent runtime settings from JSON file inside the upload parent."""
cfg_path = Path(current_app.config['UPLOAD_FOLDER']).parent / 'booth_settings.json'
if cfg_path.exists():
try:
return json.loads(cfg_path.read_text())
except (json.JSONDecodeError, OSError):
pass
return {}
def save_booth_settings(settings):
"""Persist runtime settings to JSON file."""
cfg_path = Path(current_app.config['UPLOAD_FOLDER']).parent / 'booth_settings.json'
try:
cfg_path.write_text(json.dumps(settings, indent=2))
except OSError as e:
flash(f'Warning: could not save settings: {e}', 'error')
def send_email(to, subject, body, attachments=None):
"""
Send an email via SMTP_SSL.
@ -206,13 +278,7 @@ def play(token):
abort(404)
# Load runtime max revisions setting.
cfg_path = Path(current_app.config['UPLOAD_FOLDER']).parent / 'booth_settings.json'
runtime_settings = {}
if cfg_path.exists():
try:
runtime_settings = json.loads(cfg_path.read_text())
except (json.JSONDecodeError, OSError):
runtime_settings = {}
runtime_settings = load_booth_settings()
max_revisions = runtime_settings.get('max_revisions', current_app.config['MAX_REVISIONS'])
revisions_left = max(0, max_revisions - (req.get('revision_count') or 0))
@ -255,13 +321,7 @@ def revise(token):
abort(404)
# Enforce max revisions limit for customer-submitted revisions.
cfg_path = Path(current_app.config['UPLOAD_FOLDER']).parent / 'booth_settings.json'
runtime_settings = {}
if cfg_path.exists():
try:
runtime_settings = json.loads(cfg_path.read_text())
except (json.JSONDecodeError, OSError):
runtime_settings = {}
runtime_settings = load_booth_settings()
max_revisions = runtime_settings.get('max_revisions', current_app.config['MAX_REVISIONS'])
current_count = req.get('revision_count') or 0
if current_count >= max_revisions:
@ -394,8 +454,10 @@ def admin_request(rid):
elif action == 'upload_songs':
# Save uploaded MP3 files for Version A and/or Version B.
a_path = save_upload(rid, request.files.get('song_a'), 'a')
b_path = save_upload(rid, request.files.get('song_b'), 'b')
# Use the saved Suno title as the MP3 title tag if available.
song_title = req.get('suno_title') or None
a_path = save_upload(rid, request.files.get('song_a'), 'a', song_title)
b_path = save_upload(rid, request.files.get('song_b'), 'b', song_title)
fields = {}
if a_path:
fields['song_a_path'] = a_path
@ -508,12 +570,7 @@ def admin_settings():
# Load persistent runtime settings (max_revisions overrides env var if set).
cfg_path = Path(current_app.config['UPLOAD_FOLDER']).parent / 'booth_settings.json'
runtime_settings = {}
if cfg_path.exists():
try:
runtime_settings = json.loads(cfg_path.read_text())
except (json.JSONDecodeError, OSError):
runtime_settings = {}
runtime_settings = load_booth_settings()
current_max_revisions = runtime_settings.get('max_revisions', current_app.config['MAX_REVISIONS'])
# Compute upload folder stats.
@ -598,18 +655,26 @@ def admin_settings():
val = int(request.form.get('max_revisions', '2').strip())
if val < 0:
raise ValueError
# Store in a simple config file so it persists across restarts.
cfg_path = Path(current_app.config['UPLOAD_FOLDER']).parent / 'booth_settings.json'
data = {}
if cfg_path.exists():
data = json.loads(cfg_path.read_text())
data['max_revisions'] = val
cfg_path.write_text(json.dumps(data))
cfg = load_booth_settings()
cfg['max_revisions'] = val
save_booth_settings(cfg)
flash(f'Maximum revisions set to {val}.', 'success')
except ValueError:
flash('Invalid revision limit. Please enter a non-negative number.', 'error')
return redirect(url_for('admin_settings'))
elif action == 'save_metadata':
# Update MP3 metadata defaults from the settings form.
cfg = load_booth_settings()
cfg['artist'] = request.form.get('artist', '').strip() or None
cfg['album'] = request.form.get('album', '').strip() or None
cfg['year'] = request.form.get('year', '').strip() or None
cfg['genre'] = request.form.get('genre', '').strip() or None
cfg['comment'] = request.form.get('comment', '').strip() or None
save_booth_settings(cfg)
flash('MP3 metadata defaults saved.', 'success')
return redirect(url_for('admin_settings'))
return render_template(
'admin/settings.html',
health=health,
@ -623,6 +688,7 @@ def admin_settings():
db_path=str(db_path),
upload_path=str(upload_root),
current_max_revisions=current_max_revisions,
metadata=runtime_settings,
)

7
booth_settings.json Normal file
View file

@ -0,0 +1,7 @@
{
"artist": "Trollgorithm",
"album": "Theme Booth 2026",
"year": "2026",
"genre": "Country Metal",
"comment": "Custom booth song"
}

View file

@ -12,3 +12,4 @@ flask
gunicorn
python-dotenv
werkzeug
mutagen

View file

@ -206,6 +206,32 @@
</form>
</div>
<!-- MP3 metadata defaults -->
<div class="section">
<h2>MP3 Metadata Tags</h2>
<p class="copy-hint">These values are written into every uploaded MP3 file. The song title from the prompt is written to the Title tag automatically.</p>
<form method="POST">
<input type="hidden" name="action" value="save_metadata">
<label for="artist">Artist</label>
<input type="text" id="artist" name="artist" value="{{ metadata.get('artist', '') }}" placeholder="e.g. Trollgorithm">
<label for="album">Album</label>
<input type="text" id="album" name="album" value="{{ metadata.get('album', '') }}" placeholder="e.g. Theme Song Booth 2026">
<label for="year">Year</label>
<input type="text" id="year" name="year" value="{{ metadata.get('year', '') }}" placeholder="e.g. 2026">
<label for="genre">Genre</label>
<input type="text" id="genre" name="genre" value="{{ metadata.get('genre', '') }}" placeholder="e.g. Country Metal">
<label for="comment">Comment</label>
<textarea id="comment" name="comment" rows="3" placeholder="e.g. Custom theme song generated at the booth">{{ metadata.get('comment', '') }}</textarea>
<button type="submit">Save Metadata Defaults</button>
</form>
</div>
<!-- System reset -->
<div class="danger-zone">
<h2>⚠️ Reset System</h2>