mirror of
https://github.com/narrrl/pl8list-rewind.git
synced 2026-11-03 16:41:26 +01:00
Take the client IP from the right-most X-Forwarded-For entry so a spoofed header no longer resets the per-IP limit. Cap resends per login, count them against the per-email limit, add a server-wide login mail limit and cap pending logins at 500. Count code attempts under the lock. Log the pl8list session out upstream once the feed has been read, also when reading fails or the account has no handle, and always drop it from memory.
254 lines
9.8 KiB
Python
254 lines
9.8 KiB
Python
"""Thin client for pl8list.com.
|
|
|
|
pl8list is a Remix app, so every page loader can be read as JSON by adding
|
|
`?_data=<route id>` to the page URL. Resource routes under /api/ return JSON
|
|
directly. All clients share one throttle so we stay polite.
|
|
|
|
Public data (artist pages, related tracks, previews, free tracks) works without
|
|
an account, so the crawler runs as a guest. A user's own session is only needed
|
|
for login and for reading their personal feed.
|
|
"""
|
|
|
|
import base64
|
|
import json
|
|
import re
|
|
import threading
|
|
import time
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
|
|
BASE = "https://pl8list.com"
|
|
USER_AGENT = "pl8list-wrapper/0.2 (infinite-scroll client, https://pl8.narl.io)"
|
|
|
|
_throttle_lock = threading.Lock()
|
|
_throttle_last = 0.0
|
|
|
|
|
|
def _throttle(min_interval):
|
|
global _throttle_last
|
|
with _throttle_lock:
|
|
wait = _throttle_last + min_interval - time.monotonic()
|
|
if wait > 0:
|
|
time.sleep(wait)
|
|
_throttle_last = time.monotonic()
|
|
|
|
|
|
class Client:
|
|
def __init__(self, session_cookie=None, min_interval=0.7):
|
|
self.session_cookie = session_cookie
|
|
self.min_interval = min_interval
|
|
|
|
def request(self, path, params=None, headers=None):
|
|
"""Raw request. Returns the open response; caller must close it."""
|
|
url = BASE + path
|
|
if params:
|
|
url += ("&" if "?" in url else "?") + urllib.parse.urlencode(params)
|
|
h = {"User-Agent": USER_AGENT, **(headers or {})}
|
|
if self.session_cookie:
|
|
h["Cookie"] = "_session=" + self.session_cookie
|
|
return urllib.request.urlopen(urllib.request.Request(url, headers=h), timeout=30)
|
|
|
|
def get_json(self, path, params=None, route=None):
|
|
params = dict(params or {})
|
|
if route:
|
|
params["_data"] = route
|
|
for attempt in range(4):
|
|
_throttle(self.min_interval)
|
|
try:
|
|
with self.request(path, params) as r:
|
|
body = r.read()
|
|
return json.loads(body) if body else None
|
|
except urllib.error.HTTPError as e:
|
|
if e.code in (429, 502, 503, 504) and attempt < 3:
|
|
time.sleep(2 ** attempt * 3)
|
|
continue
|
|
if e.code in (403, 404):
|
|
return None
|
|
raise
|
|
return None
|
|
|
|
# Loaders -------------------------------------------------------------
|
|
|
|
def root(self):
|
|
return self.get_json("/", route="root")
|
|
|
|
def home(self):
|
|
return self.get_json("/", route="routes/_index")
|
|
|
|
# Paginated loaders ignore `page` unless `pagination=true` is also set,
|
|
# which is what the site's own useInfiniteLoading hook sends.
|
|
|
|
def feed(self, page=1):
|
|
return self.get_json("/api/feed", {"page": page, "pagination": "true"})
|
|
|
|
def artist(self, handle, page=1):
|
|
return self.get_json("/" + urllib.parse.quote(handle), {"page": page, "pagination": "true"},
|
|
route="routes/$handle")
|
|
|
|
def related(self, handle, slug):
|
|
path = "/%s/%s/related" % (urllib.parse.quote(handle), urllib.parse.quote(slug))
|
|
return self.get_json(path, route="routes/$handle_.$slug.related")
|
|
|
|
def best_selling_artists(self):
|
|
return self.get_json("/best-selling-artists", route="routes/best-selling-artists")
|
|
|
|
def activity_ticker(self):
|
|
return self.get_json("/api/activity-ticker")
|
|
|
|
def search_users(self, q, page=1):
|
|
return self.get_json("/search/users", {"q": q, "page": page, "pagination": "true"},
|
|
route="routes/search.users")
|
|
|
|
def logout(self):
|
|
"""End this session at pl8list. Errors are ignored: we drop the session either way."""
|
|
try:
|
|
self.request("/logout", {"_data": "routes/logout"}).close()
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
class LoginError(Exception):
|
|
pass
|
|
|
|
|
|
class LoginFlow:
|
|
"""pl8list's passwordless login: POST email, pl8list mails a 6-digit code, POST code.
|
|
|
|
The site keeps the pending login in its `_session` cookie, so one flow object
|
|
holds that cookie between the two steps. After `verify` succeeds,
|
|
`self.session` is the user's logged-in pl8list session.
|
|
"""
|
|
|
|
def __init__(self, email):
|
|
self.email = email
|
|
self.session = None
|
|
|
|
def _call(self, method, path, route, fields=None):
|
|
url = BASE + path + ("&" if "?" in path else "?") + urllib.parse.urlencode({"_data": route})
|
|
headers = {"User-Agent": USER_AGENT}
|
|
if self.session:
|
|
headers["Cookie"] = "_session=" + self.session
|
|
data = None
|
|
if fields is not None:
|
|
data = urllib.parse.urlencode(fields).encode()
|
|
headers["Content-Type"] = "application/x-www-form-urlencoded"
|
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
try:
|
|
resp = urllib.request.urlopen(req, timeout=30)
|
|
except urllib.error.HTTPError as e:
|
|
resp = e
|
|
except OSError as e:
|
|
raise LoginError("pl8list unreachable: %s" % e) from e
|
|
with resp:
|
|
for c in resp.headers.get_all("Set-Cookie") or []:
|
|
m = re.match(r"_session=([^;]+)", c)
|
|
if m:
|
|
self.session = m.group(1)
|
|
body = resp.read()
|
|
status = resp.status if hasattr(resp, "status") else resp.code
|
|
try:
|
|
payload = json.loads(body) if body else None
|
|
except ValueError:
|
|
payload = None
|
|
return status, resp.headers, payload
|
|
|
|
def _guest_id(self):
|
|
"""The guest id pl8list stores in its cookie; the login forms echo it back."""
|
|
try:
|
|
raw = urllib.parse.unquote(self.session).split(".")[0]
|
|
return json.loads(base64.b64decode(raw + "=" * (-len(raw) % 4))).get("pl8list_guest_id", "")
|
|
except (ValueError, AttributeError, TypeError):
|
|
return ""
|
|
|
|
def _verify_path(self):
|
|
return "/verify?" + urllib.parse.urlencode({"email": self.email, "mode": "login"})
|
|
|
|
def start(self):
|
|
"""Ask pl8list to mail a login code."""
|
|
self._call("GET", "/login", "routes/login")
|
|
status, headers, payload = self._call("POST", "/login", "routes/login",
|
|
{"email": self.email, "pl8list_guest_id": self._guest_id()})
|
|
if "/verify" not in (headers.get("X-Remix-Redirect") or ""):
|
|
raise LoginError((payload or {}).get("error") or "pl8list refused the login (HTTP %d)" % status)
|
|
|
|
def resend(self):
|
|
_, _, payload = self._call("POST", self._verify_path(), "routes/verify",
|
|
{"action": "resend", "pl8list_guest_id": self._guest_id()})
|
|
if payload and payload.get("resendError"):
|
|
raise LoginError(payload["resendError"])
|
|
|
|
def verify(self, code):
|
|
"""Submit the code. Returns pl8list's currentUser for the new session."""
|
|
_, _, payload = self._call("POST", self._verify_path(), "routes/verify",
|
|
{"action": "verify", "code": code, "pl8list_guest_id": self._guest_id()})
|
|
if payload and payload.get("error"):
|
|
raise LoginError(payload["error"])
|
|
user = (Client(self.session).root() or {}).get("currentUser")
|
|
if not user:
|
|
raise LoginError("login failed, try again")
|
|
if not user.get("handle"):
|
|
Client(self.session).logout()
|
|
self.session = None
|
|
raise LoginError("finish setting up your account on pl8list.com first (choose a handle)")
|
|
return user
|
|
|
|
|
|
def normalize_item(item):
|
|
"""Flatten a loader track item ({track, hasFullAccess, saveCount, ...}) into a DB row."""
|
|
return normalize_track(item["track"], {
|
|
"full_access": item.get("hasFullAccess"),
|
|
"saves": item.get("saveCount"),
|
|
"downloads": item.get("downloadCount"),
|
|
"purchases": item.get("purchaseCount"),
|
|
"comments": item.get("commentCount"),
|
|
})
|
|
|
|
|
|
def normalize_related(data):
|
|
"""The related loader returns bare tracks plus per-id maps for access and counts."""
|
|
counts = data.get("counts") or {}
|
|
access = data.get("access") or {}
|
|
rows = []
|
|
for t in data.get("tracks") or []:
|
|
k = str(t["id"])
|
|
rows.append(normalize_track(t, {
|
|
"full_access": access.get(k),
|
|
"saves": (counts.get("saveCount") or {}).get(k),
|
|
"downloads": (counts.get("downloadCount") or {}).get(k),
|
|
"purchases": (counts.get("purchaseCount") or {}).get(k),
|
|
"comments": (counts.get("commentCount") or {}).get(k),
|
|
}))
|
|
return [r for r in rows if r]
|
|
|
|
|
|
def normalize_track(t, extra):
|
|
user = t.get("user") or {}
|
|
if not user.get("handle") or t.get("isDeleted") or not t.get("isPublic", True):
|
|
return None
|
|
return {
|
|
"id": t["id"],
|
|
"handle": user["handle"],
|
|
"artist_name": user.get("name") or user["handle"],
|
|
"artist_img": user.get("img"),
|
|
"artist_verified": int(bool(user.get("isVerified"))),
|
|
"slug": t["slug"],
|
|
"title": t.get("title") or t.get("filename") or t["slug"],
|
|
"description": t.get("description"),
|
|
"bpm": t.get("bpm"),
|
|
"key": t.get("key"),
|
|
"duration": t.get("duration"),
|
|
"preview_duration": t.get("previewDuration"),
|
|
"price": t.get("price") or 0,
|
|
"created_at": t.get("createdAt"),
|
|
"upload_id": t.get("uploadId"),
|
|
"preview_upload_id": t.get("previewUploadId"),
|
|
"waveform": json.dumps(t.get("waveform")) if t.get("waveform") else None,
|
|
"preview_waveform": json.dumps(t.get("previewWaveform")) if t.get("previewWaveform") else None,
|
|
"full_access": int(bool(extra.get("full_access"))),
|
|
"saves": extra.get("saves") or 0,
|
|
"downloads": extra.get("downloads") or 0,
|
|
"purchases": extra.get("purchases") or 0,
|
|
"comments": extra.get("comments") or 0,
|
|
}
|