Initial commit
This commit is contained in:
145
serve.py
Normal file
145
serve.py
Normal 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()
|
||||
Reference in New Issue
Block a user