Recover blocked searches, fall through to the next peer, rebuild the UI
Measured against a live instance: 47 failed jobs against 33 imported. Three independent causes, all of which ended the request. The Soulseek server filters some artist names. "Michael Jackson Billie Jean" and bare "Michael Jackson" both return zero responses; "Billie Jean" returns 1083 files. The Weeknd, Kanye West and Frank Ocean behave the same way. Since pick_and_enqueue sent exactly one query of `artist title`, every request for a filtered artist failed reporting "searched 0 files" -- 28 of 29 occurrences. search_queries() now walks a ladder, loosest last, ending at the bare title, and escalates only when a search underdelivers so the common case still costs one search. Dropping the artist from the query does not mean ignoring it: match_ratio() looks for it in the folder path, where an organised share puts it. A rejected transfer ended the request. pick_and_enqueue kept a running maximum and discarded every runner-up, so "Transfer rejected: File not shared." -- a per-file condition where candidate two almost always works -- was terminal, as were timeouts and slskd's 500 on an unreachable peer. The ranked list is now persisted on the job row, ordered to put a different peer next, and try_next_candidate() advances through it. A job fails only once the list is exhausted, and then reports every peer it tried and what each one said. norm() destroyed every non-ASCII character, so "Beyonce" never matched Beyonce, nor Bjork Bjork, nor Dont Don't. It now folds accents and drops apostrophes while leaving & as a separator. Also: availability outranks format in score(), because a FLAC behind 5000 queued users used to beat an immediately available MP3; transfers that stall or that slskd forgets are abandoned instead of sitting in `downloading` forever; searches are deleted only once finished, since deleting one mid-flight makes slskd throw an optimistic-concurrency error and lose the record; a failed search POST is retried rather than failing the job on one blip. The UI moves to a sidebar shell -- rail at tablet, bottom bar on phones -- with neutral chrome and the sampled artwork colour confined to hero areas rather than repainting the whole interface. Adds URL routing, a primitive set under components/ui, and a request sheet showing the attempt ladder. Fixes hover:bg-glass-hi, which was never a valid class, so track rows and the search field had no hover or focus state at all; a reduced-motion path that could strand elements at opacity 0; the login page's hardcoded palette; and the missing 404 and error boundaries. tests/test_acquisition.py covers this path against a faked slskd. It had none. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0dca779f59
commit
e1b7b69261
47 files changed
+2939
-805
No files matched your search
@@ -26,13 +26,16 @@ file on disk, two names — the same trick the *arrs use.
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import collections
|
||||
import difflib
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import sqlite3
|
||||
import unicodedata
|
||||
import uuid
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timedelta, timezone
|
||||
@@ -42,7 +45,7 @@ from urllib.parse import urlencode
|
||||
import httpx
|
||||
from fastapi import FastAPI, HTTPException, Query, Request
|
||||
from fastapi.responses import (FileResponse, HTMLResponse, JSONResponse,
|
||||
RedirectResponse)
|
||||
RedirectResponse, Response)
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel
|
||||
|
||||
@@ -68,6 +71,35 @@ FORMATS = [f.strip() for f in os.getenv("MUSE_FORMATS", "flac,mp3").split(",") i
|
||||
# so this compares against a normalised form, not the raw string.
|
||||
MIN_MATCH = float(os.getenv("MUSE_MIN_MATCH", "0.45"))
|
||||
|
||||
# The title alone must clear this before the artist is even consulted. Without
|
||||
# a floor of its own, a file by the right artist scored well enough on the
|
||||
# combined ratio to be fetched no matter which of their songs it actually was.
|
||||
TITLE_FLOOR = float(os.getenv("MUSE_TITLE_FLOOR", "0.5"))
|
||||
|
||||
# How many ranked candidates to keep per job. When a transfer fails, the next
|
||||
# one is tried instead of failing the request -- "File not shared." is a
|
||||
# per-file condition, so candidate two usually just works.
|
||||
CANDIDATES = int(os.getenv("MUSE_CANDIDATES", "8"))
|
||||
MAX_ATTEMPTS = int(os.getenv("MUSE_MAX_ATTEMPTS", "5"))
|
||||
|
||||
# A download that has moved no bytes for this long is not going to. The peer
|
||||
# queued it and vanished, or slskd lost the transfer; either way the job used
|
||||
# to sit in `downloading` forever behind a cheerful pulsing dot.
|
||||
STALL_MINUTES = float(os.getenv("MUSE_STALL_MINUTES", "20"))
|
||||
|
||||
# Query variants to try before giving up. Each costs a real Soulseek search,
|
||||
# so this escalates only when a search underdelivers -- see search_queries().
|
||||
MAX_QUERIES = int(os.getenv("MUSE_MAX_QUERIES", "4"))
|
||||
MIN_CANDIDATES = int(os.getenv("MUSE_MIN_CANDIDATES", "3"))
|
||||
|
||||
# Issuing a search is a POST that occasionally blips. Nine production failures
|
||||
# were one slskd hiccup during a bulk import, every queued job dying inside the
|
||||
# same second, each one permanently.
|
||||
SEARCH_RETRIES = int(os.getenv("MUSE_SEARCH_RETRIES", "3"))
|
||||
|
||||
# Soulseek results trickle in: what you have at t=5s is much worse than t=15s.
|
||||
SEARCH_POLLS = int(os.getenv("MUSE_SEARCH_POLLS", "20"))
|
||||
|
||||
# How many Soulseek searches may run at once.
|
||||
#
|
||||
# This is a hard cap, not a suggestion. Each search takes 10-40s of polling
|
||||
@@ -177,7 +209,8 @@ GOOGLE_API = "https://openidconnect.googleapis.com/v1/userinfo"
|
||||
|
||||
# Paths reachable without a session. Deliberately tiny: /healthz reports a bare
|
||||
# ok so the container healthcheck works without leaking configuration.
|
||||
PUBLIC_PATHS = {"/healthz", "/auth/login", "/auth/callback", "/auth/logout"}
|
||||
PUBLIC_PATHS = {"/healthz", "/auth/login", "/auth/callback", "/auth/logout",
|
||||
"/login.css"}
|
||||
|
||||
ROLES = ("admin", "user", "guest")
|
||||
|
||||
@@ -334,6 +367,28 @@ SAFE = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
|
||||
NOISE = re.compile(r"[^a-z0-9]+")
|
||||
YEAR = re.compile(r"^\d{4}$")
|
||||
|
||||
# Apostrophes vanish rather than becoming a separator, so "Don't" normalises
|
||||
# onto a filename's "Dont". Everything else non-alphanumeric becomes a space,
|
||||
# so "R&B" stays two tokens instead of collapsing to the unrelated word "rb".
|
||||
QUOTES = re.compile("['\u2018\u2019\u02bc\u0060\"\u201c\u201d]")
|
||||
|
||||
# Letters NFKD leaves alone because they are atomic rather than base + accent.
|
||||
FOLD = str.maketrans({"\u00f8": "o", "\u00e6": "ae", "\u0153": "oe",
|
||||
"\u00f0": "d", "\u00fe": "th", "\u0142": "l",
|
||||
"\u00df": "ss", "\u0111": "d", "\u0131": "i"})
|
||||
|
||||
# A leading track number: "02 - ", "02.", "A1 ". Anchored and bounded so it
|
||||
# cannot eat a title that merely opens with a digit ("1979", "99 Problems").
|
||||
TRACKNO = re.compile(r"^\s*(?:[a-d]?\d{1,3})\s*(?:[-._)\]]\s*)+", re.I)
|
||||
|
||||
# Technical release tags. Deliberately NOT "live", "remix" or "acoustic":
|
||||
# confidence() reads those to decide whether this is the recording that was
|
||||
# asked for, and stripping them here would blind it.
|
||||
JUNK = re.compile(
|
||||
r"[\[({]\s*(?:\d{2,4}\s*k(?:bps)?|v\d|web|cd\s*\d*|vinyl|flac|mp3|"
|
||||
r"lossless|hi-?res|\d{2,3}\s*bit|\d{2}\s*khz)[^\])}]*[\])}]"
|
||||
r"|\b(?:\d{3}\s*kbps|web-?dl|cdrip|vbr|cbr)\b", re.I)
|
||||
|
||||
# Most filesystems cap a path component at 255 bytes. Track titles can be far
|
||||
# longer than that once a stranger has appended their own notes, and the
|
||||
# resulting ENAMETOOLONG would fail the import after the download had finished.
|
||||
@@ -365,7 +420,71 @@ def safe_year(year) -> str | None:
|
||||
|
||||
|
||||
def norm(s: str) -> str:
|
||||
return NOISE.sub(" ", s.lower()).strip()
|
||||
"""Lowercase, fold accents away, and reduce to alphanumeric tokens.
|
||||
|
||||
The accent fold is load-bearing. Deezer returns "Beyonc\u00e9", "Bj\u00f6rk" and
|
||||
"Sigur R\u00f3s"; the strangers who named the files on Soulseek wrote
|
||||
"Beyonce", "Bjork", "Sigur Ros". Without it the accented character is not
|
||||
in [a-z0-9] and became a *space*, so "beyonce" was compared against
|
||||
"beyonc " -- a containment miss and a mediocre ratio, on a track that was
|
||||
sitting right there in the results.
|
||||
"""
|
||||
s = unicodedata.normalize("NFKD", str(s).lower().translate(FOLD))
|
||||
s = "".join(c for c in s if not unicodedata.combining(c))
|
||||
return NOISE.sub(" ", QUOTES.sub("", s)).strip()
|
||||
|
||||
|
||||
def bare_name(remote_path: str) -> str:
|
||||
"""A remote filename with extension, track number and release tags gone.
|
||||
|
||||
All of that is decoration a peer added that says nothing about which
|
||||
recording the file holds. Leaving it in drags the similarity ratio down on
|
||||
files that are in fact exactly right.
|
||||
"""
|
||||
base = remote_path.replace("\\", "/").rsplit("/", 1)[-1]
|
||||
if "." in base:
|
||||
base = base.rsplit(".", 1)[0]
|
||||
return JUNK.sub(" ", TRACKNO.sub("", base)).strip()
|
||||
|
||||
|
||||
def similarity(want: str, got: str) -> float:
|
||||
"""Best of a sequence ratio and a token-coverage ratio, 0..1.
|
||||
|
||||
difflib alone is order-sensitive: "creep radiohead" against "radiohead
|
||||
creep" scores far below what a human would call a match, and Soulseek
|
||||
filenames put the fields in whatever order the ripper felt like. The token
|
||||
measure is order-free, so taking the better of the two absorbs reordering
|
||||
and interleaved junk without having to loosen MIN_MATCH.
|
||||
"""
|
||||
if not want or not got:
|
||||
return 0.0
|
||||
seq = difflib.SequenceMatcher(None, want, got).ratio()
|
||||
a, b = set(want.split()), set(got.split())
|
||||
return max(seq, len(a & b) / len(a) if a else 0.0)
|
||||
|
||||
|
||||
def match_ratio(remote_path: str, artist: str, title: str) -> float:
|
||||
"""How well a remote path matches the track asked for, 0..1.
|
||||
|
||||
Title and artist are measured separately and the title has to stand on its
|
||||
own. A single ratio over the blob "artist title" let a strong artist match
|
||||
carry a weak title -- the same failure the Deezer ranking keeps a dedicated
|
||||
term for, where `Oasis - Live Forever` once resolved to `Cloudburst`
|
||||
because the artist agreed and nothing else had to.
|
||||
|
||||
The artist is looked for in the whole path rather than the filename: a file
|
||||
at `Radiohead/Pablo Honey/02 Creep.flac` names its artist in the folder,
|
||||
which is the commonest well-organised case there is.
|
||||
"""
|
||||
stem = norm(bare_name(remote_path))
|
||||
path = norm(remote_path.replace("\\", "/"))
|
||||
nt, na = norm(title), norm(artist)
|
||||
|
||||
t = 1.0 if (nt and nt in stem) else similarity(nt, stem)
|
||||
if t < TITLE_FLOOR:
|
||||
return 0.0 # wrong song; a matching artist must not rescue it
|
||||
a = 1.0 if (na and na in path) else similarity(na, path)
|
||||
return 0.75 * t + 0.25 * a
|
||||
|
||||
|
||||
# ─────────────────────────── storage ───────────────────────────
|
||||
@@ -382,7 +501,7 @@ def init_db():
|
||||
c.execute("""
|
||||
CREATE TABLE IF NOT EXISTS jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
state TEXT NOT NULL, -- searching|queued|downloading|imported|failed
|
||||
state TEXT NOT NULL, -- queued|searching|downloading|review|imported|failed
|
||||
detail TEXT,
|
||||
artist TEXT, title TEXT, album TEXT,
|
||||
year TEXT, track INTEGER,
|
||||
@@ -426,7 +545,15 @@ def init_db():
|
||||
("duration", "INTEGER"),
|
||||
("confidence", "REAL"),
|
||||
("match_why", "TEXT"),
|
||||
("reviewed_by", "TEXT")):
|
||||
("reviewed_by", "TEXT"),
|
||||
# The candidate ladder. Keeping the runners-up on the
|
||||
# row is what lets a rejected transfer fall through to
|
||||
# the next peer minutes later, in a different task,
|
||||
# after the search result set is long gone.
|
||||
("candidates", "TEXT"),
|
||||
("tried", "TEXT"),
|
||||
("attempts", "INTEGER NOT NULL DEFAULT 0"),
|
||||
("started_at", "TEXT")):
|
||||
if col not in cols:
|
||||
c.execute(f"ALTER TABLE jobs ADD COLUMN {col} {decl}")
|
||||
ucols = {r["name"] for r in c.execute("PRAGMA table_info(users)").fetchall()}
|
||||
@@ -521,15 +648,14 @@ def confidence(filename: str, artist: str, title: str,
|
||||
Returns the score and the human-readable reasons behind it, because a
|
||||
number alone gives the reviewer nothing to act on.
|
||||
"""
|
||||
base = filename.replace("\\", "/").rsplit("/", 1)[-1]
|
||||
stem = norm(base.rsplit(".", 1)[0])
|
||||
stem = norm(bare_name(filename))
|
||||
path = norm(filename.replace("\\", "/"))
|
||||
na, nt = norm(artist), norm(title)
|
||||
why: list[str] = []
|
||||
|
||||
# Title is the load-bearing signal: a wrong title is a wrong song, whereas
|
||||
# a missing artist is routine in files organised into per-artist folders.
|
||||
t_ratio = difflib.SequenceMatcher(None, nt, stem).ratio()
|
||||
t_ratio = similarity(nt, stem)
|
||||
if nt and nt in stem:
|
||||
t_score, t_ratio = 1.0, max(t_ratio, 1.0)
|
||||
why.append("title matches")
|
||||
@@ -587,104 +713,310 @@ AUTO_CONFIDENCE = float(os.getenv("MUSE_AUTO_CONFIDENCE", "0.95"))
|
||||
REVIEW_CONFIDENCE = float(os.getenv("MUSE_REVIEW_CONFIDENCE", "0.70"))
|
||||
|
||||
|
||||
def score(candidate: dict, peer: dict, want: str) -> float | None:
|
||||
def score(candidate: dict, peer: dict, artist: str, title: str) -> float | None:
|
||||
"""Rank one remote file. None = reject.
|
||||
|
||||
Ordering intent: correct format first, then actually-gettable (a peer with
|
||||
a free slot and a short queue beats a marginally better file behind 200
|
||||
people), then filename plausibility, then bitrate.
|
||||
Ordering intent: actually-gettable first, then correct format, then
|
||||
filename plausibility, then bitrate.
|
||||
|
||||
Availability leads deliberately. It used to sit *below* format -- 100
|
||||
points per format rank against 60 for a free upload slot -- which meant a
|
||||
FLAC behind two hundred queued people always beat an MP3 available right
|
||||
now. Whether the peer can serve you is the single most predictive signal
|
||||
for whether the job ever finishes, so it outranks the preference for a
|
||||
better file. Format is the tiebreak within an availability tier.
|
||||
"""
|
||||
ext = ext_of(candidate["filename"])
|
||||
if ext not in FORMATS:
|
||||
return None
|
||||
|
||||
base = candidate["filename"].replace("\\", "/").rsplit("/", 1)[-1]
|
||||
ratio = difflib.SequenceMatcher(None, norm(base), want).ratio()
|
||||
# A short title inside a long scene filename scores badly on raw ratio, so
|
||||
# give credit for simple containment too.
|
||||
if want in norm(base):
|
||||
ratio = max(ratio, 0.9)
|
||||
ratio = match_ratio(candidate["filename"], artist, title)
|
||||
if ratio < MIN_MATCH:
|
||||
return None
|
||||
|
||||
s = 0.0
|
||||
s += (len(FORMATS) - FORMATS.index(ext)) * 100 # format preference
|
||||
s += 60 if peer.get("hasFreeUploadSlot") else 0
|
||||
s += max(0, 40 - peer.get("queueLength", 0)) # short queues
|
||||
s += min(peer.get("uploadSpeed", 0) / 50_000, 20) # capped speed bonus
|
||||
s += ratio * 50
|
||||
s += 220 if peer.get("hasFreeUploadSlot") else 0
|
||||
# Unsaturated: the old `max(0, 40 - queueLength)` scored a 40-deep queue
|
||||
# and a 5000-deep one identically, which is the difference between waiting
|
||||
# a minute and never being served at all.
|
||||
s += 120 / (1 + peer.get("queueLength", 0) / 8)
|
||||
s += min(peer.get("uploadSpeed", 0) / 50_000, 20)
|
||||
s += (len(FORMATS) - FORMATS.index(ext)) * 60 # format preference
|
||||
s += ratio * 90
|
||||
s += min(candidate.get("bitRate") or 0, 320) / 32 # 0-10
|
||||
return s
|
||||
|
||||
|
||||
async def pick_and_enqueue(job_id: str, artist: str, title: str,
|
||||
album: str | None = None):
|
||||
"""Search slskd for one track and queue the best candidate."""
|
||||
want = norm(f"{artist} {title}")
|
||||
PAREN = re.compile(r"[\[(\{][^\])}]*[\])}]")
|
||||
FEAT = re.compile(r"\b(?:feat|ft|featuring)\b\.?.*$", re.I)
|
||||
ARTICLE = re.compile(r"^(?:the|a|an|los|las|les)\s+", re.I)
|
||||
|
||||
|
||||
class SearchUnavailable(Exception):
|
||||
"""slskd could not accept a search. Transient, not a bad request."""
|
||||
|
||||
|
||||
def search_queries(artist: str, title: str) -> list[str]:
|
||||
"""Query variants to try, loosest last.
|
||||
|
||||
The Soulseek server filters searches containing certain artist names. That
|
||||
is measured, not theorised: on this network "Michael Jackson Billie Jean"
|
||||
and bare "Michael Jackson" both return zero responses, while "Billie Jean"
|
||||
returns 1083 files. Sending only `artist title` -- all this used to do --
|
||||
means every request for a filtered artist dies reporting "searched 0
|
||||
files", and that is exactly the roster of names in the failed jobs:
|
||||
Michael Jackson, The Weeknd, Kanye West, Frank Ocean.
|
||||
|
||||
So the ladder ends at the title alone, and match_ratio() then recognises
|
||||
the artist from the folder path instead of from the query. The middle
|
||||
rungs handle the ordinary case where Deezer's metadata is simply more
|
||||
decorated than the filename a stranger typed.
|
||||
"""
|
||||
out: list[str] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
def add(*parts: str) -> None:
|
||||
q = " ".join(p for p in parts if p).strip()
|
||||
# Dedupe on the normalised form, so "Creep (Remastered)" and "Creep"
|
||||
# are recognised as one query once the punctuation is gone.
|
||||
k = norm(q)
|
||||
if q and k and k not in seen:
|
||||
seen.add(k)
|
||||
out.append(q)
|
||||
|
||||
plain_t = PAREN.sub(" ", title).strip() or title
|
||||
lean_t = FEAT.sub(" ", plain_t).strip() or plain_t
|
||||
head_t = lean_t.split("/")[0].strip() or lean_t # "A / B" -> "A"
|
||||
lean_a = ARTICLE.sub("", FEAT.sub(" ", artist).strip()).strip()
|
||||
|
||||
add(artist, title) # what was sent before, and still best when it works
|
||||
add(artist, lean_t) # decoration and featuring credits gone
|
||||
add(lean_a, head_t) # leading article gone, split title
|
||||
add(head_t) # title alone -- this is the rung that beats the filter
|
||||
add(lean_t)
|
||||
return out[:MAX_QUERIES]
|
||||
|
||||
|
||||
async def run_search(sc: httpx.AsyncClient, text: str) -> dict:
|
||||
"""Issue one Soulseek search and poll it to completion.
|
||||
|
||||
The search is deleted only once it has actually finished. Deleting one
|
||||
still InProgress makes slskd throw an optimistic-concurrency error inside
|
||||
its own SearchService and lose the record; the old code deleted
|
||||
unconditionally after its 40s budget, so every slow search corrupted a row.
|
||||
"""
|
||||
search_id = str(uuid.uuid4())
|
||||
async with slskd_client() as sc:
|
||||
for attempt in range(SEARCH_RETRIES):
|
||||
try:
|
||||
r = await sc.post("/api/v0/searches",
|
||||
json={"id": search_id, "searchText": f"{artist} {title}"})
|
||||
json={"id": search_id, "searchText": text})
|
||||
r.raise_for_status()
|
||||
break
|
||||
except Exception as e:
|
||||
set_state(job_id, "failed", f"slskd search failed: {e}")
|
||||
return
|
||||
if attempt == SEARCH_RETRIES - 1:
|
||||
raise SearchUnavailable(errstr(e)) from e
|
||||
await asyncio.sleep(2 * (attempt + 1))
|
||||
|
||||
# Poll until slskd calls it done, or we run out of patience. Soulseek
|
||||
# searches trickle in; results at t=5s are much worse than at t=15s.
|
||||
results = {}
|
||||
for _ in range(20):
|
||||
await asyncio.sleep(2)
|
||||
try:
|
||||
rr = await sc.get(f"/api/v0/searches/{search_id}",
|
||||
params={"includeResponses": "true"})
|
||||
rr.raise_for_status()
|
||||
results = rr.json()
|
||||
except Exception:
|
||||
continue
|
||||
if str(results.get("state", "")).startswith("Completed"):
|
||||
break
|
||||
results: dict = {}
|
||||
finished = False
|
||||
for _ in range(SEARCH_POLLS):
|
||||
await asyncio.sleep(2)
|
||||
try:
|
||||
rr = await sc.get(f"/api/v0/searches/{search_id}",
|
||||
params={"includeResponses": "true"})
|
||||
rr.raise_for_status()
|
||||
results = rr.json()
|
||||
except Exception as e:
|
||||
# Swallowed silently before, which made a broken slskd look
|
||||
# identical to a track nobody happens to share.
|
||||
log.debug("poll %r: %s", text, errstr(e))
|
||||
continue
|
||||
if str(results.get("state", "")).startswith("Completed"):
|
||||
finished = True
|
||||
break
|
||||
|
||||
best, best_score = None, -1.0
|
||||
for peer in results.get("responses", []):
|
||||
for f in peer.get("files", []):
|
||||
if f.get("isLocked"):
|
||||
continue
|
||||
sc_ = score(f, peer, want)
|
||||
if sc_ is not None and sc_ > best_score:
|
||||
best, best_score = (peer, f), sc_
|
||||
|
||||
# Tidy up so slskd's search history doesn't grow without bound.
|
||||
if finished:
|
||||
try:
|
||||
await sc.delete(f"/api/v0/searches/{search_id}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not best:
|
||||
set_state(job_id, "failed",
|
||||
f"no {'/'.join(FORMATS)} candidate matched (searched {results.get('fileCount', 0)} files)")
|
||||
return
|
||||
|
||||
peer, f = best
|
||||
try:
|
||||
er = await sc.post(f"/api/v0/transfers/downloads/{peer['username']}",
|
||||
json=[{"filename": f["filename"], "size": f["size"]}])
|
||||
er.raise_for_status()
|
||||
except Exception as e:
|
||||
set_state(job_id, "failed", f"enqueue failed: {e}")
|
||||
return
|
||||
log.debug("cleanup %r: %s", text, errstr(e))
|
||||
else:
|
||||
log.info("search %r unfinished after %ds, leaving it for slskd to close",
|
||||
text, SEARCH_POLLS * 2)
|
||||
return results
|
||||
|
||||
# Confidence is recorded now, while the candidate list is still in
|
||||
# hand, but it gates the IMPORT rather than the download. Holding the
|
||||
# transfer instead would mean the peer is often gone by the time
|
||||
# someone reviews it; this way the file is already on disk, approving
|
||||
# is instant, and rejecting is a delete.
|
||||
conf, why = confidence(f["filename"], artist, title, album)
|
||||
|
||||
def spread_by_peer(ranked: list[tuple[float, dict, dict]]) -> list[dict]:
|
||||
"""Order candidates so consecutive attempts land on different peers.
|
||||
|
||||
Straight score order routinely stacks three files from one well-stocked
|
||||
user at the top; if that user is offline all three attempts burn on the
|
||||
same dead connection. Peers arrive best-first, so round-robining them keeps
|
||||
the strongest candidate first and still guarantees variety behind it.
|
||||
"""
|
||||
by_peer: dict[str, list[dict]] = {}
|
||||
for s, peer, f in ranked:
|
||||
by_peer.setdefault(peer["username"], []).append({
|
||||
"username": peer["username"],
|
||||
"filename": f["filename"],
|
||||
"size": f.get("size") or 0,
|
||||
"score": round(s, 1),
|
||||
})
|
||||
queues = list(by_peer.values())
|
||||
out: list[dict] = []
|
||||
while queues:
|
||||
for q in list(queues):
|
||||
out.append(q.pop(0))
|
||||
if not q:
|
||||
queues.remove(q)
|
||||
return out
|
||||
|
||||
|
||||
def no_candidate_detail(seen: int, stats: "collections.Counter",
|
||||
queries: list[str]) -> str:
|
||||
"""Say which filter emptied the results, not merely that they are empty.
|
||||
|
||||
One message used to cover four unrelated situations, and 28 of 29 real
|
||||
occurrences turned out to be the search itself coming back empty -- which
|
||||
is now stated outright instead of being reported as a matching failure.
|
||||
"""
|
||||
if seen == 0:
|
||||
tried = ", ".join(repr(q) for q in queries)
|
||||
word = "y" if len(queries) == 1 else "ies"
|
||||
return f"Soulseek returned nothing for {len(queries)} quer{word}: {tried}"
|
||||
bits = []
|
||||
if stats["format"]:
|
||||
bits.append(f"{stats['format']} not {'/'.join(FORMATS)}")
|
||||
if stats["weak"]:
|
||||
bits.append(f"{stats['weak']} did not match the title")
|
||||
if stats["locked"]:
|
||||
bits.append(f"{stats['locked']} locked")
|
||||
return f"{seen} files found, none usable: " + ", ".join(bits)
|
||||
|
||||
|
||||
def exhausted_detail(tried: list[dict], attempts: int) -> str:
|
||||
"""Summarise the whole ladder instead of overwriting it with the last error."""
|
||||
if not tried:
|
||||
return "no candidate could be enqueued"
|
||||
reasons = collections.Counter(t["reason"].split(":")[0] for t in tried)
|
||||
plural = "" if attempts == 1 else "s"
|
||||
joined = ", ".join(f"{n}x {r}" for r, n in reasons.most_common(3))
|
||||
return f"tried {attempts} peer{plural}: {joined}"
|
||||
|
||||
|
||||
async def try_next_candidate(job_id: str, artist: str, title: str,
|
||||
album: str | None = None) -> bool:
|
||||
"""Enqueue the next-best candidate. False once the ladder is exhausted.
|
||||
|
||||
This is the difference between a request that survives one bad peer and one
|
||||
that does not. "Transfer rejected: File not shared." is a *per-file*
|
||||
condition -- the peer's share index is stale -- so candidate two almost
|
||||
always works, and slskd's 500 on an unreachable peer is the same shape.
|
||||
"""
|
||||
with db() as c:
|
||||
row = c.execute(
|
||||
"SELECT candidates, tried, attempts FROM jobs WHERE id=?",
|
||||
(job_id,)).fetchone()
|
||||
if not row:
|
||||
return False
|
||||
cands = json.loads(row["candidates"] or "[]")
|
||||
tried = json.loads(row["tried"] or "[]")
|
||||
attempts = row["attempts"] or 0
|
||||
|
||||
while cands and attempts < MAX_ATTEMPTS:
|
||||
cand = cands.pop(0)
|
||||
attempts += 1
|
||||
try:
|
||||
async with slskd_client() as sc:
|
||||
er = await sc.post(
|
||||
f"/api/v0/transfers/downloads/{cand['username']}",
|
||||
json=[{"filename": cand["filename"],
|
||||
"size": cand["size"]}])
|
||||
er.raise_for_status()
|
||||
except Exception as e:
|
||||
tried.append({"username": cand["username"],
|
||||
"filename": cand["filename"],
|
||||
"reason": f"could not reach peer: {errstr(e)}"})
|
||||
continue
|
||||
|
||||
conf, why = confidence(cand["filename"], artist, title, album)
|
||||
with db() as c:
|
||||
c.execute(
|
||||
"UPDATE jobs SET candidates=?, tried=?, attempts=? WHERE id=?",
|
||||
(json.dumps(cands), json.dumps(tried), attempts, job_id))
|
||||
extra = f", attempt {attempts}" if attempts > 1 else ""
|
||||
set_state(job_id, "downloading",
|
||||
f"from {peer['username']} (score {best_score:.0f})",
|
||||
username=peer["username"], remote_file=f["filename"],
|
||||
size=f["size"], confidence=conf, match_why="; ".join(why))
|
||||
f"from {cand['username']} (score {cand['score']:.0f}{extra})",
|
||||
username=cand["username"], remote_file=cand["filename"],
|
||||
size=cand["size"], confidence=conf, match_why="; ".join(why),
|
||||
started_at=datetime.now(timezone.utc).isoformat())
|
||||
return True
|
||||
|
||||
with db() as c:
|
||||
c.execute("UPDATE jobs SET candidates=?, tried=?, attempts=? WHERE id=?",
|
||||
(json.dumps(cands), json.dumps(tried), attempts, job_id))
|
||||
set_state(job_id, "failed", exhausted_detail(tried, attempts))
|
||||
return False
|
||||
|
||||
|
||||
async def pick_and_enqueue(job_id: str, artist: str, title: str,
|
||||
album: str | None = None):
|
||||
"""Search Soulseek for one track and queue the best candidate.
|
||||
|
||||
Builds a ranked candidate *list* rather than a single winner, so a peer
|
||||
that rejects, times out or cannot be reached costs one attempt instead of
|
||||
the entire request.
|
||||
"""
|
||||
queries = search_queries(artist, title)
|
||||
pool: dict[tuple[str, str], tuple[float, dict, dict]] = {}
|
||||
seen = 0
|
||||
stats: "collections.Counter" = collections.Counter()
|
||||
|
||||
async with slskd_client() as sc:
|
||||
for i, q in enumerate(queries):
|
||||
try:
|
||||
results = await run_search(sc, q)
|
||||
except SearchUnavailable as e:
|
||||
set_state(job_id, "failed",
|
||||
f"slskd would not accept a search: {e}")
|
||||
return
|
||||
|
||||
for peer in results.get("responses", []):
|
||||
for f in peer.get("files", []):
|
||||
seen += 1
|
||||
if f.get("isLocked"):
|
||||
stats["locked"] += 1
|
||||
continue
|
||||
if ext_of(f["filename"]) not in FORMATS:
|
||||
stats["format"] += 1
|
||||
continue
|
||||
sc_ = score(f, peer, artist, title)
|
||||
if sc_ is None:
|
||||
stats["weak"] += 1
|
||||
continue
|
||||
key = (peer["username"], f["filename"])
|
||||
if key not in pool or pool[key][0] < sc_:
|
||||
pool[key] = (sc_, peer, f)
|
||||
|
||||
log.info("job %s q%d/%d %r -> %d responses, %d files, %d candidates",
|
||||
job_id[:8], i + 1, len(queries), q,
|
||||
len(results.get("responses", [])),
|
||||
results.get("fileCount", 0), len(pool))
|
||||
# Escalate only on underdelivery: the common case stays one search,
|
||||
# which is what keeps a 50-track paste civil to slskd.
|
||||
if len(pool) >= MIN_CANDIDATES:
|
||||
break
|
||||
|
||||
if not pool:
|
||||
set_state(job_id, "failed", no_candidate_detail(seen, stats, queries))
|
||||
return
|
||||
|
||||
ranked = sorted(pool.values(), key=lambda r: -r[0])
|
||||
cands = spread_by_peer(ranked)[:CANDIDATES]
|
||||
with db() as c:
|
||||
c.execute("UPDATE jobs SET candidates=?, tried=?, attempts=0 WHERE id=?",
|
||||
(json.dumps(cands), "[]", job_id))
|
||||
await try_next_candidate(job_id, artist, title, album)
|
||||
|
||||
|
||||
# ─────────────────────────── email ───────────────────────────
|
||||
@@ -867,14 +1199,23 @@ def tag_and_file(job: sqlite3.Row, src: Path) -> Path:
|
||||
return dest
|
||||
|
||||
|
||||
def find_downloaded(remote_file: str) -> Path | None:
|
||||
"""Locate the completed file. slskd nests it under the remote's parent
|
||||
directory name, so match on basename rather than guessing the path."""
|
||||
base = remote_file.replace("\\", "/").rsplit("/", 1)[-1]
|
||||
for p in DOWNLOAD_DIR.rglob("*"):
|
||||
if p.is_file() and p.name == base:
|
||||
return p
|
||||
return None
|
||||
def downloaded_index() -> dict[str, Path]:
|
||||
"""Every completed file, by basename.
|
||||
|
||||
slskd nests a download under the remote's parent directory name, so the
|
||||
basename is the only reliable key. Built once per poll cycle and shared by
|
||||
every job in it: the previous version walked the whole tree separately for
|
||||
each in-flight job, every ten seconds, which on a large downloads
|
||||
directory is a lot of stat() calls to answer the same question.
|
||||
"""
|
||||
out: dict[str, Path] = {}
|
||||
try:
|
||||
for p in DOWNLOAD_DIR.rglob("*"):
|
||||
if p.is_file():
|
||||
out.setdefault(p.name, p)
|
||||
except OSError as e:
|
||||
log.warning("cannot walk %s: %s", DOWNLOAD_DIR, errstr(e))
|
||||
return out
|
||||
|
||||
|
||||
# ─────────────────────────── search queue ───────────────────────────
|
||||
@@ -903,8 +1244,64 @@ async def search_worker(n: int):
|
||||
work_q.task_done()
|
||||
|
||||
|
||||
def transfer_index(payload: list) -> dict[str, dict]:
|
||||
"""Flatten slskd's user -> directories -> files nesting.
|
||||
|
||||
Keeps the whole file object, not just its state. `exception` is where the
|
||||
literal "Transfer rejected: File not shared." lives, and discarding it was
|
||||
why a failed job could only ever say "transfer Completed, Rejected" with no
|
||||
reason a person could act on.
|
||||
"""
|
||||
out: dict[str, dict] = {}
|
||||
for user in payload:
|
||||
for d in user.get("directories", []):
|
||||
for f in d.get("files", []):
|
||||
out[f.get("filename", "")] = f
|
||||
return out
|
||||
|
||||
|
||||
def stalled_for(job: sqlite3.Row) -> float:
|
||||
"""Minutes since this transfer last had anything to show for itself."""
|
||||
started = job["started_at"] if "started_at" in job.keys() else None
|
||||
if not started:
|
||||
return 0.0
|
||||
try:
|
||||
t = datetime.fromisoformat(started)
|
||||
except ValueError:
|
||||
return 0.0
|
||||
if t.tzinfo is None:
|
||||
t = t.replace(tzinfo=timezone.utc)
|
||||
return (datetime.now(timezone.utc) - t).total_seconds() / 60.0
|
||||
|
||||
|
||||
async def abandon(job: sqlite3.Row, reason: str) -> None:
|
||||
"""Give up on the current peer and fall through to the next candidate."""
|
||||
with db() as c:
|
||||
row = c.execute("SELECT tried FROM jobs WHERE id=?",
|
||||
(job["id"],)).fetchone()
|
||||
tried = json.loads((row["tried"] if row else None) or "[]")
|
||||
tried.append({"username": job["username"], "filename": job["remote_file"],
|
||||
"reason": reason})
|
||||
with db() as c:
|
||||
c.execute("UPDATE jobs SET tried=? WHERE id=?",
|
||||
(json.dumps(tried), job["id"]))
|
||||
log.info("job %s giving up on %s: %s", job["id"][:8], job["username"], reason)
|
||||
await try_next_candidate(job["id"], job["artist"], job["title"], job["album"])
|
||||
|
||||
|
||||
async def cancel_transfer(username: str, filename: str) -> None:
|
||||
"""Ask slskd to drop a transfer we have stopped waiting for."""
|
||||
try:
|
||||
async with slskd_client(timeout=PROBE_TIMEOUT) as sc:
|
||||
await sc.delete(f"/api/v0/transfers/downloads/{username}",
|
||||
params={"filename": filename})
|
||||
except Exception as e:
|
||||
log.debug("cancel %s/%s: %s", username, filename, errstr(e))
|
||||
|
||||
|
||||
async def import_worker():
|
||||
"""Poll slskd for in-flight downloads and import the finished ones."""
|
||||
missing: collections.Counter = collections.Counter()
|
||||
while True:
|
||||
await asyncio.sleep(10)
|
||||
try:
|
||||
@@ -912,28 +1309,36 @@ async def import_worker():
|
||||
jobs = c.execute(
|
||||
"SELECT * FROM jobs WHERE state='downloading'").fetchall()
|
||||
if not jobs:
|
||||
missing.clear()
|
||||
continue
|
||||
|
||||
async with slskd_client() as sc:
|
||||
r = await sc.get("/api/v0/transfers/downloads")
|
||||
r.raise_for_status()
|
||||
payload = r.json()
|
||||
index = transfer_index(r.json())
|
||||
|
||||
# Flatten slskd's user -> directories -> files nesting.
|
||||
states: dict[str, str] = {}
|
||||
for user in payload:
|
||||
for d in user.get("directories", []):
|
||||
for f in d.get("files", []):
|
||||
states[f.get("filename", "")] = str(f.get("state", ""))
|
||||
# One directory walk per cycle, shared by every job. This used to be
|
||||
# a full recursive rglob per stuck job, every ten seconds, forever.
|
||||
done_files: dict[str, Path] | None = None
|
||||
|
||||
for job in jobs:
|
||||
st = states.get(job["remote_file"], "")
|
||||
t = index.get(job["remote_file"])
|
||||
st = str((t or {}).get("state", ""))
|
||||
|
||||
if "Completed, Succeeded" in st:
|
||||
src = find_downloaded(job["remote_file"])
|
||||
if done_files is None:
|
||||
done_files = downloaded_index()
|
||||
src = done_files.get(
|
||||
job["remote_file"].replace("\\", "/").rsplit("/", 1)[-1])
|
||||
if not src:
|
||||
continue # still being moved out of incomplete
|
||||
# Record the real size, not the size the peer advertised —
|
||||
# storage quotas are computed from this column.
|
||||
# Normally just still being moved out of incomplete, but
|
||||
# unbounded before: a misconfigured DOWNLOAD_DIR meant
|
||||
# polling until the heat death of the process.
|
||||
if stalled_for(job) > STALL_MINUTES:
|
||||
set_state(job["id"], "failed",
|
||||
"downloaded but the file never appeared in "
|
||||
f"{DOWNLOAD_DIR} -- check MUSE_DOWNLOAD_DIR")
|
||||
continue
|
||||
try:
|
||||
actual = src.stat().st_size
|
||||
except OSError:
|
||||
@@ -941,11 +1346,11 @@ async def import_worker():
|
||||
|
||||
conf = job["confidence"]
|
||||
if conf is not None and conf < AUTO_CONFIDENCE:
|
||||
# Hold it. The file stays in downloads, untagged and
|
||||
# unlinked, until someone approves or rejects it.
|
||||
band = ("almost certainly wrong"
|
||||
if conf < REVIEW_CONFIDENCE else "worth a look")
|
||||
set_state(job["id"], "review",
|
||||
f"{conf:.0%} confident — {job['match_why'] or ''}",
|
||||
size=actual)
|
||||
f"{conf:.0%} confident, {band} -- "
|
||||
f"{job['match_why'] or ''}", size=actual)
|
||||
await notify_review(job)
|
||||
continue
|
||||
|
||||
@@ -953,10 +1358,36 @@ async def import_worker():
|
||||
set_state(job["id"], "imported", str(dest),
|
||||
imported_path=str(dest), size=actual)
|
||||
await notify_imported(job, dest)
|
||||
|
||||
elif "Completed" in st and "Succeeded" not in st:
|
||||
set_state(job["id"], "failed", f"transfer {st}")
|
||||
# Every terminal non-success used to end the request here.
|
||||
# "File not shared." is per-file and per-peer, so the only
|
||||
# sane response is to try the next candidate.
|
||||
why = (t or {}).get("exception") or st
|
||||
await abandon(job, str(why)[:120])
|
||||
|
||||
elif t is None:
|
||||
# Absent from slskd entirely: it restarted, or the transfer
|
||||
# was cleared. Tolerate a couple of polls in case it is a
|
||||
# race, then stop waiting for something nobody is doing.
|
||||
missing[job["id"]] += 1
|
||||
if missing[job["id"]] >= 3:
|
||||
del missing[job["id"]]
|
||||
await abandon(job, "slskd forgot the transfer")
|
||||
|
||||
else:
|
||||
missing.pop(job["id"], None)
|
||||
# Queued behind a wall, or connected and sending nothing.
|
||||
# There was no clock on this at all, which is how a job sat
|
||||
# in `downloading` for days behind a cheerful pulsing dot.
|
||||
if not (t or {}).get("bytesTransferred") \
|
||||
and stalled_for(job) > STALL_MINUTES:
|
||||
place = (t or {}).get("placeInQueue")
|
||||
where = f" at #{place} in the queue" if place else ""
|
||||
await cancel_transfer(job["username"], job["remote_file"])
|
||||
await abandon(job, f"no bytes in {STALL_MINUTES:.0f}m{where}")
|
||||
except Exception as e:
|
||||
log.warning("worker: %s", e)
|
||||
log.warning("import worker: %s", errstr(e))
|
||||
|
||||
|
||||
# ─────────────────────────── api ───────────────────────────
|
||||
@@ -1094,20 +1525,91 @@ async def healthz():
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
CHOOSER = """<!doctype html><html lang=en><head><meta charset=utf-8>
|
||||
<meta name=viewport content="width=device-width,initial-scale=1"><title>muse</title>
|
||||
</head><body style="margin:0;min-height:100vh;display:grid;place-items:center;
|
||||
background:#08090d;color:#f2f4f9;font:15px/1.5 -apple-system,BlinkMacSystemFont,
|
||||
ui-sans-serif,system-ui,sans-serif">
|
||||
<div style="text-align:center;padding:32px">
|
||||
<div style="font-size:26px;font-weight:700;letter-spacing:-.5px">muse</div>
|
||||
<div style="color:#9aa3b6;font-size:13px;margin:6px 0 26px">Sign in to continue</div>
|
||||
{buttons}</div></body></html>"""
|
||||
# The sign-in page is the first screen anyone sees, and for a while it was the
|
||||
# only one built out of hardcoded hex with no relation to the rest of the app.
|
||||
# It cannot use the Next bundle -- /_next/* sits behind the session on purpose,
|
||||
# so an unauthenticated visitor never receives it -- so it gets one small
|
||||
# stylesheet of its own, served from a public path. `style-src 'self'` in the
|
||||
# CSP permits exactly that; an inline <style> block would be refused.
|
||||
LOGIN_CSS = """
|
||||
:root{
|
||||
--bg:#0a0b0f; --surface:#121319; --surface-2:#181a22;
|
||||
--fg:#f4f6fb; --dim:#a0a8bb; --mute:#6d7488;
|
||||
--edge:rgb(255 255 255/.09); --edge-hi:rgb(255 255 255/.16);
|
||||
--accent:#0a84ff; --accent-ink:#fff;
|
||||
--e2:0 2px 6px rgb(0 0 0/.34), 0 12px 32px rgb(0 0 0/.26);
|
||||
color-scheme:light dark;
|
||||
}
|
||||
@media (prefers-color-scheme:light){
|
||||
:root{
|
||||
--bg:#f6f7f9; --surface:#fff; --surface-2:#f2f3f6;
|
||||
--fg:#0d1117; --dim:#596273; --mute:#858da0;
|
||||
--edge:rgb(10 14 25/.1); --edge-hi:rgb(10 14 25/.18);
|
||||
--accent:#0071e3;
|
||||
--e2:0 2px 6px rgb(20 30 60/.07), 0 12px 32px rgb(20 30 60/.08);
|
||||
}
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
body{
|
||||
margin:0; min-height:100dvh; display:grid; place-items:center; padding:24px;
|
||||
background:var(--bg); color:var(--fg);
|
||||
font:15px/1.55 -apple-system,BlinkMacSystemFont,"SF Pro Text",ui-sans-serif,
|
||||
system-ui,"Segoe UI",Roboto,sans-serif;
|
||||
letter-spacing:-.008em; -webkit-font-smoothing:antialiased;
|
||||
}
|
||||
.card{
|
||||
width:100%; max-width:352px; padding:32px 28px; text-align:center;
|
||||
background:var(--surface); border:1px solid var(--edge);
|
||||
border-radius:18px; box-shadow:var(--e2);
|
||||
}
|
||||
.mark{
|
||||
width:44px; height:44px; margin:0 auto 18px; display:grid; place-items:center;
|
||||
background:var(--accent); border-radius:12px; color:var(--accent-ink);
|
||||
}
|
||||
h1{
|
||||
margin:0; font-size:23px; font-weight:700; letter-spacing:-.024em; line-height:1.18;
|
||||
font-family:-apple-system,BlinkMacSystemFont,"SF Pro Display",ui-sans-serif,system-ui,sans-serif;
|
||||
}
|
||||
p.sub{margin:6px 0 24px; font-size:13px; color:var(--dim)}
|
||||
a.btn{
|
||||
display:flex; align-items:center; justify-content:center; gap:9px;
|
||||
margin:8px 0; padding:11px 18px; border-radius:10px; text-decoration:none;
|
||||
background:var(--surface-2); border:1px solid var(--edge-hi);
|
||||
color:var(--fg); font-size:13px; font-weight:600;
|
||||
transition:border-color .15s, background .15s, transform .1s;
|
||||
}
|
||||
a.btn:hover{border-color:var(--accent); background:var(--bg)}
|
||||
a.btn:active{transform:scale(.985)}
|
||||
a.btn:focus-visible{outline:2px solid var(--accent); outline-offset:2px}
|
||||
p.foot{margin:22px 0 0; font-size:11px; color:var(--mute)}
|
||||
"""
|
||||
|
||||
_BTN = ('<a href="/auth/login?provider={p}" style="display:block;margin:10px auto;'
|
||||
'padding:12px 26px;min-width:230px;border-radius:12px;text-decoration:none;'
|
||||
'background:rgb(255 255 255/.06);border:1px solid rgb(255 255 255/.14);'
|
||||
'color:#f2f4f9;font-weight:600">Continue with {label}</a>')
|
||||
CHOOSER = """<!doctype html><html lang=en><head><meta charset=utf-8>
|
||||
<meta name=viewport content="width=device-width,initial-scale=1">
|
||||
<meta name=theme-color media="(prefers-color-scheme: light)" content="#f6f7f9">
|
||||
<meta name=theme-color media="(prefers-color-scheme: dark)" content="#0a0b0f">
|
||||
<title>Sign in · muse</title>
|
||||
<link rel=stylesheet href="/login.css"></head>
|
||||
<body><main class=card>
|
||||
<div class=mark><svg width=22 height=22 viewBox="0 0 24 24" fill=none
|
||||
stroke=currentColor stroke-width=2 stroke-linecap=round stroke-linejoin=round
|
||||
aria-hidden="true"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/>
|
||||
<circle cx="18" cy="16" r="3"/></svg></div>
|
||||
<h1>muse</h1>
|
||||
<p class=sub>Request a song, get it in your library.</p>
|
||||
{buttons}
|
||||
<p class=foot>You’ll be sent to your provider to sign in.</p>
|
||||
</main></body></html>"""
|
||||
|
||||
_BTN = ('<a class=btn href="/auth/login?provider={p}">Continue with {label}</a>')
|
||||
|
||||
|
||||
@app.get("/login.css")
|
||||
async def login_css():
|
||||
"""The sign-in page's stylesheet. Public, because it is needed by someone
|
||||
who by definition has no session yet."""
|
||||
return Response(LOGIN_CSS, media_type="text/css",
|
||||
headers={"Cache-Control": "public, max-age=3600"})
|
||||
|
||||
|
||||
@app.get("/auth/login")
|
||||
@@ -1844,7 +2346,8 @@ async def review_decide(job_id: str, body: ReviewBody, request: Request):
|
||||
if not job or (not admin and job["requested_by"] != me):
|
||||
raise HTTPException(404, "no such review item")
|
||||
|
||||
src = find_downloaded(job["remote_file"] or "")
|
||||
base = (job["remote_file"] or "").replace("\\", "/").rsplit("/", 1)[-1]
|
||||
src = downloaded_index().get(base)
|
||||
if body.action == "reject":
|
||||
if src:
|
||||
try:
|
||||
@@ -1948,6 +2451,25 @@ async def recap(request: Request, days: int = Query(365, ge=7, le=3650)):
|
||||
}
|
||||
|
||||
|
||||
def job_dict(r: sqlite3.Row) -> dict:
|
||||
"""A job as the UI wants it.
|
||||
|
||||
`candidates` is deliberately dropped: it is the untried remainder of the
|
||||
ladder, several KB of peer names and paths per job, and the jobs list is
|
||||
polled every four seconds. `tried` is sent parsed, because it is the answer
|
||||
to "why did this fail" and the UI renders it as a list.
|
||||
"""
|
||||
d = dict(r)
|
||||
d.pop("candidates", None)
|
||||
try:
|
||||
d["tried"] = json.loads(d.get("tried") or "[]")
|
||||
except (TypeError, ValueError):
|
||||
d["tried"] = []
|
||||
d["attempts"] = d.get("attempts") or 0
|
||||
d["remaining"] = max(0, MAX_ATTEMPTS - d["attempts"])
|
||||
return d
|
||||
|
||||
|
||||
@app.get("/api/jobs")
|
||||
async def jobs(request: Request, limit: int = Query(200, ge=1, le=1000),
|
||||
mine: bool = False):
|
||||
@@ -1969,7 +2491,7 @@ async def jobs(request: Request, limit: int = Query(200, ge=1, le=1000),
|
||||
counts = {r["state"]: r["n"] for r in c.execute(
|
||||
f"SELECT state, COUNT(*) n FROM jobs {where} GROUP BY state",
|
||||
args).fetchall()}
|
||||
return {"jobs": [dict(r) for r in rows], "counts": counts,
|
||||
return {"jobs": [job_dict(r) for r in rows], "counts": counts,
|
||||
"pending": work_q.qsize(), "scope": "all" if everyone else "mine"}
|
||||
|
||||
|
||||
@@ -1980,7 +2502,7 @@ async def retry(job_id: str, request: Request):
|
||||
just works."""
|
||||
u = current_user(request)
|
||||
with db() as c:
|
||||
row = c.execute("SELECT id, requested_by FROM jobs WHERE id=?",
|
||||
row = c.execute("SELECT id, state, requested_by FROM jobs WHERE id=?",
|
||||
(job_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "no such job")
|
||||
@@ -1990,6 +2512,19 @@ async def retry(job_id: str, request: Request):
|
||||
if AUTH_MODE != "off" and u and u["role"] != "admin" \
|
||||
and row["requested_by"] != u["username"]:
|
||||
raise HTTPException(404, "no such job")
|
||||
# Only failed jobs. Requeueing one in `review` used to strand its
|
||||
# downloaded file on disk with no row left pointing at it, and requeueing
|
||||
# one already `imported` would re-download a track that is in the library.
|
||||
if row["state"] != "failed":
|
||||
raise HTTPException(409, f"only failed jobs can be retried "
|
||||
f"(this one is {row['state']})")
|
||||
# Clear the previous attempt, or the ladder resumes mid-way through a
|
||||
# candidate list gathered for a search that has long since expired.
|
||||
with db() as c:
|
||||
c.execute("UPDATE jobs SET username=NULL, remote_file=NULL, size=NULL,"
|
||||
" confidence=NULL, match_why=NULL, candidates=NULL,"
|
||||
" tried=NULL, attempts=0, started_at=NULL WHERE id=?",
|
||||
(job_id,))
|
||||
set_state(job_id, "queued", "")
|
||||
await work_q.put(job_id)
|
||||
return {"ok": True}
|
||||
@@ -2339,6 +2874,19 @@ async def health():
|
||||
r = await sc.get("/api/v0/application")
|
||||
detail["slskd"] = r.status_code
|
||||
ok = ok and r.status_code == 200
|
||||
# Reachable and logged out is the failure worth naming: every
|
||||
# search returns nothing and every request fails, but the container
|
||||
# is up and healthy so nothing else reports a problem. This used to
|
||||
# be visible only on the admin panel, which meant an ordinary user
|
||||
# watched their requests fail with no way to learn why.
|
||||
if r.status_code == 200:
|
||||
app_state = r.json()
|
||||
srv = app_state.get("server") or {}
|
||||
detail["logged_in"] = bool(srv.get("isLoggedIn"))
|
||||
detail["shared_files"] = (
|
||||
((app_state.get("user") or {}).get("statistics") or {})
|
||||
.get("fileCount"))
|
||||
ok = ok and detail["logged_in"]
|
||||
except Exception as e:
|
||||
detail["slskd"] = errstr(e)
|
||||
ok = False
|
||||
|
||||
Reference in new issue
Block a user