"""HTTP server that serves the Guardian Daily Newscast MP3 index page and audio files.""" import glob import os import json import threading from http.server import HTTPServer, SimpleHTTPRequestHandler from socketserver import ThreadingMixIn from urllib.parse import unquote from loguru import logger class ThreadingHTTPServer(ThreadingMixIn, HTTPServer): """HTTP server that handles each request in a new thread.""" daemon_threads = True class NewscastHandler(SimpleHTTPRequestHandler): """Custom handler that serves a dynamic MP3 index at / and static files below.""" data_dir: str = "/app/data" def do_GET(self): if self.path == "/" or self.path == "": self.serve_index() else: # Strip any leading / to get a relative path rel_path = self.path.lstrip("/") # Prevent directory traversal if ".." in rel_path or rel_path.startswith("/"): self.send_error(403, "Forbidden") return super().do_GET() def serve_index(self): """Serve the dynamic MP3 listing page.""" audio_dir = os.path.join(self.data_dir, "audio") scripts_file = os.path.join(self.data_dir, "scripts.json") # Load script metadata for timestamps scripts_map = {} if os.path.exists(scripts_file): try: with open(scripts_file, "r", encoding="utf-8") as f: scripts = json.load(f) for item in scripts: scripts_map[item["headline"]] = item except (json.JSONDecodeError, KeyError): pass mp3_files = [] if os.path.isdir(audio_dir): mp3_files = sorted( f for f in os.listdir(audio_dir) if f.lower().endswith(".mp3") ) html = _generate_index(mp3_files, scripts_map) self.send_response(200) self.send_header("Content-Type", "text/html; charset=utf-8") self.send_header("Content-Length", str(len(html.encode("utf-8")))) self.end_headers() self.wfile.write(html.encode("utf-8")) def log_message(self, format, *args): """Suppress per-request logs to avoid noisy output.""" pass def do_POST(self): if self.path == "/clear": self.clear_audio() else: self.send_error(405, "Method Not Allowed") def clear_audio(self): """Remove all MP3 files from the audio directory.""" if ".." in self.path: self.send_error(403, "Forbidden") return audio_dir = os.path.join(self.data_dir, "audio") if not os.path.isdir(audio_dir): self._json_response({"status": "ok", "cleared": 0}) return mp3_paths = glob.glob(os.path.join(audio_dir, "*.mp3")) count = 0 for path in mp3_paths: try: os.remove(path) count += 1 except OSError: logger.exception(f"Failed to remove {path}") logger.info(f"Cleared {count} audio files.") self._json_response({"status": "ok", "cleared": count}) def _json_response(self, data): """Send a JSON response.""" body = json.dumps(data).encode("utf-8") self.send_response(200) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def _generate_index(mp3_files, scripts_map): """Generate the HTML index page for the MP3 newscast.""" audio_entries = "" for f in mp3_files: safe_name = f.replace('"', """) audio_entries += f"""
{safe_name} Download
""" if not mp3_files: audio_entries = '

No newscast files available yet. The pipeline will populate this directory.

' return f""" Guardian Daily Newscast

Guardian Daily Newscast

Daily US news — spoken by AI

{audio_entries}
""" def run_server(port: int, data_dir: str) -> None: """Start the HTTP server in a background thread and block.""" os.chdir("/") # Serve from root (paths are absolute) NewscastHandler.data_dir = data_dir handler = lambda *a, **kw: NewscastHandler( *a, directory=data_dir, **kw ) server = ThreadingHTTPServer(("0.0.0.0", port), handler) logger.info(f"Web server listening on port {port}") server.serve_forever()