424 lines
18 KiB
Python
424 lines
18 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
Lokaler Podcast RSS Feed Generator
|
||
Scannt das audio/ Verzeichnis nach MP3-Dateien und erstellt einen RSS-Feed für Podcast-Apps.
|
||
"""
|
||
|
||
import os
|
||
import xml.etree.ElementTree as ET
|
||
from datetime import datetime
|
||
import mutagen
|
||
from mutagen.mp3 import MP3
|
||
from mutagen.id3 import ID3
|
||
import argparse
|
||
import urllib.parse
|
||
from pathlib import Path
|
||
|
||
|
||
class LocalPodcastGenerator:
|
||
def __init__(self, audio_dir="../httpdocs/_audio", output_file="podcast_feed.xml", base_url="https://www.serman.club"):
|
||
self.audio_dir = audio_dir
|
||
self.output_file = output_file
|
||
self.base_url = base_url.rstrip('/')
|
||
|
||
def get_mp3_files(self):
|
||
"""Scannt das Audio-Verzeichnis nach MP3-Dateien."""
|
||
audio_path = Path(self.audio_dir)
|
||
if not audio_path.exists():
|
||
print(f"❌ Audio-Verzeichnis '{self.audio_dir}' existiert nicht!")
|
||
return []
|
||
|
||
mp3_files = list(audio_path.glob("*.mp3"))
|
||
print(f"🎵 {len(mp3_files)} MP3-Dateien gefunden in '{self.audio_dir}/'")
|
||
|
||
# Sortiere nach Änderungsdatum (neueste zuerst)
|
||
mp3_files.sort(key=lambda x: x.stat().st_mtime, reverse=True)
|
||
|
||
return mp3_files
|
||
|
||
def get_mp3_metadata(self, file_path):
|
||
"""Extrahiert Metadaten aus einer MP3-Datei."""
|
||
try:
|
||
audio = MP3(file_path)
|
||
|
||
# Versuche ID3-Tags zu lesen
|
||
title = None
|
||
artist = None
|
||
album = None
|
||
duration = None
|
||
cover_data = None
|
||
|
||
if audio.tags:
|
||
title = str(audio.tags.get('TIT2', [''])[0]) if audio.tags.get('TIT2') else None
|
||
artist = str(audio.tags.get('TPE1', [''])[0]) if audio.tags.get('TPE1') else None
|
||
album = str(audio.tags.get('TALB', [''])[0]) if audio.tags.get('TALB') else None
|
||
|
||
# Cover-Art extrahieren (APIC = Attached Picture)
|
||
# Versuche verschiedene APIC-Tag-Varianten
|
||
for key in audio.tags.keys():
|
||
if key.startswith('APIC'):
|
||
try:
|
||
cover_data = audio.tags[key].data
|
||
if cover_data:
|
||
print(f" 🎨 Cover gefunden in Tag: {key}")
|
||
break
|
||
except Exception as tag_error:
|
||
print(f" ⚠️ Fehler beim Lesen von {key}: {tag_error}")
|
||
continue
|
||
|
||
# Dauer in Sekunden
|
||
if hasattr(audio, 'info') and audio.info.length:
|
||
duration = int(audio.info.length)
|
||
|
||
# Fallback: Dateiname als Titel verwenden
|
||
if not title:
|
||
title = file_path.stem
|
||
|
||
# Dateigröße
|
||
file_size = file_path.stat().st_size
|
||
|
||
# Änderungsdatum als Veröffentlichungsdatum
|
||
pub_date = datetime.fromtimestamp(file_path.stat().st_mtime)
|
||
|
||
return {
|
||
'title': title,
|
||
'artist': artist or 'SERMAN',
|
||
'album': album,
|
||
'duration': duration,
|
||
'file_size': file_size,
|
||
'pub_date': pub_date,
|
||
'filename': file_path.name,
|
||
'cover_data': cover_data
|
||
}
|
||
|
||
except Exception as e:
|
||
print(f"⚠️ Fehler beim Lesen der Metadaten von {file_path.name}: {e}")
|
||
# Fallback-Metadaten
|
||
return {
|
||
'title': file_path.stem,
|
||
'artist': 'SERMAN',
|
||
'album': None,
|
||
'duration': None,
|
||
'file_size': file_path.stat().st_size,
|
||
'pub_date': datetime.fromtimestamp(file_path.stat().st_mtime),
|
||
'filename': file_path.name,
|
||
'cover_data': None
|
||
}
|
||
|
||
def extract_episode_cover(self, metadata, episode_index):
|
||
"""Extrahiert und speichert das Episode-Cover (nur wenn noch nicht vorhanden)."""
|
||
if not metadata['cover_data']:
|
||
return None
|
||
|
||
try:
|
||
# Erstelle Cover-Dateiname basierend auf MP3-Dateiname
|
||
cover_filename = f"cover_{Path(metadata['filename']).stem}.jpg"
|
||
|
||
# Speichere Cover im _audio-Verzeichnis (neben den MP3-Dateien)
|
||
audio_path = Path(self.audio_dir)
|
||
cover_path = audio_path / cover_filename
|
||
|
||
# Prüfe ob Cover bereits existiert
|
||
if cover_path.exists():
|
||
# Cover existiert bereits - verwende es einfach (URL-encoded)
|
||
cover_url = f"{self.base_url}/_audio/{urllib.parse.quote(cover_filename)}"
|
||
print(f" 🖼️ Cover bereits vorhanden: {cover_filename}")
|
||
return cover_url
|
||
|
||
# Cover existiert noch nicht - extrahiere es
|
||
with open(cover_path, 'wb') as f:
|
||
f.write(metadata['cover_data'])
|
||
|
||
# Rückgabe der URL zum Cover (URL-encoded für korrekte Links)
|
||
cover_url = f"{self.base_url}/_audio/{urllib.parse.quote(cover_filename)}"
|
||
print(f" 🖼️ Cover neu extrahiert: {cover_filename}")
|
||
return cover_url
|
||
|
||
except Exception as e:
|
||
print(f" ⚠️ Fehler beim Extrahieren des Covers: {e}")
|
||
return None
|
||
|
||
def format_duration(self, seconds):
|
||
"""Formatiert die Dauer in HH:MM:SS Format."""
|
||
if not seconds:
|
||
return "00:00:00"
|
||
|
||
hours = seconds // 3600
|
||
minutes = (seconds % 3600) // 60
|
||
seconds = seconds % 60
|
||
return f"{hours:02d}:{minutes:02d}:{seconds:02d}"
|
||
|
||
def create_rss_feed(self, podcast_title="SERMAN - Organic House Podcast",
|
||
podcast_description=None, podcast_author="SERMAN"):
|
||
"""Erstellt den RSS-Feed aus den lokalen MP3-Dateien."""
|
||
|
||
# Standard-Beschreibung
|
||
if not podcast_description:
|
||
podcast_description = """Die Leidenschaft für organischen House
|
||
|
||
Als Club DJ lebe ich für die Momente, in denen Musik und Emotion verschmelzen. Meine Sets sind geprägt von organischen House-Klängen, die eine Brücke zwischen tiefen Basslines und melodischen Höhenflügen schlagen.
|
||
|
||
Ich spezialisiere mich auf House Music, die mehr als nur Beats bietet – sie erzählt Geschichten durch warme Vocals, emotionale Melodien und eine Atmosphäre, die Menschen verbindet. Jeder Mix ist eine Reise durch verschiedene Stimmungen und Energielevel."""
|
||
|
||
mp3_files = self.get_mp3_files()
|
||
if not mp3_files:
|
||
print("❌ Keine MP3-Dateien gefunden!")
|
||
return False
|
||
|
||
# RSS Root Element
|
||
rss = ET.Element("rss")
|
||
rss.set("version", "2.0")
|
||
rss.set("xmlns:itunes", "http://www.itunes.com/dtds/podcast-1.0.dtd")
|
||
rss.set("xmlns:content", "http://purl.org/rss/1.0/modules/content/")
|
||
|
||
# Channel Element
|
||
channel = ET.SubElement(rss, "channel")
|
||
|
||
# Channel Metadaten mit besserer Podcast-Kompatibilität
|
||
title = ET.SubElement(channel, "title")
|
||
title.text = podcast_title
|
||
|
||
description = ET.SubElement(channel, "description")
|
||
description.text = podcast_description
|
||
|
||
link = ET.SubElement(channel, "link")
|
||
link.text = self.base_url
|
||
|
||
language = ET.SubElement(channel, "language")
|
||
language.text = "de-DE"
|
||
|
||
# Wichtig: lastBuildDate für bessere Erkennung von Updates
|
||
last_build = ET.SubElement(channel, "lastBuildDate")
|
||
last_build.text = datetime.now().strftime('%a, %d %b %Y %H:%M:%S +0000')
|
||
|
||
# pubDate für den Channel
|
||
channel_pub_date = ET.SubElement(channel, "pubDate")
|
||
channel_pub_date.text = datetime.now().strftime('%a, %d %b %Y %H:%M:%S +0000')
|
||
|
||
# Generator Info
|
||
generator = ET.SubElement(channel, "generator")
|
||
generator.text = "SERMAN Local Podcast Generator"
|
||
|
||
# iTunes-spezifische Tags für bessere Podcast-App-Kompatibilität
|
||
itunes_author = ET.SubElement(channel, "itunes:author")
|
||
itunes_author.text = podcast_author
|
||
|
||
itunes_summary = ET.SubElement(channel, "itunes:summary")
|
||
itunes_summary.text = podcast_description
|
||
|
||
# iTunes Kategorie
|
||
itunes_category = ET.SubElement(channel, "itunes:category")
|
||
itunes_category.set("text", "Music")
|
||
|
||
# Sub-Kategorie für bessere Einordnung
|
||
itunes_sub_category = ET.SubElement(itunes_category, "itunes:category")
|
||
itunes_sub_category.set("text", "Music Commentary")
|
||
|
||
# Explicit Content
|
||
itunes_explicit = ET.SubElement(channel, "itunes:explicit")
|
||
itunes_explicit.text = "false"
|
||
|
||
# iTunes Type (für episodische Podcasts)
|
||
itunes_type = ET.SubElement(channel, "itunes:type")
|
||
itunes_type.text = "episodic"
|
||
|
||
# Standard-Bild (kann später angepasst werden)
|
||
image_url = f"{self.base_url}/_img/podcast-cover.png"
|
||
image = ET.SubElement(channel, "image")
|
||
image_url_elem = ET.SubElement(image, "url")
|
||
image_url_elem.text = image_url
|
||
image_title = ET.SubElement(image, "title")
|
||
image_title.text = podcast_title
|
||
image_link = ET.SubElement(image, "link")
|
||
image_link.text = self.base_url
|
||
|
||
itunes_image = ET.SubElement(channel, "itunes:image")
|
||
itunes_image.set("href", image_url)
|
||
|
||
print(f"📦 Erstelle RSS-Feed mit {len(mp3_files)} Episoden...")
|
||
|
||
# Items (Episoden) hinzufügen
|
||
for episode_index, mp3_file in enumerate(mp3_files):
|
||
metadata = self.get_mp3_metadata(mp3_file)
|
||
|
||
# Episode-Cover extrahieren
|
||
episode_cover_url = self.extract_episode_cover(metadata, episode_index)
|
||
|
||
item = ET.SubElement(channel, "item")
|
||
|
||
# Titel
|
||
item_title = ET.SubElement(item, "title")
|
||
item_title.text = metadata['title']
|
||
|
||
# Beschreibung
|
||
item_description = ET.SubElement(item, "description")
|
||
description_text = f"Mix von {metadata['artist']}"
|
||
if metadata['album']:
|
||
description_text += f" aus {metadata['album']}"
|
||
item_description.text = description_text
|
||
|
||
# Link zur Episode-Seite (nicht zur Audio-Datei)
|
||
item_link = ET.SubElement(item, "link")
|
||
item_link.text = self.base_url
|
||
|
||
# GUID (eindeutige ID - verwende Audio-URL)
|
||
item_guid = ET.SubElement(item, "guid")
|
||
# Die Audio-URL als eindeutige GUID
|
||
audio_url = f"{self.base_url}/_audio/{urllib.parse.quote(metadata['filename'])}"
|
||
item_guid.text = audio_url
|
||
item_guid.set("isPermaLink", "false")
|
||
|
||
# Veröffentlichungsdatum (korrekt formatiert für RSS)
|
||
item_pubdate = ET.SubElement(item, "pubDate")
|
||
# RFC 2822 Format für RSS (mit Timezone)
|
||
pub_date_formatted = metadata['pub_date'].strftime('%a, %d %b %Y %H:%M:%S +0000')
|
||
item_pubdate.text = pub_date_formatted
|
||
|
||
# Audio-Enclosure (das ist die eigentliche Audio-URL)
|
||
enclosure = ET.SubElement(item, "enclosure")
|
||
enclosure.set("url", audio_url)
|
||
enclosure.set("type", "audio/mpeg")
|
||
enclosure.set("length", str(metadata['file_size']))
|
||
|
||
# Dauer
|
||
if metadata['duration']:
|
||
item_duration = ET.SubElement(item, "itunes:duration")
|
||
item_duration.text = self.format_duration(metadata['duration'])
|
||
|
||
# iTunes-spezifische Tags für bessere Episode-Erkennung
|
||
itunes_title = ET.SubElement(item, "itunes:title")
|
||
itunes_title.text = metadata['title']
|
||
|
||
itunes_summary = ET.SubElement(item, "itunes:summary")
|
||
itunes_summary.text = description_text
|
||
|
||
itunes_explicit_item = ET.SubElement(item, "itunes:explicit")
|
||
itunes_explicit_item.text = "false"
|
||
|
||
# iTunes Episode Typ
|
||
itunes_episode_type = ET.SubElement(item, "itunes:episodeType")
|
||
itunes_episode_type.text = "full"
|
||
|
||
# Episode-Cover hinzufügen - nur iTunes Format für bessere Kompatibilität
|
||
if episode_cover_url:
|
||
# iTunes image tag (das Standard-Format für Podcast-Apps)
|
||
itunes_image_item = ET.SubElement(item, "itunes:image")
|
||
itunes_image_item.set("href", episode_cover_url)
|
||
|
||
# Keywords/Tags basierend auf Dateiname
|
||
if any(keyword in metadata['title'].lower() for keyword in ['organic', 'house']):
|
||
itunes_keywords = ET.SubElement(item, "itunes:keywords")
|
||
itunes_keywords.text = "Organic house"
|
||
|
||
print(f" ✅ {metadata['title']} ({self.format_duration(metadata['duration']) if metadata['duration'] else 'Unbekannte Dauer'})")
|
||
|
||
# XML in Datei schreiben
|
||
tree = ET.ElementTree(rss)
|
||
ET.indent(tree, space=" ", level=0)
|
||
|
||
try:
|
||
tree.write(self.output_file, encoding='utf-8', xml_declaration=True)
|
||
print(f"✅ RSS-Feed erfolgreich erstellt: {self.output_file}")
|
||
print(f"📊 Anzahl der Episoden: {len(mp3_files)}")
|
||
print(f"🌐 Audio-URLs verwenden Basis-URL: {self.base_url}")
|
||
return True
|
||
except Exception as e:
|
||
print(f"❌ Fehler beim Schreiben der XML-Datei: {e}")
|
||
return False
|
||
|
||
def serve_feed(self, port=8000):
|
||
"""Startet einen einfachen HTTP-Server für den RSS-Feed und Audio-Dateien."""
|
||
import http.server
|
||
import socketserver
|
||
import os
|
||
from pathlib import Path
|
||
|
||
# Kopiere den RSS-Feed ins httpdocs-Verzeichnis
|
||
if "../httpdocs" in self.audio_dir:
|
||
httpdocs_path = Path("../httpdocs").resolve()
|
||
if httpdocs_path.exists():
|
||
# Kopiere RSS-Feed ins httpdocs-Verzeichnis
|
||
import shutil
|
||
source_feed = Path(self.output_file)
|
||
target_feed = httpdocs_path / self.output_file
|
||
if source_feed.exists():
|
||
shutil.copy2(source_feed, target_feed)
|
||
print(f"📄 RSS-Feed kopiert nach: {target_feed}")
|
||
|
||
# Wechsle ins httpdocs-Verzeichnis für den Server
|
||
os.chdir(httpdocs_path)
|
||
print(f"📁 Server-Root: {httpdocs_path}")
|
||
else:
|
||
print(f"⚠️ httpdocs-Verzeichnis nicht gefunden: {httpdocs_path}")
|
||
|
||
class CustomHandler(http.server.SimpleHTTPRequestHandler):
|
||
def do_GET(self):
|
||
"""Überschreibt GET-Requests um Root-Zugriff zu blockieren."""
|
||
if self.path == '/' or self.path == '/index.html' or self.path == '/index.htm':
|
||
self.send_error(403, "Access denied")
|
||
return
|
||
else:
|
||
# Für alle anderen Pfade normale Verarbeitung
|
||
super().do_GET()
|
||
|
||
def list_directory(self, path):
|
||
"""Deaktiviert Directory-Listing - zeigt 403 Forbidden."""
|
||
self.send_error(403, "Directory listing disabled")
|
||
return None
|
||
|
||
def end_headers(self):
|
||
# CORS-Header hinzufügen für bessere Kompatibilität
|
||
self.send_header('Access-Control-Allow-Origin', '*')
|
||
self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
|
||
self.send_header('Access-Control-Allow-Headers', 'Content-Type')
|
||
super().end_headers()
|
||
|
||
handler = CustomHandler
|
||
|
||
try:
|
||
with socketserver.TCPServer(("", port), handler) as httpd:
|
||
print(f"🌐 Server läuft auf http://localhost:{port}")
|
||
print(f"📡 RSS-Feed: http://localhost:{port}/{self.output_file}")
|
||
print(f"🎵 Audio-Dateien: http://localhost:{port}/_audio/")
|
||
print("⏹️ Drücke Ctrl+C zum Beenden")
|
||
httpd.serve_forever()
|
||
except KeyboardInterrupt:
|
||
print("\n👋 Server beendet.")
|
||
except Exception as e:
|
||
print(f"❌ Fehler beim Starten des Servers: {e}")
|
||
|
||
|
||
def main():
|
||
parser = argparse.ArgumentParser(description="Erstellt einen RSS-Feed aus lokalen MP3-Dateien")
|
||
parser.add_argument("-a", "--audio-dir", default="../httpdocs/_audio",
|
||
help="Verzeichnis mit MP3-Dateien (Standard: ../httpdocs/_audio)")
|
||
parser.add_argument("-o", "--output", default="podcast_feed.xml",
|
||
help="Ausgabedatei für den RSS-Feed (Standard: podcast_feed.xml)")
|
||
parser.add_argument("-u", "--base-url", default="https://www.serman.club",
|
||
help="Basis-URL für Audio-Dateien (Standard: https://www.serman.club)")
|
||
parser.add_argument("-t", "--title", default="SERMAN - Organic House Podcast",
|
||
help="Titel des Podcasts")
|
||
parser.add_argument("--author", default="SERMAN",
|
||
help="Autor des Podcasts")
|
||
parser.add_argument("--serve", action="store_true",
|
||
help="Startet einen HTTP-Server für den RSS-Feed")
|
||
parser.add_argument("--port", type=int, default=8087,
|
||
help="Port für den HTTP-Server (Standard: 80870)")
|
||
|
||
args = parser.parse_args()
|
||
|
||
generator = LocalPodcastGenerator(args.audio_dir, args.output, args.base_url)
|
||
|
||
print(f"🎵 Erstelle lokalen Podcast-Feed aus '{args.audio_dir}/'")
|
||
print(f"📡 Basis-URL: {args.base_url}")
|
||
print("-" * 60)
|
||
|
||
success = generator.create_rss_feed(args.title, None, args.author)
|
||
|
||
if success and args.serve:
|
||
generator.serve_feed(args.port)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|