Webradio mit Python: HTTP-Streaming für MP3 und M3U leicht gemacht

15. März 2025

Ich habe schon länger mit dem Gedanken gespielt, einen eigenen Webradio-Stream für mein lokales Netzwerk einzurichten – idealerweise mit einer einfachen grafischen Oberfläche, um die Songs und Playlists zu verwalten. Nach einigen Experimenten und Fehlschlägen habe ich endlich ein funktionierendes Python-Skript erstellt, das genau das macht: Es streamt MP3-Dateien oder M3U-Playlists über HTTP, sodass man sie mit einem Client wie VLC abspielen kann.

Das Beste daran? Es kommt mit einer hübschen GUI in sanften Graublau-Tönen, die die Bedienung kinderleicht macht. In diesem Artikel teile ich das Skript, erkläre, wie es funktioniert, was nicht geklappt hat, und wie man den Stream auch über das Internet zugänglich macht.

Download + Kurzanleitung: Web_Radio_Streaming_Server_GUI.zip

Bildschirmfoto zu 2025 03 15 15 59 47
Die GUI des Web-Streaming-Servers. Die Pfadangaben zu den Ordnern und M3U-Listen können in einer einer ini-Datei eingetragen werden.

Bedienung: Das Skript ist sehr einfach zu bedienen. Nach dem Start öffnet sich ein Fenster, in dem man entweder einen Ordner mit MP3-Dateien oder eine M3U-Playlist auswählen kann. Ein Klick auf „Stream starten“ startet den HTTP-Server, und der Statusbereich zeigt die URL an, unter der der Stream erreichbar ist – z. B. http://192.168.1.233:8000/stream. Diese URL kann man dann in VLC eingeben, um den Stream abzuspielen. Mit „Stream stoppen“ wird der Server sauber beendet. Wichtig: Wenn man einen Ordner auswählt, sollten nur MP3-Dateien darin enthalten sein, da andere Dateitypen (z. B. Bilder oder Textdateien) den Abspielprozess stören können. Sowohl M3U-Playlists als auch MP3-Ordner werden in einer Dauerschleife abgespielt, sodass der Stream kontinuierlich läuft, bis man ihn stoppt.

Beispiel einer Ini-Datei: Ein Muster wird automatisch angelegt.

[Paths]
path1 = /home/volker/Schreibtisch/MP3 Mix Smooth Jazz
path2 = /home/volker/Schreibtisch/Verknüpfung mit MP4 Smoth Jazz Pat Metheny Paul Hardcastle/mp3/Pat_Metheny_Group/playlist.m3u
path3 = /home/volker/Schreibtisch/Verknüpfung mit MP4 Smoth Jazz Pat Metheny Paul Hardcastle/mp3/Paul_Hardcastle_III
path4 = /home/volker/Schreibtisch/Miles Davis - Kind of Blue/playlist.m3u
path5 = /home/volker/Musik/MP3/60er 70er Klassiker
path6 = /home/volker/Musik/MP3/Konstantin Klashtorni  - Kool & Klean - Vol. II (2011)/playlist.m3u
path7 = /home/volker/Musik/MP3/Chris Standring - Compilation/playlist.m3u
path8 = /home/volker/Musik/MP3/playlist.m3u

Funktion: Das Skript basiert auf mehreren Bausteinen. Die grafische Oberfläche wird mit tkinter erstellt, einer Standardbibliothek von Python, die ich mit einem Graublau-Farbschema optisch aufgewertet habe. Der Streaming-Server nutzt Pythons http.server-Modul in Kombination mit socketserver.ThreadingMixIn, um mehrere Clients gleichzeitig zu unterstützen (theoretisch bis zu MAX_CLIENTS = 5, aber das ist aktuell nicht strikt begrenzt). Die Audiodaten werden in Chunks gesendet, wobei das Transfer-Encoding: chunked-Protokoll verwendet wird, um einen kontinuierlichen Stream zu gewährleisten. Das Skript lädt entweder alle MP3-Dateien aus einem Ordner oder liest eine M3U-Playlist und streamt die Songs in einer Endlosschleife.

