
Let me paint you a picture. I’m running a Jellyfin media server (because I like my stuff local, thank you very much), and everything was smooth—until I threw some FLAC files at it. Cue the buffering, crashing, and a whole bunch of “Why won’t this work?!” moments. Turns out, Jellyfin + FLAC isn’t always a match made in heaven, especially on less beefy hardware.
So what did I do? Built my own freakin’ web app to fix it.
The Problem: FLAC Drama in Jellyfin
Jellyfin’s a fantastic media server, but FLAC files, especially in larger libraries, can be a bit much. My setup was running on an AlmaLinux 9 VM, already handling torrent duties and some other lightweight services. I wanted something that could convert FLAC to MP3 or WAV—cleanly, quickly, and ideally via a simple web interface so I didn’t have to mess with the command line every time.

The Solution: A Custom DIY Audio Converter Web App
I didn’t want bloat. I didn’t want another media manager. I wanted a focused tool that just… converts audio. So I built a Flask-based web app, wrapped it in Gunicorn, and turned it into a systemd service. It’s lightweight, fast, and looks great with a dark pastel theme.
Features I Needed (and Got):
- Upload audio files via web UI
- Convert to MP3, WAV, or AAC using FFmpeg
- Save converted files directly to my mounted network storage
- Automatically delete the uploaded original files after conversion
- Provide real-time logging of conversion status via the web UI
- Modern interface with dark pastel colors (yes, vibes matter)
- Runs as a persistent background service
- Minimal resource usage
- Accessible from any device on my LAN
- Supports batch/multi-file uploads
Tech Stack
- Python 3 + Flask – For the core web server
- FFmpeg – Does the actual heavy lifting for conversion
- Gunicorn – A production-ready WSGI server
- systemd – To manage the service reliably across reboots
- AlmaLinux 9 MusicServ – Running as a VM in my home lab

