Files
serman-rss-wrapper-mixcloud/local_podcast_generator.py

424 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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()