- Added mutagen, requests, and yt-dlp as dependencies in pyproject.toml and requirements.txt. - Removed run_generator.py, start_server.py, and test_generator.py as they are no longer needed. - Introduced setup.sh for initial setup instructions and directory creation. - Updated uv.lock with new package versions and dependencies.
308 lines
12 KiB
Python
308 lines
12 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="_audio", output_file="podcast_feed.xml", base_url="http://localhost:8087"):
|
||
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
|
||
|
||
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
|
||
|
||
# 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
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
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
|
||
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"
|
||
|
||
# iTunes-spezifische Tags
|
||
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_category = ET.SubElement(channel, "itunes:category")
|
||
itunes_category.set("text", "Music")
|
||
|
||
# Explicit Content
|
||
itunes_explicit = ET.SubElement(channel, "itunes:explicit")
|
||
itunes_explicit.text = "false"
|
||
|
||
# Standard-Bild (kann später angepasst werden)
|
||
image_url = f"{self.base_url}/podcast-cover.jpg"
|
||
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 mp3_file in mp3_files:
|
||
metadata = self.get_mp3_metadata(mp3_file)
|
||
|
||
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 Audio-Datei
|
||
item_link = ET.SubElement(item, "link")
|
||
audio_url = f"{self.base_url}/{self.audio_dir}/{urllib.parse.quote(metadata['filename'])}"
|
||
item_link.text = audio_url
|
||
|
||
# GUID
|
||
item_guid = ET.SubElement(item, "guid")
|
||
item_guid.text = audio_url
|
||
item_guid.set("isPermaLink", "true")
|
||
|
||
# Veröffentlichungsdatum
|
||
item_pubdate = ET.SubElement(item, "pubDate")
|
||
item_pubdate.text = metadata['pub_date'].strftime('%a, %d %b %Y %H:%M:%S %z')
|
||
|
||
# Audio-Enclosure
|
||
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
|
||
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"
|
||
|
||
# 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
|
||
|
||
# Wechsle in das Arbeitsverzeichnis
|
||
original_dir = os.getcwd()
|
||
|
||
class CustomHandler(http.server.SimpleHTTPRequestHandler):
|
||
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}/{self.audio_dir}/")
|
||
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="_audio",
|
||
help="Verzeichnis mit MP3-Dateien (Standard: _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="http://localhost:8087",
|
||
help="Basis-URL für Audio-Dateien (Standard: http://localhost:8087)")
|
||
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()
|