#!/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 2 Tage zurück from datetime import timezone, timedelta pub_date = datetime.fromtimestamp(file_path.stat().st_mtime, tz=timezone.utc) - timedelta(days=2) 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, aber 2 Tage zurück um Zukunftsprobleme zu vermeiden from datetime import timezone, timedelta current_date = datetime.now(timezone.utc) - timedelta(days=2) 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 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()