#!/usr/bin/env python3 """Serve ranker.html on localhost and resolve Letterboxd posters for it. Usage: python serve.py [--port 8731] [--no-browser] The page cannot fetch letterboxd.com itself: a browser blocks it as a cross-origin request. This server does the fetching, so dropping a ratings.csv onto the page is enough. Resolved posters are cached in posters.json, which the standalone page also accepts if you would rather open ranker.html directly. """ import argparse import json import os import socket import threading import webbrowser from concurrent.futures import ThreadPoolExecutor from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer import fetch_posters ROOT = os.path.dirname(os.path.abspath(__file__)) CACHE_PATH = os.path.join(ROOT, "posters.json") MAX_URIS_PER_REQUEST = 60 cache = {} cache_lock = threading.Lock() def resolve(uris): wanted = [] with cache_lock: for uri in uris: if uri not in cache and uri not in wanted: wanted.append(uri) if wanted: with ThreadPoolExecutor(max_workers=fetch_posters.WORKERS) as pool: found = list(pool.map(fetch_posters.fetch_poster, wanted)) with cache_lock: for uri, poster in zip(wanted, found): cache[uri] = poster fetch_posters.save_cache(CACHE_PATH, cache) hits = len([poster for poster in found if poster]) print(" resolved %d of %d" % (hits, len(wanted))) with cache_lock: return {uri: cache.get(uri) for uri in uris} # Windows would happily let a second process bind a port that is already being served, so # ask whether anyone answers there first. Refusing to reuse the address would catch that too, # but it also blocks the port for a couple of minutes after a restart, and moving to another # port loses the saved progress, which belongs to the original one. def port_in_use(port): probe = socket.socket() probe.settimeout(0.4) try: return probe.connect_ex(("127.0.0.1", port)) == 0 finally: probe.close() class Handler(SimpleHTTPRequestHandler): def __init__(self, *args, **kwargs): super().__init__(*args, directory=ROOT, **kwargs) def send_json(self, payload, status=200): body = json.dumps(payload).encode("utf-8") self.send_response(status) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def do_GET(self): if self.path == "/api/health": self.send_json({"ok": True, "cached": len(cache)}) return if self.path == "/": self.path = "/ranker.html" super().do_GET() def do_POST(self): if self.path != "/api/posters": self.send_error(404) return try: length = int(self.headers.get("Content-Length", "0")) payload = json.loads(self.rfile.read(length).decode("utf-8")) uris = [uri for uri in payload.get("uris", []) if isinstance(uri, str) and uri.strip()] except (ValueError, TypeError) as error: self.send_json({"error": str(error)}, 400) return self.send_json(resolve(uris[:MAX_URIS_PER_REQUEST])) def log_message(self, fmt, *args): pass def main(): parser = argparse.ArgumentParser(description=__doc__) # Above the common dev ports and below the 49152 ephemeral range, so nothing # should take it. Progress is saved per origin, so keep this stable: on another # port the browser treats the page as a different site and offers no resume. parser.add_argument("--port", type=int, default=8791) parser.add_argument("--no-browser", action="store_true") args = parser.parse_args() global cache cache = fetch_posters.load_cache(CACHE_PATH) server = None for port in range(args.port, args.port + 10): if port_in_use(port): print("Port %d is already serving something, trying %d." % (port, port + 1)) print("Saved progress belongs to port %d, so it will not be offered here." % args.port) continue try: server = ThreadingHTTPServer(("127.0.0.1", port), Handler) break except OSError as error: print("Port %d could not be opened (%s), trying %d." % (port, error, port + 1)) if server is None: print("Could not find a free port from %d to %d." % (args.port, args.port + 9)) return url = "http://127.0.0.1:%d/" % port print("Letterboxd Pairwise Ranker at %s" % url) print("%d posters already cached in %s" % (len([v for v in cache.values() if v]), CACHE_PATH)) print("Drop your ratings.csv onto the page. Ctrl+C to stop.") if not args.no_browser: webbrowser.open(url) try: server.serve_forever() except KeyboardInterrupt: print("\nStopped.") finally: server.server_close() if __name__ == "__main__": main()