mirror of
https://github.com/narrrl/pl8list-rewind.git
synced 2026-11-03 16:41:26 +01:00
- Tap/click a track to play or pause, double-tap/double-click to like with a heart burst; any key or tap starts playback - Media Session API: lock-screen, notification and headphone controls, with next/previous working while the screen is locked - Drag the waveform to seek, hover shows the time - "Not for me" button feeds a dislike signal into the ranker - Share button (native share sheet or copy link) - Likes newest first, each playable right away - Filters remembered per browser, clear button, active indicator - Panels close on outside click or Esc; help overlay on "?" - Buffering spinner, paused badge, toast messages, first-run hint - Installable as an app (manifest and icons) - Refreshed README screenshots and controls
167 lines
6.0 KiB
Python
167 lines
6.0 KiB
Python
"""Pick the next batch of tracks for the scroll feed.
|
|
|
|
Each unserved playable track gets a score:
|
|
|
|
popularity log-scaled saves/downloads/purchases/comments, normalised to 0..1
|
|
freshness exp(-age / 21 days)
|
|
artist learned from your events on that artist's tracks (likes, full listens, skips)
|
|
tempo / key closeness to the BPM and key of tracks you liked or finished
|
|
related track came from the "related" page of something you liked
|
|
exploration Gumbel noise, so the feed never freezes into one niche
|
|
|
|
The batch is then picked greedily with a penalty for repeating an artist.
|
|
"""
|
|
|
|
import datetime
|
|
import json
|
|
import math
|
|
import random
|
|
from collections import defaultdict
|
|
|
|
WEIGHTS = {
|
|
"popularity": 1.2,
|
|
"freshness": 0.8,
|
|
"artist": 1.5,
|
|
"tempo": 1.0,
|
|
"key": 0.3,
|
|
"related": 1.0,
|
|
"unknown_artist": 0.2,
|
|
"exploration": 0.6,
|
|
}
|
|
# Per-event contributions to artist affinity.
|
|
EVENT_SIGNAL = {"like": 3.0, "unlike": -3.0, "complete": 1.5, "skip": -1.0, "dislike": -3.0}
|
|
REPEAT_ARTIST_PENALTY = 1.5
|
|
RECENT_ARTIST_WINDOW = 6
|
|
|
|
|
|
def _age_days(iso):
|
|
if not iso:
|
|
return 365.0
|
|
try:
|
|
t = datetime.datetime.fromisoformat(iso.replace("Z", "+00:00"))
|
|
except ValueError:
|
|
return 365.0
|
|
return max(0.0, (datetime.datetime.now(datetime.timezone.utc) - t).total_seconds() / 86400)
|
|
|
|
|
|
def _tempo_match(bpm, liked_bpms):
|
|
"""Gaussian closeness to any liked tempo, also counting half/double time as a match."""
|
|
if not bpm or not liked_bpms:
|
|
return 0.0
|
|
best = 0.0
|
|
for b in liked_bpms:
|
|
d = min(abs(bpm - b), abs(bpm * 2 - b), abs(bpm / 2 - b))
|
|
best = max(best, math.exp(-((d / 6.0) ** 2)))
|
|
return best
|
|
|
|
|
|
def taste(db, user_id):
|
|
"""Summarise a user's listening history into artist affinity, liked tempos/keys and liked track ids."""
|
|
rows = db.query("""
|
|
SELECT e.track_id, e.kind, e.seconds, t.handle, t.bpm, t.key
|
|
FROM events e JOIN tracks t ON t.id = e.track_id
|
|
WHERE e.user_id = ? AND e.kind IN ('like', 'unlike', 'complete', 'skip', 'dislike')
|
|
ORDER BY e.ts
|
|
""", (user_id,))
|
|
artist = defaultdict(float)
|
|
liked = {}
|
|
completed = {}
|
|
for r in rows:
|
|
artist[r["handle"]] += EVENT_SIGNAL[r["kind"]]
|
|
if r["kind"] == "like":
|
|
liked[r["track_id"]] = r
|
|
elif r["kind"] == "unlike":
|
|
liked.pop(r["track_id"], None)
|
|
elif r["kind"] == "complete":
|
|
completed[r["track_id"]] = r
|
|
positive = {**completed, **liked}
|
|
return {
|
|
"artist": {h: math.tanh(v / 4.0) for h, v in artist.items()},
|
|
"bpms": [r["bpm"] for r in positive.values() if r["bpm"]][-50:],
|
|
"keys": {r["key"] for r in positive.values() if r["key"]},
|
|
"positive_ids": set(positive),
|
|
"liked_ids": set(liked),
|
|
}
|
|
|
|
|
|
def pick(db, user_id, n=10, free_only=False, bpm_min=None, bpm_max=None):
|
|
where = ["(t.full_access=1 OR t.preview_upload_id IS NOT NULL)",
|
|
"NOT EXISTS (SELECT 1 FROM events e WHERE e.user_id=? AND e.kind='served' AND e.track_id=t.id)"]
|
|
args = [user_id]
|
|
if free_only:
|
|
where.append("t.price = 0")
|
|
if bpm_min is not None:
|
|
where.append("t.bpm >= ?")
|
|
args.append(bpm_min)
|
|
if bpm_max is not None:
|
|
where.append("t.bpm <= ?")
|
|
args.append(bpm_max)
|
|
cands = db.query("SELECT * FROM tracks t WHERE " + " AND ".join(where), args)
|
|
if not cands:
|
|
return []
|
|
|
|
t = taste(db, user_id)
|
|
recent = [r["handle"] for r in db.query("""
|
|
SELECT t.handle FROM events e JOIN tracks t ON t.id=e.track_id
|
|
WHERE e.user_id=? AND e.kind='served' ORDER BY e.ts DESC LIMIT ?
|
|
""", (user_id, RECENT_ARTIST_WINDOW))]
|
|
|
|
def pop_raw(r):
|
|
return (math.log1p(r["saves"]) + 0.7 * math.log1p(r["downloads"])
|
|
+ 1.0 * math.log1p(r["purchases"]) + 0.5 * math.log1p(r["comments"]))
|
|
|
|
max_pop = max(pop_raw(r) for r in cands) or 1.0
|
|
w = WEIGHTS
|
|
scored = []
|
|
for r in cands:
|
|
aff = t["artist"].get(r["handle"])
|
|
s = (w["popularity"] * pop_raw(r) / max_pop
|
|
+ w["freshness"] * math.exp(-_age_days(r["created_at"]) / 21.0)
|
|
+ w["artist"] * (aff or 0.0)
|
|
+ w["tempo"] * _tempo_match(r["bpm"], t["bpms"])
|
|
+ w["key"] * (1.0 if r["key"] in t["keys"] else 0.0)
|
|
+ w["related"] * (1.0 if r["related_of"] in t["positive_ids"] else 0.0)
|
|
+ w["unknown_artist"] * (1.0 if aff is None else 0.0)
|
|
+ w["exploration"] * -math.log(-math.log(random.random() or 1e-12)))
|
|
scored.append([s, r])
|
|
|
|
out = []
|
|
seen_artists = list(recent)
|
|
scored.sort(key=lambda x: -x[0])
|
|
pool = scored[: max(n * 8, 60)]
|
|
while pool and len(out) < n:
|
|
best_i = max(range(len(pool)),
|
|
key=lambda i: pool[i][0] - REPEAT_ARTIST_PENALTY * seen_artists.count(pool[i][1]["handle"]))
|
|
s, r = pool.pop(best_i)
|
|
out.append(serialize(r, s))
|
|
seen_artists.append(r["handle"])
|
|
return out
|
|
|
|
|
|
def serialize(r, score=None):
|
|
preview = not r["full_access"]
|
|
wave = r["preview_waveform"] if preview and r["preview_waveform"] else r["waveform"]
|
|
return {
|
|
"id": r["id"],
|
|
"handle": r["handle"],
|
|
"slug": r["slug"],
|
|
"title": r["title"],
|
|
"description": r["description"],
|
|
"artist_name": r["artist_name"],
|
|
"artist_img": r["artist_img"],
|
|
"artist_verified": bool(r["artist_verified"]),
|
|
"bpm": r["bpm"],
|
|
"key": r["key"],
|
|
"duration": r["preview_duration"] if preview and r["preview_duration"] else r["duration"],
|
|
"price": r["price"],
|
|
"created_at": r["created_at"],
|
|
"saves": r["saves"],
|
|
"downloads": r["downloads"],
|
|
"purchases": r["purchases"],
|
|
"comments": r["comments"],
|
|
"preview": preview,
|
|
"upload_id": r["preview_upload_id"] if preview else r["upload_id"],
|
|
"waveform": json.loads(wave) if wave else None,
|
|
"score": round(score, 3) if score is not None else None,
|
|
}
|