video search: availability-aware ranking (best-in-class, like music)

picks the most DOWNLOADABLE release, not just the biggest/fastest:
- peer_availability() mirrors the music side's scoring — free upload slot, tiered
  upload speed, graduated queue penalty.
- group_video_files now captures queueLength, picks the best PEER per release by
  (availability, then speed) instead of speed alone, and ranks hits by availability.
- _evaluate_hits sorts by (accepted, quality score, availability, size) so within a
  quality tier a free-slot/empty-queue source wins over one stuck behind a 1500-deep
  queue — fixes the downloads that sat at 0%.
queue/speed also surfaced on results for the UI. tested.
This commit is contained in:
BoulderBadgeDad 2026-06-26 10:54:56 -07:00
parent e84d1bcde5
commit 12cec4c042
3 changed files with 88 additions and 12 deletions

View file

@ -65,11 +65,17 @@ def _evaluate_hits(raw, profile, scope, want_season, want_episode) -> list:
size_gb = round((hit.get("size_bytes") or 0) / (1024 ** 3), 1) size_gb = round((hit.get("size_bytes") or 0) / (1024 ** 3), 1)
verdict = evaluate_release(parsed, profile, scope=scope, want_season=want_season, verdict = evaluate_release(parsed, profile, scope=scope, want_season=want_season,
want_episode=want_episode, size_gb=size_gb) want_episode=want_episode, size_gb=size_gb)
avail = hit.get("seeders") if hit.get("seeders") is not None else (hit.get("peers") or 0) # Availability = how downloadable the source is (slskd: free slot/queue/speed score
# from group_video_files; torrents/mock: seeders/peers). Ranks within a quality tier
# so we grab a free-slot/empty-queue release over one stuck behind a huge queue.
avail = hit.get("availability")
if avail is None:
avail = hit.get("seeders") if hit.get("seeders") is not None else (hit.get("peers") or 0)
results.append({ results.append({
"title": hit.get("title"), "size_gb": size_gb, "size_bytes": hit.get("size_bytes") or 0, "title": hit.get("title"), "size_gb": size_gb, "size_bytes": hit.get("size_bytes") or 0,
"seeders": hit.get("seeders"), "peers": hit.get("peers"), "seeders": hit.get("seeders"), "peers": hit.get("peers"),
"username": hit.get("username"), "slots": hit.get("slots"), "username": hit.get("username"), "slots": hit.get("slots"),
"queue": hit.get("queue"), "speed": hit.get("speed"),
"filename": hit.get("filename"), "_avail": avail, "filename": hit.get("filename"), "_avail": avail,
"quality_label": verdict["quality_label"], "accepted": verdict["accepted"], "quality_label": verdict["quality_label"], "accepted": verdict["accepted"],
"rejected": verdict["rejected"], "score": verdict["score"], "rejected": verdict["rejected"], "score": verdict["score"],
@ -78,7 +84,8 @@ def _evaluate_hits(raw, profile, scope, want_season, want_episode) -> list:
"audio": parsed.get("audio"), "group": parsed.get("group"), "audio": parsed.get("audio"), "group": parsed.get("group"),
"repack": parsed.get("repack") or parsed.get("proper"), "repack": parsed.get("repack") or parsed.get("proper"),
}) })
results.sort(key=lambda r: (r["accepted"], r["score"], r["_avail"]), reverse=True) # accepted first, then quality-profile score, then availability, then bigger file.
results.sort(key=lambda r: (r["accepted"], r["score"], r["_avail"], r["size_bytes"]), reverse=True)
for r in results: for r in results:
r.pop("_avail", None) r.pop("_avail", None)
return results[:40] return results[:40]

View file

