177 lines
6.9 KiB
Python
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()
|