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)
verdict = evaluate_release(parsed, profile, scope=scope, want_season=want_season,
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({
"title": hit.get("title"), "size_gb": size_gb, "size_bytes": hit.get("size_bytes") or 0,
"seeders": hit.get("seeders"), "peers": hit.get("peers"),
"username": hit.get("username"), "slots": hit.get("slots"),
"queue": hit.get("queue"), "speed": hit.get("speed"),
"filename": hit.get("filename"), "_avail": avail,
"quality_label": verdict["quality_label"], "accepted": verdict["accepted"],
"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"),
"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:
r.pop("_avail", None)
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
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:
"""Flatten slskd responses → one hit per release folder, with a peer count and the
best (fastest, most-available) source. Pure drives the unit tests."""
"""Flatten slskd responses → one hit per release folder. The chosen source per release
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 = {}
for resp in (responses if isinstance(responses, list) else []):
if not isinstance(resp, dict):
@ -130,6 +159,8 @@ def group_video_files(responses: Any) -> list:
user = resp.get("username")
speed = resp.get("uploadSpeed", 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 []):
fn = f.get("filename", "")
if not _is_video(fn):
@ -138,20 +169,23 @@ def group_video_files(responses: Any) -> list:
g = groups.get(rel)
if g is None:
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)
if user:
g["users"].add(user)
if speed > g["best_speed"]:
g["best_speed"] = speed
g["username"] = user
g["slots"] = slots
g["filename"] = fn
if (avail, speed) > g["best"]: # most available, then fastest, peer wins
g["best"] = (avail, speed)
g["username"], g["slots"], g["queue"] = user, slots, queue
g["speed"], g["availability"], g["filename"] = speed, avail, fn
out = []
for g in groups.values():
out.append({"title": g["title"], "size_bytes": g["size_bytes"], "peers": len(g["users"]),
"username": g["username"], "slots": g["slots"], "filename": g["filename"]})
out.sort(key=lambda h: (h["peers"], h["size_bytes"]), reverse=True)
"username": g["username"], "slots": g["slots"], "queue": g["queue"],
"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

View file

@ -81,3 +81,38 @@ def test_429_sets_a_cooldown():
nxt = _ss._reserve_search_slot()
assert nxt >= time.monotonic() + 8 # next search waits out the cooldown
_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