#!/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 und validiere es try: from PIL import Image import io # Lade das Bild aus den Cover-Daten image = Image.open(io.BytesIO(metadata['cover_data'])) width, height = image.size # Apple Podcasts benötigt quadratische Cover zwischen 1400-3000px if width < 1400 or height < 1400: print(f" ⚠️ Cover zu klein ({width}x{height}px), skaliere auf 1400x1400px") image = image.resize((1400, 1400), Image.Resampling.LANCZOS) elif width > 3000 or height > 3000: print(f" ⚠️ Cover zu groß ({width}x{height}px), skaliere auf 3000x3000px") image = image.resize((3000, 3000), Image.Resampling.LANCZOS) elif width != height: print(f" ⚠️ Cover nicht quadratisch ({width}x{height}px), schneide zu") size = min(width, height) image = image.crop(((width-size)//2, (height-size)//2, (width+size)//2, (height+size)//2)) # Speichere das optimierte Cover image.save(cover_path, 'JPEG', quality=95, optimize=True) print(f" 🖼️ Cover optimiert und gespeichert: {cover_filename} ({image.size[0]}x{image.size[1]}px)") except ImportError: print(" ⚠️ PIL/Pillow nicht installiert - Cover wird ohne Optimierung gespeichert") # Fallback: Speichere Cover ohne Optimierung with open(cover_path, 'wb') as f: f.write(metadata['cover_data']) print(f" 🖼️ Cover gespeichert: {cover_filename} (unoptimiert)") except Exception as img_error: print(f" ⚠️ Fehler bei Cover-Optimierung: {img_error}") # Fallback: Speichere Cover ohne Optimierung with open(cover_path, 'wb') as f: f.write(metadata['cover_data']) print(f" 🖼️ Cover gespeichert: {cover_filename} (unoptimiert)") # Rückgabe der URL zum Cover (URL-encoded für korrekte Links) cover_url = f"{self.base_url}/_audio/{urllib.parse.quote(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 do_HEAD(self): """HTTP HEAD Requests für Apple Podcasts Kompatibilität.""" # HEAD-Requests sind für Apple Podcasts erforderlich super().do_HEAD() 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, HEAD, POST, OPTIONS') self.send_header('Access-Control-Allow-Headers', 'Content-Type') # Cache-Control für bessere Performance if self.path.endswith(('.mp3', '.jpg', '.png')): self.send_header('Cache-Control', 'public, max-age=3600') 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()