146 lines
5.0 KiB
Python
146 lines
5.0 KiB
Python
#!/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()
|