648 lines
30 KiB
Python
648 lines
30 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}/'")
|
||
|
||
# Debug: Zeige alle gefundenen Dateien
|
||
for i, mp3_file in enumerate(mp3_files):
|
||
print(f" Datei {i+1}: '{mp3_file.name}'")
|
||
|
||
# Benenne MP3-Dateien um (Leerzeichen → Unterstriche)
|
||
renamed_files = []
|
||
rename_count = 0
|
||
for mp3_file in mp3_files:
|
||
print(f" 🔍 Prüfe: '{mp3_file.name}' - Leerzeichen vorhanden: {' ' in mp3_file.name}")
|
||
if ' ' in mp3_file.name:
|
||
# Neuer Dateiname ohne Leerzeichen
|
||
new_name = mp3_file.name.replace(' ', '_')
|
||
new_path = mp3_file.parent / new_name
|
||
|
||
# Prüfe ob neue Datei bereits existiert
|
||
if not new_path.exists():
|
||
try:
|
||
mp3_file.rename(new_path)
|
||
print(f" 📝 Umbenannt: '{mp3_file.name}' → '{new_name}'")
|
||
renamed_files.append(new_path)
|
||
rename_count += 1
|
||
except Exception as e:
|
||
print(f" ⚠️ Fehler beim Umbenennen von '{mp3_file.name}': {e}")
|
||
renamed_files.append(mp3_file)
|
||
else:
|
||
print(f" ⚠️ Datei '{new_name}' existiert bereits, überspringe Umbenennung")
|
||
# Verwende die bereits existierende umbenannte Datei
|
||
renamed_files.append(new_path)
|
||
else:
|
||
print(f" ✅ Keine Umbenennung nötig: '{mp3_file.name}'")
|
||
renamed_files.append(mp3_file)
|
||
|
||
print(f" 📊 {rename_count} Dateien umbenannt, {len(renamed_files)} Dateien total")
|
||
|
||
# Sortiere nach Änderungsdatum (neueste zuerst)
|
||
renamed_files.sort(key=lambda x: x.stat().st_mtime, reverse=True)
|
||
|
||
return renamed_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
|
||
comment = 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
|
||
|
||
# Comment-Tag extrahieren (COMM = Comment)
|
||
try:
|
||
# Versuche verschiedene Comment-Tag-Formate
|
||
if 'COMM::eng' in audio.tags:
|
||
comment = str(audio.tags['COMM::eng'])
|
||
elif 'COMM::' in str(audio.tags.keys()):
|
||
# Suche nach COMM-Tags mit beliebiger Sprache
|
||
for key in audio.tags.keys():
|
||
if key.startswith('COMM::'):
|
||
comment = str(audio.tags[key])
|
||
break
|
||
else:
|
||
# Versuche allgemeine COMM-Tags
|
||
comm_tags = audio.tags.getall('COMM')
|
||
if comm_tags:
|
||
comment = str(comm_tags[0])
|
||
except Exception as comm_error:
|
||
print(f" ⚠️ Fehler beim Lesen des Comment-Tags: {comm_error}")
|
||
|
||
if comment and comment.strip():
|
||
print(f" 💬 Comment gefunden: {comment[:50]}...")
|
||
|
||
# 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, aber einen Tag zurück
|
||
from datetime import timezone, timedelta
|
||
pub_date = datetime.fromtimestamp(file_path.stat().st_mtime, tz=timezone.utc) - timedelta(days=1)
|
||
|
||
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,
|
||
'comment': comment
|
||
}
|
||
|
||
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,
|
||
'comment': 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: gleicher Name wie MP3, aber mit .jpg Endung und _ statt Leerzeichen
|
||
mp3_stem = Path(metadata['filename']).stem
|
||
cover_filename = f"{mp3_stem.replace(' ', '_')}.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/{self.safe_url_encode(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']))
|
||
original_width, original_height = image.size
|
||
print(f" 📐 Original Cover-Größe: {original_width}x{original_height}px")
|
||
|
||
# Apple Podcasts benötigt quadratische Cover zwischen 1400-3000px
|
||
# Verwende 3000x3000 für beste Qualität, optimiere dann für <512KB
|
||
target_size = 3000
|
||
print(f" 🎯 Setze Cover-Größe auf {target_size}x{target_size}px (maximale Qualität)")
|
||
|
||
# Mache das Bild quadratisch (schneide zu oder fülle auf)
|
||
if original_width != original_height:
|
||
size = min(original_width, original_height)
|
||
left = (original_width - size) // 2
|
||
top = (original_height - size) // 2
|
||
right = left + size
|
||
bottom = top + size
|
||
image = image.crop((left, top, right, bottom))
|
||
print(f" ✂️ Bild zugeschnitten auf quadratisch: {size}x{size}px")
|
||
|
||
# Skaliere auf Zielgröße
|
||
image = image.resize((target_size, target_size), Image.Resampling.LANCZOS)
|
||
|
||
# Konvertiere zu RGB falls nötig (für JPEG) - Apple Podcasts erfordert festen Hintergrund
|
||
if image.mode in ('RGBA', 'LA', 'P'):
|
||
# Verwende weißen Hintergrund für Transparenz (Apple Podcasts Anforderung)
|
||
background = Image.new('RGB', image.size, (255, 255, 255))
|
||
if image.mode == 'P':
|
||
image = image.convert('RGBA')
|
||
if image.mode in ('RGBA', 'LA'):
|
||
# Berücksichtige Alpha-Kanal für bessere Transparenz-Behandlung
|
||
background.paste(image, mask=image.split()[-1] if len(image.split()) > 3 else None)
|
||
else:
|
||
background.paste(image)
|
||
image = background
|
||
print(f" 🔄 Transparenz entfernt (Apple Podcasts Anforderung)")
|
||
|
||
# Speichere das optimierte Cover mit angepasster Qualität für Episode-Cover
|
||
# Reduziere nur JPEG-Qualität, behalte Bildgröße zwischen 1400-3000px bei
|
||
quality = 85
|
||
max_file_size = 512 * 1024 # 512KB in Bytes
|
||
|
||
# Ersten Versuch mit Standard-Qualität
|
||
temp_buffer = io.BytesIO()
|
||
image.save(temp_buffer, 'JPEG', quality=quality, optimize=True, progressive=True)
|
||
file_size = temp_buffer.tell()
|
||
|
||
# Reduziere nur Qualität iterativ falls Datei zu groß (Bildgröße bleibt gleich)
|
||
while file_size > max_file_size and quality > 30:
|
||
quality -= 5
|
||
temp_buffer = io.BytesIO()
|
||
image.save(temp_buffer, 'JPEG', quality=quality, optimize=True, progressive=True)
|
||
file_size = temp_buffer.tell()
|
||
print(f" 📉 Reduziere Qualität auf {quality}% (Dateigröße: {file_size//1024}KB)")
|
||
|
||
# Speichere die finale Version (Bildgröße: 3000x3000px)
|
||
image.save(cover_path, 'JPEG', quality=quality, optimize=True, progressive=True)
|
||
final_width, final_height = image.size
|
||
final_size_kb = cover_path.stat().st_size // 1024
|
||
print(f" 🖼️ Cover optimiert: {cover_filename} ({final_width}x{final_height}px, {final_size_kb}KB, Qualität: {quality}%)")
|
||
|
||
# Prüfe finale Dateigröße und warne bei Überschreitung
|
||
if final_size_kb > 512:
|
||
print(f" ⚠️ Warnung: Cover-Datei größer als 512KB ({final_size_kb}KB)")
|
||
else:
|
||
print(f" ✅ Cover-Datei unter 512KB Limit")
|
||
|
||
except ImportError:
|
||
print(" ❌ PIL/Pillow nicht installiert - kann Cover nicht optimieren!")
|
||
print(" 💡 Installiere mit: uv add pillow")
|
||
return None
|
||
except Exception as img_error:
|
||
print(f" ❌ Fehler bei Cover-Optimierung: {img_error}")
|
||
return None
|
||
|
||
# Rückgabe der URL zum Cover (URL-encoded für korrekte Links)
|
||
cover_url = f"{self.base_url}/_audio/{self.safe_url_encode(cover_filename)}"
|
||
return cover_url
|
||
|
||
except Exception as e:
|
||
print(f" ⚠️ Fehler beim Extrahieren des Covers: {e}")
|
||
return None
|
||
|
||
def safe_url_encode(self, filename):
|
||
"""Erstellt URL-sichere Dateinamen durch korrektes Encoding."""
|
||
# Ersetze Leerzeichen durch Unterstriche und verwende dann URL-Encoding
|
||
safe_filename = filename.replace(' ', '_')
|
||
return urllib.parse.quote(safe_filename, safe='')
|
||
|
||
def format_rfc2822_date(self, dt):
|
||
"""Formatiert ein Datum im RFC 2822 Format für RSS."""
|
||
# RFC 2822 Format: "Wed, 02 Oct 2002 08:00:00 +0000"
|
||
# Explizite englische Namen verwenden (unabhängig von Locale)
|
||
import locale
|
||
old_locale = locale.getlocale(locale.LC_TIME)
|
||
try:
|
||
# Versuche englisches Locale zu setzen
|
||
locale.setlocale(locale.LC_TIME, 'C')
|
||
formatted = dt.strftime('%a, %d %b %Y %H:%M:%S +0000')
|
||
except:
|
||
# Fallback: manuelle Formatierung
|
||
weekdays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
|
||
months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
||
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
|
||
|
||
weekday = weekdays[dt.weekday()]
|
||
month = months[dt.month - 1]
|
||
formatted = f"{weekday}, {dt.day:02d} {month} {dt.year} {dt.hour:02d}:{dt.minute:02d}:{dt.second:02d} +0000"
|
||
finally:
|
||
# Locale zurücksetzen
|
||
try:
|
||
locale.setlocale(locale.LC_TIME, old_locale)
|
||
except:
|
||
pass
|
||
|
||
return formatted
|
||
|
||
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 mit Atom-Namespace für self-link
|
||
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/")
|
||
rss.set("xmlns:atom", "http://www.w3.org/2005/Atom")
|
||
|
||
# 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" # ISO 639-1 Code (nicht de-DE)
|
||
|
||
# Atom self-link (erforderlich für RSS-Validierung)
|
||
atom_link = ET.SubElement(channel, "atom:link")
|
||
# Verwende nur den Dateinamen ohne Pfad für die URL
|
||
output_filename = Path(self.output_file).name
|
||
atom_link.set("href", f"{self.base_url}/{output_filename}")
|
||
atom_link.set("rel", "self")
|
||
atom_link.set("type", "application/rss+xml")
|
||
|
||
# Wichtig: lastBuildDate für bessere Erkennung von Updates (RFC 2822 konform)
|
||
# Verwende aktuelles Datum für die Feed-Erstellung, aber einen Tag zurück
|
||
from datetime import timezone, timedelta
|
||
current_date = datetime.now(timezone.utc) - timedelta(days=1)
|
||
|
||
last_build = ET.SubElement(channel, "lastBuildDate")
|
||
last_build.text = self.format_rfc2822_date(current_date)
|
||
|
||
# pubDate für den Channel (RFC 2822 konform)
|
||
channel_pub_date = ET.SubElement(channel, "pubDate")
|
||
channel_pub_date.text = self.format_rfc2822_date(current_date)
|
||
|
||
# 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 Email (empfohlen)
|
||
itunes_owner = ET.SubElement(channel, "itunes:owner")
|
||
owner_name = ET.SubElement(itunes_owner, "itunes:name")
|
||
owner_name.text = podcast_author
|
||
owner_email = ET.SubElement(itunes_owner, "itunes:email")
|
||
owner_email.text = "music@serman.club"
|
||
|
||
# iTunes Kategorie (nur gültige iTunes-Kategorien verwenden)
|
||
itunes_category = ET.SubElement(channel, "itunes:category")
|
||
itunes_category.set("text", "Music")
|
||
|
||
# Entferne ungültige Sub-Kategorie - Music hat keine gültigen Sub-Kategorien
|
||
|
||
# Explicit Content (muss "true" oder "false" sein)
|
||
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.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 episode_index, mp3_file in enumerate(mp3_files):
|
||
metadata = self.get_mp3_metadata(mp3_file)
|
||
|
||
print(f"\n🎵 Episode {episode_index + 1}: {metadata['title']}")
|
||
print(f" 📄 Datei: {metadata['filename']}")
|
||
print(f" 🎨 Cover-Daten vorhanden: {'Ja' if metadata['cover_data'] else 'Nein'}")
|
||
print(f" 💬 Comment vorhanden: {'Ja' if metadata['comment'] else 'Nein'}")
|
||
if metadata['comment']:
|
||
print(f" 📝 Comment: {metadata['comment'][:100]}{'...' if len(metadata['comment']) > 100 else ''}")
|
||
|
||
# Episode-Cover extrahieren
|
||
episode_cover_url = self.extract_episode_cover(metadata, episode_index)
|
||
|
||
if episode_cover_url:
|
||
print(f" 🔗 Cover-URL: {episode_cover_url}")
|
||
else:
|
||
print(f" ❌ Keine Cover-URL generiert")
|
||
|
||
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']}"
|
||
|
||
# Füge Comment hinzu falls vorhanden
|
||
if metadata['comment'] and metadata['comment'].strip():
|
||
description_text += f"\n\n{metadata['comment']}"
|
||
print(f" 📝 Beschreibung erweitert mit Comment ({len(metadata['comment'])} Zeichen)")
|
||
else:
|
||
print(f" 📝 Standard-Beschreibung verwendet (kein Comment)")
|
||
|
||
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 (URL-safe encoded)
|
||
audio_url = f"{self.base_url}/_audio/{self.safe_url_encode(metadata['filename'])}"
|
||
item_guid.text = audio_url
|
||
item_guid.set("isPermaLink", "false")
|
||
|
||
# Veröffentlichungsdatum (RFC 2822 konform)
|
||
item_pubdate = ET.SubElement(item, "pubDate")
|
||
# RFC 2822 Format mit korrekter Formatierung
|
||
pub_date_formatted = self.format_rfc2822_date(metadata['pub_date'])
|
||
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 (Apple empfiehlt Sekunden als Integer)
|
||
if metadata['duration']:
|
||
item_duration = ET.SubElement(item, "itunes:duration")
|
||
item_duration.text = str(int(metadata['duration'])) # Integer Sekunden für Apple
|
||
|
||
# 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" # Muss "true" oder "false" sein
|
||
|
||
# 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 Episode-Level
|
||
if episode_cover_url:
|
||
# Nur iTunes image tag für Episode-Cover (Standard RSS <image> ist nur für Channel-Level gültig)
|
||
itunes_image_item = ET.SubElement(item, "itunes:image")
|
||
itunes_image_item.set("href", episode_cover_url)
|
||
print(f" ✅ iTunes Cover-Tag hinzugefügt: {episode_cover_url}")
|
||
else:
|
||
print(f" ⚠️ Kein Episode-Cover verfügbar - verwende Standard-Podcast-Cover")
|
||
|
||
# Keywords/Tags basierend auf Dateiname (mit Kommas getrennt)
|
||
if any(keyword in metadata['title'].lower() for keyword in ['organic', 'house']):
|
||
itunes_keywords = ET.SubElement(item, "itunes:keywords")
|
||
itunes_keywords.text = "organic, house, electronic, mix"
|
||
|
||
print(f" ✅ {metadata['title']} ({self.format_duration(metadata['duration']) if metadata['duration'] else 'Unbekannte Dauer'})")
|
||
if episode_cover_url:
|
||
print(f" 🖼️ Mit Episode-Cover: {episode_cover_url}")
|
||
else:
|
||
print(f" 📷 Ohne Episode-Cover")
|
||
|
||
# XML in Datei schreiben
|
||
tree = ET.ElementTree(rss)
|
||
ET.indent(tree, space=" ", level=0)
|
||
|
||
try:
|
||
# Explizite XML-Deklaration mit UTF-8 encoding
|
||
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()
|