Initial commit

This commit is contained in:
Luis Reich
2026-09-22 23:01:56 +02:00
parent d122f1610e
commit a4bbb390c3
9 changed files with 2296 additions and 1 deletions

145
serve.py Normal file
View File

@@ -0,0 +1,145 @@
#!/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()