Was nicht geklappt hat: Ich habe anfangs versucht, Metadaten (wie Titel und Interpret) in den Stream einzubetten, damit VLC sie anzeigen kann. Das ist leider nicht gelungen – die Metadaten wurden nicht korrekt übertragen, und ich habe nach mehreren Ansätzen (z. B. mit icy-metaint und Metadaten-Blöcken) aufgegeben. Ebenso wollte ich eine Anzeige einbauen, die zeigt, wie viele Clients gerade zuhören, aber das war mit der aktuellen Implementierung zu komplex und hat nicht zuverlässig funktioniert. Außerdem hatte ich Probleme mit verzerrtem Ton, als ich versucht habe, die Audiodaten manuell in Chunks zu splitten – das lag an der fehlenden Frame-Awareness für MP3-Dateien. Letztlich habe ich diese Features weggelassen, um eine stabile Basisversion zu haben.

Streaming über das Internet: Um den Stream nicht nur im lokalen Netzwerk, sondern auch über das Internet zugänglich zu machen, sind ein paar Schritte nötig:

  • Portforwarding: Logge dich in deinen Router ein (oft über 192.168.1.1 im Browser) und richte ein Portforwarding für Port 8000 ein. Weiterleite den Port 8000 (TCP) an die lokale IP-Adresse deines Computers, auf dem das Skript läuft (z. B. 192.168.1.233). Die genaue Vorgehensweise hängt vom Router ab – bei einer Fritzbox findest du das unter „Internet > Freigaben > Portfreigaben“.
  • Öffentliche IP-Adresse: Finde deine öffentliche IP-Adresse heraus, z. B. über eine Webseite wie whatismyipaddress.com. Diese IP wird sich bei den meisten Internetanbietern regelmäßig ändern, da sie dynamisch ist.
  • DynDNS-Dienst: Um mit einer dynamischen IP umzugehen, kannst du einen DynDNS-Dienst wie No-IP oder DynDNS nutzen. Registriere dich dort, erstelle einen Hostnamen (z. B. meinwebradio.ddns.net), und installiere den DynDNS-Client auf deinem Computer oder Router, damit deine öffentliche IP automatisch aktualisiert wird.
  • Stream abrufen: Sobald alles eingerichtet ist, kannst du den Stream von außerhalb deines Netzwerks über die URL http://meinwebradio.ddns.net:8000/stream (oder deine öffentliche IP) in VLC abspielen. Beachte, dass dein Router möglicherweise zusätzliche Sicherheitsfunktionen wie eine Firewall hat – stelle sicher, dass Port 8000 nicht blockiert wird.
  • Sicherheitshinweis: Wenn du deinen Stream öffentlich machst, solltest du dich mit Sicherheitsaspekten auseinandersetzen, z. B. den Zugriff mit einer Firewall beschränken oder das Skript um eine Authentifizierung erweitern, damit nicht jeder darauf zugreifen kann.

Abhängigkeiten: Das Skript hat minimale Abhängigkeiten, da es fast ausschließlich auf Python-Standardbibliotheken setzt:

  • Python 3: Das Skript läuft mit Python 3 (getestet mit Python 3.10.12).
  • tkinter: Für die GUI – normalerweise in Python enthalten, unter Debian/Ubuntu ggf. nachinstallieren mit sudo apt install python3-tk.
  • Keine weiteren externen Bibliotheken wie ffmpeg oder gTTS sind nötig, im Gegensatz zu meinen früheren Ansätzen.

Das Skript wurde auf Debian und Ubuntu getestet und läuft auf beiden Systemen einwandfrei. Für die Installation der Abhängigkeiten reicht dieser Befehl:

sudo apt-get update && sudo apt-get install -y python3-tk