@ -120,9 +120,38 @@ def _release_name(filename: str) -> str:
return base.rsplit(".", 1)[0] if "." in base else base return base.rsplit(".", 1)[0] if "." in base else base
def peer_availability(free_slots: Any, upload_speed: Any, queue_length: Any) -> float:
"""How DOWNLOADABLE a peer is right now (higher = grabs sooner). Mirrors the music
side's availability scoring: a free upload slot, the upload speed (tiered), and the
queue length (graduated penalty). A fast peer with a free slot and an empty queue beats
a faster one stuck behind a 1500-deep queue. Pure."""
try:
slots, speed, queue = int(free_slots or 0), int(upload_speed or 0), int(queue_length or 0)
except (TypeError, ValueError):
slots, speed, queue = 0, 0, 0
s = 0.05 if slots > 0 else -0.15
if speed >= 5_000_000:
s += 0.15
elif speed >= 1_000_000:
s += 0.10
elif speed >= 500_000:
s += 0.05
elif speed < 100_000:
s -= 0.05
if queue > 50:
s -= 0.25
elif queue > 20:
s -= 0.15
elif queue > 10:
s -= 0.10
return round(s, 4)
def group_video_files(responses: Any) -> list: def group_video_files(responses: Any) -> list:
"""Flatten slskd responses → one hit per release folder, with a peer count and the """Flatten slskd responses → one hit per release folder. The chosen source per release
best (fastest, most-available) source. Pure drives the unit tests.""" is the most DOWNLOADABLE peer (free slot low queue speed), not just the fastest, and
hits are ranked by that availability so we grab a free-slot/empty-queue release over a
high-spec one stuck behind a huge queue. Pure drives the unit tests."""
groups: dict = {} groups: dict = {}
for resp in (responses if isinstance(responses, list) else []): for resp in (responses if isinstance(responses, list) else []):
if not isinstance(resp, dict): if not isinstance(resp, dict):
@ -130,6 +159,8 @@ def group_video_files(responses: Any) -> list:
user = resp.get("username") user = resp.get("username")
speed = resp.get("uploadSpeed", 0) or 0 speed = resp.get("uploadSpeed", 0) or 0
slots = resp.get("freeUploadSlots", 0) or 0 slots = resp.get("freeUploadSlots", 0) or 0
queue = resp.get("queueLength", 0) or 0
avail = peer_availability(slots, speed, queue)
for f in (resp.get("files") or []): for f in (resp.get("files") or []):
fn = f.get("filename", "") fn = f.get("filename", "")
if not _is_video(fn): if not _is_video(fn):
@ -138,20 +169,23 @@ def group_video_files(responses: Any) -> list:
g = groups.get(rel) g = groups.get(rel)
if g is None: if g is None:
g = groups[rel] = {"title": rel, "size_bytes": 0, "users": set(), g = groups[rel] = {"title": rel, "size_bytes": 0, "users": set(),
"best_speed": -1, "username": None, "slots": 0, "filename": fn} "best": (-99.0, -1), "username": None, "slots": 0,
"queue": 0, "speed": 0, "availability": -99.0, "filename": fn}
g["size_bytes"] = max(g["size_bytes"], f.get("size", 0) or 0) g["size_bytes"] = max(g["size_bytes"], f.get("size", 0) or 0)
if user: if user:
g["users"].add(user) g["users"].add(user)
if speed > g["best_speed"]: if (avail, speed) > g["best"]: # most available, then fastest, peer wins
g["best_speed"] = speed g["best"] = (avail, speed)
g["username"] = user g["username"], g["slots"], g["queue"] = user, slots, queue
g["slots"] = slots g["speed"], g["availability"], g["filename"] = speed, avail, fn
g["filename"] = fn
out = [] out = []
for g in groups.values(): for g in groups.values():
out.append({"title": g["title"], "size_bytes": g["size_bytes"], "peers": len(g["users"]), out.append({"title": g["title"], "size_bytes": g["size_bytes"], "peers": len(g["users"]),
"username": g["username"], "slots": g["slots"], "filename": g["filename"]}) "username": g["username"], "slots": g["slots"], "queue": g["queue"],
out.sort(key=lambda h: (h["peers"], h["size_bytes"]), reverse=True) "speed": g["speed"], "availability": g["availability"], "filename": g["filename"]})
# availability first, then more peers, then bigger; the quality profile still gates the
# final pick downstream (_evaluate_hits), this just orders within a quality tier.
out.sort(key=lambda h: (h["availability"], h["peers"], h["size_bytes"]), reverse=True)
return out return out

View file

@ -81,3 +81,38 @@ def test_429_sets_a_cooldown():
nxt = _ss._reserve_search_slot() nxt = _ss._reserve_search_slot()
assert nxt >= time.monotonic() + 8 # next search waits out the cooldown assert nxt >= time.monotonic() + 8 # next search waits out the cooldown
_reset_throttle() _reset_throttle()
# ── availability-aware ranking (best-in-class peer/release selection) ─────────
def test_peer_availability_rewards_slot_speed_penalizes_queue():
from core.video.slskd_search import peer_availability
good = peer_availability(2, 6_000_000, 0) # free slot + fast + empty queue
stuck = peer_availability(0, 50_000, 1500) # no slot + slow + 1500-deep queue
assert good > stuck
assert peer_availability(1, 2_000_000, 0) > peer_availability(1, 2_000_000, 60) # queue hurts
assert peer_availability(1, 0, 0) > peer_availability(0, 0, 0) # slot helps
assert peer_availability(1, 6_000_000, 0) > peer_availability(1, 200_000, 0) # speed helps
def test_group_picks_most_downloadable_peer_not_just_fastest():
rel = "Movie.2014.1080p.BluRay.x264-GRP"
responses = [
{"username": "fast_stuck", "uploadSpeed": 9_000_000, "freeUploadSlots": 0,
"queueLength": 1500, "files": [{"filename": rel + "/m.mkv", "size": 2_000_000_000}]},
{"username": "free_now", "uploadSpeed": 800_000, "freeUploadSlots": 1,
"queueLength": 0, "files": [{"filename": rel + "/m.mkv", "size": 2_000_000_000}]},
]
h = group_video_files(responses)[0]
assert h["username"] == "free_now" # availability beats raw speed
assert h["queue"] == 0 and h["slots"] == 1
def test_group_ranks_available_release_above_queued_one():
responses = [
{"username": "q", "uploadSpeed": 9_000_000, "freeUploadSlots": 0, "queueLength": 1500,
"files": [{"filename": "A.2020.1080p.BluRay.x264-GRP/a.mkv", "size": 5_000_000_000}]},
{"username": "f", "uploadSpeed": 700_000, "freeUploadSlots": 1, "queueLength": 0,
"files": [{"filename": "B.2020.1080p.BluRay.x264-GRP/b.mkv", "size": 2_000_000_000}]},
]
hits = group_video_files(responses)
assert hits[0]["username"] == "f" # free-slot release first, despite smaller