Step-by-Step: Full Reproducible Setup with Code
1. Install Dependencies
sudo dnf install epel-release -y
sudo dnf install ffmpeg python3 python3-pip nginx -y
pip3 install --user flask gunicorn
2. Directory Layout
/opt/audio_converter/
├── app.py
├── run_gunicorn.sh
├── uploads/
├── templates/
│ └── index.html
└── static/
├── css/style.css
└── js/main.js
3. app.py
from flask import Flask, request, render_template, Response, stream_with_context
import os, subprocess, time
from werkzeug.utils import secure_filename
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
UPLOAD_FOLDER = os.path.join(BASE_DIR, 'uploads')
STORAGE_PATH = "/mnt/media_storage/audio_converted"
ALLOWED_EXTENSIONS = {'mp3', 'flac', 'aac', 'wav', 'ogg', 'm4a'}
app = Flask(__name__, static_folder='static', template_folder='templates')
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
os.makedirs(STORAGE_PATH, exist_ok=True)
def allowed_file(filename):
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
def convert_file(input_path, output_path):
subprocess.run(['ffmpeg', '-y', '-i', input_path, output_path],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
@app.route('/')
def index():
return render_template('index.html')
@app.route('/convert_stream', methods=['POST'])
def convert_stream():
files = request.files.getlist('audiofiles')
output_format = request.form.get('format')
@stream_with_context
def generate():
for file in files:
if file and allowed_file(file.filename):
filename = secure_filename(file.filename)
input_path = os.path.join(app.config['UPLOAD_FOLDER'], filename)
file.save(input_path)
yield f"data: 📥 Received: {filename}\n\n"
time.sleep(0.2)
base = os.path.splitext(filename)[0]
output_filename = f"{base}.{output_format}"
output_path = os.path.join(STORAGE_PATH, output_filename)
yield f"data: 🔄 Converting to {output_format.upper()}...\n\n"
convert_file(input_path, output_path)
time.sleep(0.2)
yield f"data: 📤 Saved to storage: {output_filename}\n\n"
os.remove(input_path)
yield f"data: 🧹 Deleted original: {filename}\n\n"
time.sleep(0.1)
yield "data: ✅ All files converted and uploaded!\n\n"
return Response(generate(), mimetype='text/event-stream')
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000)
4. run_gunicorn.sh
#!/bin/bash
source ~/.bashrc
/home/rzasharp/.local/bin/gunicorn -w 1 -t 600 -b 0.0.0.0:8000 app:app
5. templates/index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Audio Converter</title>
<link rel="stylesheet" href="/static/css/style.css">
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
</head>
<body>
<div class="container">
<h1>🎧 Audio Converter</h1>
<form id="uploadForm" enctype="multipart/form-data" method="POST" action="/convert_stream">
<input type="file" name="audiofiles" multiple required>
<select name="format">
<option value="mp3">MP3</option>
<option value="wav">WAV</option>
<option value="aac">AAC</option>
</select>
<button type="submit">Convert & Upload</button>
</form>
<div id="statusBox" class="status"></div>
</div>
<script src="/static/js/main.js"></script>
</body>
</html>
6. static/css/style.css
body {
margin: 0;
font-family: 'Segoe UI', sans-serif;
background-color: #1E1E2F;
color: #ECECEC;
}
.container {
max-width: 600px;
margin: 50px auto;
padding: 20px;
background-color: #2C2C3E;
border-radius: 12px;
box-shadow: 0 0 20px rgba(0,0,0,0.5);
}
h1 {
color: #A29BFE;
text-align: center;
}
input[type="file"], select, button {
width: 100%;
padding: 10px;
margin-top: 15px;
border: none;
border-radius: 6px;
font-size: 1em;
}
select {
background-color: #2C2C3E;
color: #ECECEC;
}
button {
background-color: #74B9FF;
color: #1E1E2F;
cursor: pointer;
transition: background-color 0.3s;
}
button:hover {
background-color: #81ECEC;
}
.status {
height: 200px;
overflow-y: auto;
white-space: pre-line;
font-family: monospace;
background: #11182f;
border: 1px solid #2c2c3e;
border-radius: 6px;
margin-top: 20px;
padding: 10px;
}
7. static/js/main.js
document.getElementById("uploadForm").addEventListener("submit", function (e) {
e.preventDefault();
const form = e.target;
const statusBox = document.getElementById("statusBox");
statusBox.innerHTML = "";
const formData = new FormData(form);
fetch('/convert_stream', {
method: 'POST',
body: formData
}).then(response => {
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
function read() {
reader.read().then(({ done, value }) => {
if (done) return;
const chunk = decoder.decode(value, { stream: true });
chunk.split("\n\n").forEach(line => {
if (line.startsWith("data: ")) {
const msg = line.replace("data: ", "").trim();
if (msg) {
statusBox.innerHTML += msg + "<br/>";
statusBox.scrollTop = statusBox.scrollHeight;
}
}
});
read();
});
}
read();
});
});
8. systemd
service (optional)
[Unit]
Description=Flask Audio Converter Service
After=network.target
[Service]
User=rzasharp
WorkingDirectory=/opt/audio_converter
ExecStart=/opt/audio_converter/run_gunicorn.sh
Environment="PATH=/home/rzasharp/.local/bin:/usr/bin:/bin"
Restart=always
[Install]
WantedBy=multi-user.target
Then:
sudo systemctl daemon-reload
sudo systemctl enable --now audio_converter
Boom. Now it auto-starts on reboot and runs quietly in the background like a good little daemon.
Issues I Ran Into
- FLASK ONLY BINDING TO LOCALHOST: It was running on 127.0.0.1 and not accessible from other devices. Fixed it by using
host='0.0.0.0'
. - Permission Errors: Flask couldn’t write to the upload folder. A quick
chown -R
to my user solved that. - systemd wouldn’t launch gunicorn: Turned out gunicorn was in my
.local/bin
, which systemd doesn’t see by default. Fixed it by wrapping in a shell script. - Wanting to delete originals: Added a checkbox and Python logic to remove original files post-conversion.
Resource Usage
Ridiculously light. We’re talking ~1–2% CPU when converting and ~60MB RAM. It’s perfect to run alongside torrents or other lightweight tools on a low-resource VM.
Why I Love This
- I don’t have to think about it anymore.
- Anyone on my network can convert files (yes, you can add auth later).
- It just works—and it looks damn good doing it.
If you’re running Jellyfin or any other media server that chokes on FLAC or lossless audio formats, this setup is gold. Total time to build and debug everything? Around 4 hours. Clean. Simple. Reusable. Efficient.