Add MP3 metadata settings and auto-tag uploaded files with title, artist, album, year, genre, comment
This commit is contained in:
parent
98b191ff3c
commit
d74a715216
4 changed files with 130 additions and 30 deletions
126
app.py
126
app.py
|
|
@ -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 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
|
from models import SCHEMA
|
||||||
|
|
||||||
|
# Audio metadata import
|
||||||
|
from mutagen.mp3 import MP3
|
||||||
|
from mutagen.easyid3 import EasyID3
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# App setup
|
# App setup
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -98,13 +102,15 @@ def upload_path(request_id):
|
||||||
return p
|
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
|
Save an uploaded MP3 file for a request, preserving the original filename
|
||||||
with a version prefix (e.g. A - MySong.mp3 / B - MySong.mp3).
|
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 request_id: database ID of the request
|
||||||
:param file_obj: Flask FileStorage from request.files
|
:param file_obj: Flask FileStorage from request.files
|
||||||
:param version: 'a' or 'b'
|
: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
|
:return: full filesystem path saved, or None on missing/invalid file
|
||||||
"""
|
"""
|
||||||
if not file_obj or file_obj.filename == '':
|
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)
|
p = upload_path(request_id)
|
||||||
dest = p / filename
|
dest = p / filename
|
||||||
file_obj.save(dest)
|
file_obj.save(dest)
|
||||||
|
apply_mp3_tags(str(dest), song_title)
|
||||||
return str(dest)
|
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):
|
def send_email(to, subject, body, attachments=None):
|
||||||
"""
|
"""
|
||||||
Send an email via SMTP_SSL.
|
Send an email via SMTP_SSL.
|
||||||
|
|
@ -206,13 +278,7 @@ def play(token):
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|
||||||
# Load runtime max revisions setting.
|
# Load runtime max revisions setting.
|
||||||
cfg_path = Path(current_app.config['UPLOAD_FOLDER']).parent / 'booth_settings.json'
|
runtime_settings = load_booth_settings()
|
||||||
runtime_settings = {}
|
|
||||||
if cfg_path.exists():
|
|
||||||
try:
|
|
||||||
runtime_settings = json.loads(cfg_path.read_text())
|
|
||||||
except (json.JSONDecodeError, OSError):
|
|
||||||
runtime_settings = {}
|
|
||||||
max_revisions = runtime_settings.get('max_revisions', current_app.config['MAX_REVISIONS'])
|
max_revisions = runtime_settings.get('max_revisions', current_app.config['MAX_REVISIONS'])
|
||||||
revisions_left = max(0, max_revisions - (req.get('revision_count') or 0))
|
revisions_left = max(0, max_revisions - (req.get('revision_count') or 0))
|
||||||
|
|
||||||
|
|
@ -255,13 +321,7 @@ def revise(token):
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|
||||||
# Enforce max revisions limit for customer-submitted revisions.
|
# Enforce max revisions limit for customer-submitted revisions.
|
||||||
cfg_path = Path(current_app.config['UPLOAD_FOLDER']).parent / 'booth_settings.json'
|
runtime_settings = load_booth_settings()
|
||||||
runtime_settings = {}
|
|
||||||
if cfg_path.exists():
|
|
||||||
try:
|
|
||||||
runtime_settings = json.loads(cfg_path.read_text())
|
|
||||||
except (json.JSONDecodeError, OSError):
|
|
||||||
runtime_settings = {}
|
|
||||||
max_revisions = runtime_settings.get('max_revisions', current_app.config['MAX_REVISIONS'])
|
max_revisions = runtime_settings.get('max_revisions', current_app.config['MAX_REVISIONS'])
|
||||||
current_count = req.get('revision_count') or 0
|
current_count = req.get('revision_count') or 0
|
||||||
if current_count >= max_revisions:
|
if current_count >= max_revisions:
|
||||||
|
|
@ -394,8 +454,10 @@ def admin_request(rid):
|
||||||
|
|
||||||
elif action == 'upload_songs':
|
elif action == 'upload_songs':
|
||||||
# Save uploaded MP3 files for Version A and/or Version B.
|
# Save uploaded MP3 files for Version A and/or Version B.
|
||||||
a_path = save_upload(rid, request.files.get('song_a'), 'a')
|
# Use the saved Suno title as the MP3 title tag if available.
|
||||||
b_path = save_upload(rid, request.files.get('song_b'), 'b')
|
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 = {}
|
fields = {}
|
||||||
if a_path:
|
if a_path:
|
||||||
fields['song_a_path'] = 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).
|
# Load persistent runtime settings (max_revisions overrides env var if set).
|
||||||
cfg_path = Path(current_app.config['UPLOAD_FOLDER']).parent / 'booth_settings.json'
|
cfg_path = Path(current_app.config['UPLOAD_FOLDER']).parent / 'booth_settings.json'
|
||||||
runtime_settings = {}
|
runtime_settings = load_booth_settings()
|
||||||
if cfg_path.exists():
|
|
||||||
try:
|
|
||||||
runtime_settings = json.loads(cfg_path.read_text())
|
|
||||||
except (json.JSONDecodeError, OSError):
|
|
||||||
runtime_settings = {}
|
|
||||||
current_max_revisions = runtime_settings.get('max_revisions', current_app.config['MAX_REVISIONS'])
|
current_max_revisions = runtime_settings.get('max_revisions', current_app.config['MAX_REVISIONS'])
|
||||||
|
|
||||||
# Compute upload folder stats.
|
# Compute upload folder stats.
|
||||||
|
|
@ -598,18 +655,26 @@ def admin_settings():
|
||||||
val = int(request.form.get('max_revisions', '2').strip())
|
val = int(request.form.get('max_revisions', '2').strip())
|
||||||
if val < 0:
|
if val < 0:
|
||||||
raise ValueError
|
raise ValueError
|
||||||
# Store in a simple config file so it persists across restarts.
|
cfg = load_booth_settings()
|
||||||
cfg_path = Path(current_app.config['UPLOAD_FOLDER']).parent / 'booth_settings.json'
|
cfg['max_revisions'] = val
|
||||||
data = {}
|
save_booth_settings(cfg)
|
||||||
if cfg_path.exists():
|
|
||||||
data = json.loads(cfg_path.read_text())
|
|
||||||
data['max_revisions'] = val
|
|
||||||
cfg_path.write_text(json.dumps(data))
|
|
||||||
flash(f'Maximum revisions set to {val}.', 'success')
|
flash(f'Maximum revisions set to {val}.', 'success')
|
||||||
except ValueError:
|
except ValueError:
|
||||||
flash('Invalid revision limit. Please enter a non-negative number.', 'error')
|
flash('Invalid revision limit. Please enter a non-negative number.', 'error')
|
||||||
return redirect(url_for('admin_settings'))
|
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(
|
return render_template(
|
||||||
'admin/settings.html',
|
'admin/settings.html',
|
||||||
health=health,
|
health=health,
|
||||||
|
|
@ -623,6 +688,7 @@ def admin_settings():
|
||||||
db_path=str(db_path),
|
db_path=str(db_path),
|
||||||
upload_path=str(upload_root),
|
upload_path=str(upload_root),
|
||||||
current_max_revisions=current_max_revisions,
|
current_max_revisions=current_max_revisions,
|
||||||
|
metadata=runtime_settings,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
7
booth_settings.json
Normal file
7
booth_settings.json
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"artist": "Trollgorithm",
|
||||||
|
"album": "Theme Booth 2026",
|
||||||
|
"year": "2026",
|
||||||
|
"genre": "Country Metal",
|
||||||
|
"comment": "Custom booth song"
|
||||||
|
}
|
||||||
|
|
@ -12,3 +12,4 @@ flask
|
||||||
gunicorn
|
gunicorn
|
||||||
python-dotenv
|
python-dotenv
|
||||||
werkzeug
|
werkzeug
|
||||||
|
mutagen
|
||||||
|
|
|
||||||
|
|
@ -206,6 +206,32 @@
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</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 -->
|
<!-- System reset -->
|
||||||
<div class="danger-zone">
|
<div class="danger-zone">
|
||||||
<h2>⚠️ Reset System</h2>
|
<h2>⚠️ Reset System</h2>
|
||||||
|
|
|
||||||
Reference in a new issue