Files

177 lines
6.9 KiB
Python

"""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"""
<div class="episode">
<audio controls preload="none">
<source src="/audio/{f}" type="audio/mpeg">
Your browser does not support the audio element.
</audio>
<div class="episode-meta">
<span class="episode-title">{safe_name}</span>
<a href="/audio/{f}" class="download-btn" download>Download</a>
</div>
</div>"""
if not mp3_files:
audio_entries = '<p class="empty">No newscast files available yet. The pipeline will populate this directory.</p>'
return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Guardian Daily Newscast</title>
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0f1117; color: #e1e4e8; min-height: 100vh; }}
.container {{ max-width: 720px; margin: 0 auto; padding: 2rem 1rem; }}
h1 {{ font-size: 1.8rem; margin-bottom: 0.25rem; color: #fff; }}
.subtitle {{ color: #8b949e; margin-bottom: 2rem; font-size: 0.95rem; }}
.episode {{ background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 1rem; margin-bottom: 0.75rem; }}
audio {{ width: 100%; margin-bottom: 0.5rem; border-radius: 4px; }}
.episode-meta {{ display: flex; align-items: center; justify-content: space-between; gap: 1rem; }}
.episode-title {{ color: #c9d1d9; font-size: 0.9rem; word-break: break-word; }}
.download-btn {{ background: #238636; color: #fff; padding: 0.35rem 0.75rem; border-radius: 6px; text-decoration: none; font-size: 0.85rem; white-space: nowrap; }}
.download-btn:hover {{ background: #2ea043; }}
.empty {{ text-align: center; color: #8b949e; padding: 3rem 0; }}
.refresh-btn {{ background: #21262d; color: #c9d1d9; border: 1px solid #30363d; padding: 0.5rem 1rem; border-radius: 6px; cursor: pointer; font-size: 0.85rem; margin-bottom: 1.5rem; }}
.refresh-btn:hover {{ background: #30363d; }}
.clear-btn {{ background: #490202; color: #f85149; border: 1px solid #f85149; padding: 0.5rem 1rem; border-radius: 6px; cursor: pointer; font-size: 0.85rem; margin-bottom: 1.5rem; }}
.clear-btn:hover {{ background: #da3633; color: #fff; }}
</style>
<script>
function refresh() {{ location.reload(); }}
function clearAudio() {{ fetch('/clear', {{ method: 'POST' }}).then(() => location.reload()); }}
setInterval(refresh, 300000);
</script>
</head>
<body>
<div class="container">
<h1>Guardian Daily Newscast</h1>
<p class="subtitle">Daily US news — spoken by AI</p>
<button class="refresh-btn" onclick="refresh()">Refresh</button>
<button class="clear-btn" onclick="clearAudio()">Clear Audio</button>
{audio_entries}
</div>
</body>
</html>"""
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()