74 lines
3.4 KiB
JavaScript
74 lines
3.4 KiB
JavaScript
// How the import finder asks when it is not ALLOWED to ask.
|
|
//
|
|
// The Soulseek server drops a search outright when the query carries every
|
|
// word of one of its excluded phrases. Measured 2026-08-11 against the live
|
|
// server, with the record's own copies provably on the network:
|
|
//
|
|
// animal collective merriweather 578 peers · 2,664 FLAC files
|
|
// merriweather pavilion 683 peers
|
|
// post pavilion 712 peers
|
|
// merriweather post pavilion 0 peers <- every time
|
|
// pavilion merriweather post 0 peers <- order is irrelevant
|
|
// merriweather post pavilion flac 0 peers <- extra terms don't help
|
|
//
|
|
// The published exclusions behave identically ("village people" 0, "bryan
|
|
// adams" 0, "village people flac" 0, while "abba gold" answers with 608), so
|
|
// this is that same mechanism — but the server publishes only SOME of its
|
|
// list. `sanitizeSearchQuery` strips the phrases it does publish; an
|
|
// unpublished one like this album's title reaches the wire and is swallowed.
|
|
//
|
|
// Nothing here can make the server answer a refused query. What it CAN do is
|
|
// ask a shorter question the server does answer, and then apply the part we
|
|
// were not allowed to say to the answers, where no server is involved.
|
|
|
|
const STOPWORDS = new Set(["the", "a", "an", "and", "or", "of", "in", "on", "at", "to", "for", "with"]);
|
|
|
|
/** Words, punctuation folded away. `&` and `-` are separators, not letters, so
|
|
* "Rock & Roll" and "Rock-n-Roll" both yield asking words rather than one
|
|
* token nothing will match. */
|
|
export function queryWords(s) {
|
|
return String(s ?? "")
|
|
.toLowerCase()
|
|
.replace(/[^\p{L}\p{N}]+/gu, " ")
|
|
.split(/\s+/)
|
|
.filter(Boolean);
|
|
}
|
|
|
|
/** The record's name with its last word dropped — the shortest change that
|
|
* breaks the refused word-set while still naming the record.
|
|
*
|
|
* Trailing stopwords go with it: "The Dark Side of the Moon" without "moon"
|
|
* is "the dark side of the", which asks the network for three words that
|
|
* carry no meaning and narrow the result set for nothing.
|
|
*
|
|
* Returns "" when there is nothing left worth asking. A one-word name
|
|
* shortens to nothing at all, and a bare artist query asks the network for
|
|
* that artist's entire discography — the same thing `findQueries` refuses
|
|
* to do. */
|
|
export function shortenName(name) {
|
|
const w = queryWords(name);
|
|
if (w.length < 2) return "";
|
|
w.pop();
|
|
while (w.length && STOPWORDS.has(w[w.length - 1])) w.pop();
|
|
return w.join(" ");
|
|
}
|
|
|
|
/** Does this peer's path claim to BE the record we could not ask for? Every
|
|
* word of the full name has to appear in it.
|
|
*
|
|
* This is the half of the question the server was not allowed to answer, and
|
|
* it is what keeps the shortened query honest: "animal collective merriweather
|
|
* post" also matches a live set recorded AT Merriweather Post Pavilion, and
|
|
* the venue is in the path of every bootleg taped there.
|
|
*
|
|
* Deliberately whole-word, not substring: "Pavillion" is not "Pavilion", and
|
|
* a folder that misspells the record is refused rather than guessed at. That
|
|
* costs a few real copies whose owners cannot spell; accepting near-misses
|
|
* costs correctness, and there is no way to tell the two apart from here. */
|
|
export function pathHasTitle(path, name) {
|
|
const need = queryWords(name);
|
|
if (!need.length) return false;
|
|
const have = new Set(queryWords(path));
|
|
return need.every((w) => have.has(w));
|
|
}
|