Client: Ich habe den Stream mit VLC als Client getestet. Einfach die URL (z. B. http://192.168.1.233:8000/stream) in VLC unter „Medien > Netzwerkstream öffnen“ eingeben, und schon läuft die Musik. VLC ist stabil und unterstützt das chunked Encoding, das das Skript verwendet, problemlos.

Code des Skripts: Web Radio Streaming Server

import os
import time
from http.server import HTTPServer, BaseHTTPRequestHandler
import socketserver
import errno
import threading
import tkinter as tk
from tkinter import ttk, messagebox
import configparser

# Farbschema (sanfte Graublau-Töne)
COLORS = {
    "bg_dark": "#2E3B4E",
    "bg_main": "#3D4F65",
    "bg_light": "#4A5D75",
    "text": "#E0E7EF",
    "accent": "#6B8CAE",
    "button": "#5A7A9E"
}

# Konfigurationsdatei für Pfade
CONFIG_FILE = "webradio_paths.ini"

# Maximale Anzahl der Clients (für zukünftige Erweiterungen, aktuell nicht begrenzt)
MAX_CLIENTS = 5

class RadioStreamHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        if self.path == "/stream":
            self.send_response(200)
            self.send_header("Content-type", "audio/mpeg")
            self.send_header("Transfer-Encoding", "chunked")
            self.send_header("Cache-Control", "no-cache")
            self.send_header("Connection", "keep-alive")
            self.send_header("icy-name", "Mein LAN-Radio")
            self.end_headers()

            playlist = self.server.get_playlist()
            if not playlist:
                self.wfile.write(b"0\r\n\r\n")
                return

            while True:
                for mp3_file in playlist:
                    try:
                        with open(mp3_file, "rb") as f:
                            audio_data = f.read()
                            chunk_size = len(audio_data)
                            self.wfile.write(f"{chunk_size:x}\r\n".encode())
                            self.wfile.write(audio_data)
                            self.wfile.write(b"\r\n")
                            self.wfile.flush()
                            print(f"Spiele für Client {self.client_address}: {os.path.basename(mp3_file)}")
                    except IOError as e:
                        if e.errno == errno.EPIPE:
                            print(f"Client {self.client_address} getrennt bei {mp3_file}, fahre fort...")
                            return  # Client-Verbindung endet, aber andere können weiterlaufen
                        else:
                            print(f"Fehler bei {mp3_file} für Client {self.client_address}: {e}")
                            return
                    except Exception as e:
                        print(f"Unbekannter Fehler bei {mp3_file} für Client {self.client_address}: {e}")
                        return
                time.sleep(0.1)  # Kurze Pause zwischen Songs für Synchronisation

        else:
            self.send_error(404, "Nur /stream verfügbar")

class ThreadingRadioServer(socketserver.ThreadingMixIn, HTTPServer):
    def __init__(self, server_address, handler_class, music_dir=None, m3u_file=None):
        super().__init__(server_address, handler_class)
        self.music_dir = music_dir
        self.m3u_file = m3u_file
        self.playlist = self.load_playlist()

    def load_playlist(self):
        if self.m3u_file and os.path.exists(self.m3u_file):
            with open(self.m3u_file, "r") as f:
                playlist = []
                for line in f:
                    line = line.strip()
                    if line and line.endswith(".mp3"):
                        # Absoluter Pfad oder relativ zum M3U-Ordner
                        if os.path.isabs(line):
                            mp3_path = line
                        else:
                            mp3_path = os.path.join(os.path.dirname(self.m3u_file), line)
                        if os.path.exists(mp3_path):
                            playlist.append(mp3_path)
                        else:
                            print(f"Datei nicht gefunden: {mp3_path}")
                return playlist
        elif self.music_dir:
            return [os.path.join(self.music_dir, f) for f in os.listdir(self.music_dir) if f.endswith(".mp3")]
        return []

    def get_playlist(self):
        return self.playlist

class WebradioApp:
    def __init__(self, root):
        self.root = root
        self.root.title("Webradio Streaming Server")
        self.root.geometry("600x500")
        self.root.configure(bg=COLORS["bg_main"])

        self.server = None
        self.server_thread = None
        self.paths = self.load_paths()
        
        self.create_styles()
        self.create_widgets()

    def load_paths(self):
        """Lade Pfade aus der INI-Datei"""
        config = configparser.ConfigParser()
        if not os.path.exists(CONFIG_FILE):
            # Standard-Pfade, falls die INI-Datei nicht existiert
            config['Paths'] = {
                'path1': '/home/volker/Schreibtisch/MP3 Mix Smooth Jazz',
                'path2': '/home/volker/Musik'
            }
            with open(CONFIG_FILE, 'w') as configfile:
                config.write(configfile)
        config.read(CONFIG_FILE)
        return list(config['Paths'].values())

    def create_styles(self):
        """Definiere die Stile für ttk-Widgets"""
        self.style = ttk.Style()
        self.style.configure("TFrame", background=COLORS["bg_main"])
        self.style.configure("TLabel", 
                            background=COLORS["bg_main"], 
                            foreground=COLORS["text"],
                            font=("Arial", 10))
        self.style.configure("TButton", 
                            background=COLORS["button"],
                            foreground=COLORS["text"],
                            font=("Arial", 10, "bold"))
        self.style.configure("TListbox", 
                            background=COLORS["bg_light"],
                            foreground=COLORS["text"])
        self.style.map("TButton",
                      background=[("active", COLORS["accent"])])
        self.style.configure("Header.TLabel",
                            font=("Arial", 14, "bold"),
                            padding=10)
        self.style.configure("Section.TLabel",
                            font=("Arial", 12, "bold"),
                            padding=(0, 10, 0, 5))

    def create_widgets(self):
        """Erstelle die GUI-Elemente"""
        main_frame = ttk.Frame(self.root)
        main_frame.pack(fill=tk.BOTH, expand=True, padx=20, pady=20)

        # Titel
        title_label = ttk.Label(main_frame, text="Webradio Streaming Server", 
                               style="Header.TLabel")
        title_label.pack(fill=tk.X)

        # Auswahlbereich
        selection_section = ttk.Label(main_frame, text="Ordner oder M3U auswählen:",
                                     style="Section.TLabel")
        selection_section.pack(fill=tk.X)

        self.listbox = tk.Listbox(main_frame, 
                                 bg=COLORS["bg_light"],
                                 fg=COLORS["text"],
                                 selectbackground=COLORS["accent"],
                                 relief="flat",
                                 height=10)
        self.listbox.pack(fill=tk.BOTH, expand=True, pady=5)

        # Fülle die Listbox mit Pfaden
        for path in self.paths:
            self.listbox.insert(tk.END, path)

        # Aktionsbereich
        actions_frame = ttk.Frame(main_frame)
        actions_frame.pack(fill=tk.X, pady=10)

        start_button = ttk.Button(actions_frame, 
                                 text="Stream starten", 
                                 command=self.start_stream)
        start_button.pack(side=tk.LEFT, padx=5)

        stop_button = ttk.Button(actions_frame, 
                                text="Stream stoppen", 
                                command=self.stop_stream)
        stop_button.pack(side=tk.LEFT, padx=5)

        # Status
        self.status_var = tk.StringVar()
        self.status_var.set("Bereit")

        status_frame = ttk.Frame(main_frame)
        status_frame.pack(fill=tk.X, pady=10)

        status_label = ttk.Label(status_frame, text="Status:")
        status_label.pack(side=tk.LEFT, padx=5)

        status_text = ttk.Label(status_frame, textvariable=self.status_var)
        status_text.pack(side=tk.LEFT, padx=5)

    def start_stream(self):
        """Starte den Streaming-Server"""
        if self.server:
            messagebox.showwarning("Warnung", "Der Server läuft bereits!")
            return

        selection = self.listbox.curselection()
        if not selection:
            messagebox.showwarning("Warnung", "Bitte wählen Sie einen Ordner oder eine M3U-Datei aus!")
            return

        selected_path = self.listbox.get(selection[0])
        music_dir = None
        m3u_file = None

        if selected_path.endswith(".m3u"):
            m3u_file = selected_path
        else:
            music_dir = selected_path

        self.status_var.set("Starte Server...")
        self.root.update_idletasks()

        try:
            server_address = ("0.0.0.0", 8000)
            self.server = ThreadingRadioServer(server_address, RadioStreamHandler, music_dir, m3u_file)
            local_ip = self.get_local_ip()
            self.status_var.set(f"LAN-Radio läuft auf http://{local_ip}:8000/stream (bis zu {MAX_CLIENTS} Clients)")
            self.server_thread = threading.Thread(target=self.server.serve_forever)
            self.server_thread.daemon = True
            self.server_thread.start()
        except Exception as e:
            self.status_var.set("Fehler beim Starten des Servers")
            messagebox.showerror("Fehler", f"Fehler beim Starten des Servers: {e}")
            self.server = None

    def stop_stream(self):
        """Stoppe den Streaming-Server"""
        if not self.server:
            messagebox.showwarning("Warnung", "Kein Server läuft!")
            return

        self.status_var.set("Stoppe Server...")
        self.root.update_idletasks()

        try:
            self.server.shutdown()
            self.server.server_close()
            self.server = None
            self.server_thread = None
            self.status_var.set("Server gestoppt")
        except Exception as e:
            self.status_var.set("Fehler beim Stoppen")
            messagebox.showerror("Fehler", f"Fehler beim Stoppen des Servers: {e}")

    def get_local_ip(self):
        import socket
        s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        try:
            s.connect(("8.8.8.8", 80))
            ip = s.getsockname()[0]
        except Exception:
            ip = "localhost"
        finally:
            s.close()
        return ip

if __name__ == "__main__":
    root = tk.Tk()
    app = WebradioApp(root)
    root.mainloop()

Modifikation für mpg123 und Asterisk: Falls der Stream in Asterisk oder mit mpg123 abgespielt werden soll, sind Modifikationen am obigen Skript notwendig. Dadurch wird der Stream nicht mehr in Chunks gesendet sondern an einem Stück. Nachfolgend die Änderungen:

class RadioStreamHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        if self.path == "/stream":
            self.send_response(200)
            self.send_header("Content-type", "audio/mpeg")
            self.send_header("Cache-Control", "no-cache")
            self.send_header("Connection", "keep-alive")
            self.send_header("icy-name", "Mein LAN-Radio")
            self.end_headers()

            playlist = self.server.get_playlist()
            if not playlist:
                self.wfile.write(b"")  # Leere Antwort bei leerer Playlist
                return

            while True:
                for mp3_file in playlist:
                    try:
                        with open(mp3_file, "rb") as f:
                            audio_data = f.read()
                            self.wfile.write(audio_data)  # Direkt schreiben ohne Chunk-Header
                            self.wfile.flush()
                            print(f"Spiele für Client {self.client_address}: {os.path.basename(mp3_file)}")
                    except IOError as e:
                        if e.errno == errno.EPIPE:
                            print(f"Client {self.client_address} getrennt bei {mp3_file}, fahre fort...")
                            return
                        else:
                            print(f"Fehler bei {mp3_file} für Client {self.client_address}: {e}")
                            return
                    except Exception as e:
                        print(f"Unbekannter Fehler bei {mp3_file} für Client {self.client_address}: {e}")
                        return
                time.sleep(0.1)  # Kurze Pause zwischen Songs

        else:
            self.send_error(404, "Nur /stream verfügbar")

Das vollständige Skript: WEB Radio Streaming Server für Asterisk und mpg123.zip

Skript zum automatischen Erzeugen von m3u-Dateien: Liest alle mp3-Dateien in den Unterordnern und macht daraus eine M3U-Datei.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

"""
Dieses Python-Skript erstellt eine M3U-Playlist aus den Audio-Dateien in dem Verzeichnis, in dem das Skript ausgeführt wird, sowie in allen Unterordnern. Es unterstützt .mp3- und .mp4-Dateien und ist darauf ausgelegt, Metadaten von .mp3-Dateien zu extrahieren, um diese in die Playlist zu integrieren. Relative Pfade werden verwendet.

Erstellt mit Grok unter Anleitung von Volker Lange-Janson SM5ZBS, angepasst am 16. März 2025.
Jeder darf damit machen, was er will.
"""

import os
try:
    from mutagen.mp3 import MP3
    mutagen_available = True
except ImportError:
    mutagen_available = False

# Definiere die Dateiendungen, die in die Playlist aufgenommen werden sollen
erlaubte_endungen = ('.mp3',)

# Ermittle das Verzeichnis, in dem sich das Skript befindet
skript_verzeichnis = os.path.dirname(os.path.abspath(__file__))

# Erstelle den Namen der m3u-Datei
m3u_datei = os.path.join(skript_verzeichnis, 'playlist.m3u')

def get_mp3_info(dateipfad):
    """Versucht, MP3-Metadaten zu extrahieren, wenn mutagen verfügbar ist."""
    if not mutagen_available:
        return "Unknown Artist - Unknown Title", None, 0
    try:
        audio = MP3(dateipfad)
        title = audio.get('TIT2', ['Unknown Title'])[0]
        artist = audio.get('TPE1', ['Unknown Artist'])[0]
        album = audio.get('TALB', ['Unknown Album'])[0]
        track = audio.get('TRCK', ['0'])[0].split('/')[0]  # Nur die erste Zahl der Tracknummer nehmen
        duration = int(audio.info.length) if audio.info else 0
        return f"{artist} - {title} ({album})", int(track) if track.isdigit() else None, duration
    except Exception:
        return "Unknown Artist - Unknown Title", None, 0

# Dateien sortieren nach Tracknummer und Dateiname
def datei_sortierschluessel(dateipfad):
    if dateipfad.lower().endswith('.mp3'):
        _, tracknummer, _ = get_mp3_info(dateipfad)
        return (tracknummer if tracknummer is not None else float('inf'), dateipfad.lower())
    return (float('inf'), dateipfad.lower())

# Alle Dateien aus Verzeichnis und Unterordnern sammeln
def finde_dateien(verzeichnis):
    dateien = []
    for wurzel, _, dateiliste in os.walk(verzeichnis):
        for datei in dateiliste:
            if datei.lower().endswith(erlaubte_endungen):
                voller_pfad = os.path.join(wurzel, datei)
                relativer_pfad = os.path.relpath(voller_pfad, skript_verzeichnis)
                dateien.append(relativer_pfad)
    return sorted(dateien, key=datei_sortierschluessel)

# Dateien finden und sortieren
dateien = finde_dateien(skript_verzeichnis)

# M3U-Datei schreiben
with open(m3u_datei, 'w', encoding='utf-8') as playlist:
    playlist.write("#EXTM3U\n")
    for relativer_pfad in dateien:
        voller_pfad = os.path.join(skript_verzeichnis, relativer_pfad)
        info, _, duration = get_mp3_info(voller_pfad) if relativer_pfad.lower().endswith('.mp3') else (relativer_pfad, None, 0)
        playlist.write(f"#EXTINF:{duration},{info}\n{relativer_pfad}\n")
        
        # Falls MP3, Metadaten in der Konsole ausgeben
        if relativer_pfad.lower().endswith('.mp3'):
            print(f"{relativer_pfad}: {info}")

print(f"Playlist erfolgreich erstellt: {m3u_datei}")

Fazit: Das Skript ist eine einfache, aber effektive Lösung, um ein Webradio im lokalen Netzwerk oder über das Internet zu streamen. Es ist nicht perfekt – die fehlenden Metadaten und die Client-Anzeige sind Schwächen, die ich in Zukunft vielleicht noch angehe. Aber für den Moment erfüllt es seinen Zweck: unkompliziertes Streaming mit einer benutzerfreundlichen Oberfläche. Für alle, die ein kleines Bastelprojekt suchen oder einfach nur ihre MP3-Sammlung im Netzwerk abspielen wollen, ist das Skript ein guter Startpunkt. Viel Spaß beim Streamen!

Anmerkung: Die Skripte und Texte einschließlich der Formatierung wurden fast vollständig von Grok 3 verfasst und ließen sich als HTML direkt in WordPress kopieren. Vollautomatisch geht es noch nicht. Dieser Artikel dient auch als Beispiel, was bereits im März 2025 mit einem Chatbot machbar ist.