Skip to content

Instantly share code, notes, and snippets.

@hmoff1711
Last active January 31, 2026 18:11
Show Gist options
  • Select an option

  • Save hmoff1711/734de3c6fd9e1ef6262b69a3e619bbd4 to your computer and use it in GitHub Desktop.

Select an option

Save hmoff1711/734de3c6fd9e1ef6262b69a3e619bbd4 to your computer and use it in GitHub Desktop.
Spicetify extension: Artist (PlayCount) playlist builder + auto-unfollow

Artist (PlayCount) Playlist Builder (Spicetify + Sort Play)

Creates Artist (PlayCount) playlists for your followed artists (sorted by Play Count / Popularity). After creating the playlist, it unfollows the artist so your Library → Artists becomes a progress list.

Requirements

  • Spotify Desktop (Windows)
  • Spicetify + Marketplace
  • Sort Play extension (Marketplace)
  • Sort Play: Create Playlist After Sorting = ON
  • (Recommended) keyboard with NumPad

Prep (follow all artists)

https://www.nativenoise.co.za/spotify/follow-all-artists/

Install

  1. Copy artist-playcount-playlist.js to: %appdata%\spicetify\Extensions\artist-playcount-playlist.js
  2. Enable + apply (run in PowerShell): spicetify config extensions artist-playcount-playlist.js && spicetify apply
  3. Restart Spotify.

Use

  1. Spotify → Your Library → Artists → sort Name (A–Z)
  2. Open any artist page once (first time only)
  3. Hotkeys: Numpad 5 = one step, Numpad 9 = autopilot, Numpad 0 = stop/reset
(() => {
const STATE_KEY = "__spx_sortplay_state";
const HOOK_KEY = "__spx_sortplay_hook";
const W = window;
const D = document;
const S = (W[STATE_KEY] ||= {});
if (S.inited) return;
if (typeof S.step !== "number") S.step = 0;
if (typeof S.running !== "boolean") S.running = false;
if (typeof S.lastKeyAt !== "number") S.lastKeyAt = 0;
if (typeof S.inited !== "boolean") S.inited = false;
if (typeof S.lastProcessed !== "string") S.lastProcessed = "";
if (typeof S.auto !== "boolean") S.auto = false;
if (typeof S.lastErrorAt !== "number") S.lastErrorAt = 0;
if (typeof S.lastErrorMsg !== "string") S.lastErrorMsg = "";
if (typeof S.autoPending !== "object") S.autoPending = null;
const H = (W[HOOK_KEY] ||= { installed: false, last: null });
const RX_LIBRARY = /(моя медиатека|your library)/i;
const RX_BADROW = /(создать|create|home|главная|search|поиск|настройк|settings|liked songs|любимые|очередь|queue|подкаст|podcast|папк|folder)/i;
const RX_SORTBY = /^(sort by|сортировать|сортировка)$/i;
const RX_SORTBY_SOFT = /(sort by|сортир)/i;
const RX_PLAYCOUNT = /(play\s*count|playcount|прослуш|сч[её]тчик.*прослуш)/i;
const RX_FOLLOW_ANY = /(following|подписан|подписана|уже подписан|уже подписана|follow|подписаться|unfollow|отписаться)/i;
const RX_FOLLOWING = /(following|подписан|подписана|уже подписан|уже подписана)/i;
const RX_FOLLOW = /(follow|подписаться)/i;
const HAS_POINTER = typeof W.PointerEvent === "function";
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const now = () => Date.now();
const norm = (v) => {
if (v == null) return "";
const s = String(v).trim();
return s ? s.replace(/\s+/g, " ") : "";
};
const low = (v) => norm(v).toLowerCase();
const safeText = (el) => {
if (!el) return "";
const t = el.textContent;
return t ? String(t) : "";
};
const notify = (msg, isError) => {
try {
const sp = W.Spicetify;
const fn = sp && sp.showNotification;
if (typeof fn === "function") fn(msg, !!isError);
} catch {}
};
const isVisible = (el) => {
if (!el || !el.isConnected || typeof el.getBoundingClientRect !== "function") return false;
const r = el.getBoundingClientRect();
if (r.width < 2 || r.height < 2) return false;
try {
const cs = getComputedStyle(el);
if (cs.display === "none" || cs.visibility === "hidden") return false;
return String(cs.opacity) !== "0";
} catch {
return false;
}
};
const isInView = (el) => {
if (!isVisible(el)) return false;
const r = el.getBoundingClientRect();
return !(r.bottom < 0 || r.top > W.innerHeight);
};
const waitUntil = (cond, { timeout = 12000, interval = 150 } = {}) => {
timeout = Math.max(100, timeout | 0);
interval = Math.max(50, interval | 0);
const start = now();
return new Promise((resolve, reject) => {
const tick = async () => {
let v = false;
try {
v = await cond();
} catch {
v = false;
}
if (v) return resolve(v);
if (now() - start >= timeout) return reject(new Error("timeout"));
setTimeout(tick, interval);
};
tick();
});
};
const installSortPlayHooks = async () => {
if (H.installed) return;
await waitUntil(() => W.Spicetify?.Platform?.RootlistAPI && W.Spicetify?.CosmosAsync, {
timeout: 30000,
interval: 250
});
const sp = W.Spicetify;
const rl = sp?.Platform?.RootlistAPI;
if (rl?.createPlaylist && !rl.__spx_wrapped) {
const orig = rl.createPlaylist.bind(rl);
rl.createPlaylist = async (name, opts) => {
const res = await orig(name, opts);
try {
const uri = typeof res === "string" ? res : res?.uri || res;
H.last = { via: "RootlistAPI", name, uri, ts: now() };
} catch {}
return res;
};
rl.__spx_wrapped = true;
}
const ca = sp?.CosmosAsync;
if (ca?.post && !ca.__spx_wrapped) {
const orig = ca.post.bind(ca);
ca.post = async (url, body) => {
const out = await orig(url, body);
try {
const u = String(url || "");
if (/\/v1\/users\/[^/]+\/playlists/i.test(u)) {
const name = body?.name;
const uri = out?.uri || (out?.id ? `spotify:playlist:${out.id}` : undefined);
H.last = { via: "CosmosAsync", name, uri, ts: now() };
}
} catch {}
return out;
};
ca.__spx_wrapped = true;
}
H.installed = true;
};
const waitPlaylistCreated = async (artistName, startedAt) => {
const a = low(norm(artistName));
const strictWindowMs = 20000;
const timeoutMs = S.auto ? 1200000 : 180000;
const start = now();
let interval = 250;
while (now() - start < timeoutMs) {
if (!S.auto && !S.autoPending?.paused) return false;
const x = H.last;
if (x && x.ts && x.ts > startedAt) {
if (x.name && a && now() - startedAt <= strictWindowMs) {
const n = low(norm(x.name));
if (!n.includes(a)) {
await sleep(300);
continue;
}
}
return true;
}
const elapsed = now() - start;
if (elapsed > 30000) interval = 500;
if (elapsed > 120000) interval = 1000;
await sleep(interval);
}
throw new Error("Таймаут ожидания создания плейлиста");
};
const dispatch = (target, type, base, pointer) => {
try {
if (pointer) {
if (!HAS_POINTER) return false;
target.dispatchEvent(
new PointerEvent(type, {
bubbles: base.bubbles,
cancelable: base.cancelable,
clientX: base.clientX,
clientY: base.clientY,
pointerId: pointer,
pointerType: "mouse",
isPrimary: true
})
);
} else {
target.dispatchEvent(new MouseEvent(type, base));
}
return true;
} catch {
return false;
}
};
const clickAt = (x, y) => {
const t = D.elementFromPoint(x, y);
if (!t) return false;
const base = { bubbles: true, cancelable: true, clientX: x, clientY: y };
dispatch(t, "pointerdown", base, 1);
dispatch(t, "mousedown", base);
dispatch(t, "pointerup", base, 1);
dispatch(t, "mouseup", base);
return dispatch(t, "click", base);
};
const hoverAt = (x, y) => {
const t = D.elementFromPoint(x, y);
if (!t) return false;
const base = { bubbles: true, cancelable: true, clientX: x, clientY: y };
dispatch(t, "pointerover", base, 2);
dispatch(t, "mouseover", base);
dispatch(t, "pointermove", base, 2);
dispatch(t, "mousemove", base);
dispatch(t, "mouseenter", base);
return true;
};
const clickEl = (el) => {
if (!el) return false;
try {
el.scrollIntoView({ block: "nearest", inline: "nearest" });
} catch {}
const r = el.getBoundingClientRect();
const x = Math.max(r.left + 14, Math.min(r.left + Math.min(90, r.width * 0.45), r.right - 6));
const y = Math.max(r.top + 6, Math.min(r.top + r.height / 2, r.bottom - 6));
if (clickAt(x, y)) return true;
try {
el.click();
return true;
} catch {
return false;
}
};
const hoverEl = (el) => {
if (!el) return false;
const r = el.getBoundingClientRect();
const x = Math.max(r.left + 10, Math.min(r.left + r.width * 0.6, r.right - 6));
const y = Math.max(r.top + 6, Math.min(r.top + r.height / 2, r.bottom - 6));
return hoverAt(x, y);
};
let footerCache = null;
const footerTop = () => {
const f = footerCache && footerCache.isConnected ? footerCache : (footerCache = D.querySelector("footer"));
if (f && isInView(f)) {
const r = f.getBoundingClientRect();
if (r.height > 40) return r.top;
}
return W.innerHeight - 90;
};
let scrollNodeCache = null;
const scrollNodeOk = (el) => !!el && el.isConnected && isVisible(el) && el.scrollHeight > el.clientHeight + 60;
const getScrollNode = () => {
if (scrollNodeOk(scrollNodeCache)) return scrollNodeCache;
const h1 = D.querySelector("h1");
if (h1) {
for (let p = h1.parentElement; p; p = p.parentElement) {
try {
const cs = getComputedStyle(p);
const oy = String(cs.overflowY || "");
if ((oy === "auto" || oy === "scroll") && p.scrollHeight > p.clientHeight + 60) return (scrollNodeCache = p);
} catch {}
}
}
const sel =
'[data-overlayscrollbars-viewport],.os-viewport,.main-view-container__scroll-node,[data-testid="main-view-container"],main,div[role="main"]';
let best = null;
let bestScore = -1;
for (const el of D.querySelectorAll(sel)) {
if (!scrollNodeOk(el)) continue;
const r = el.getBoundingClientRect();
if (r.width < 300 || r.height < 250) continue;
let score = r.width * r.height;
try {
if (h1 && el.contains(h1)) score += 1e9;
} catch {}
if (score > bestScore) {
bestScore = score;
best = el;
}
}
return (scrollNodeCache = best || D.scrollingElement || D.documentElement);
};
const scrollTopMain = async () => {
const n = getScrollNode();
try {
n.scrollTop = 0;
} catch {}
try {
n.dispatchEvent(new Event("scroll", { bubbles: true }));
} catch {}
try {
W.scrollTo(0, 0);
} catch {}
await sleep(80);
try {
n.scrollTop = 0;
} catch {}
try {
n.dispatchEvent(new Event("scroll", { bubbles: true }));
} catch {}
};
let libRootCache = null;
const libRootOk = (el) => !!el && el.isConnected && isInView(el);
const findLibraryRoot = () => {
if (libRootOk(libRootCache)) return libRootCache;
const sel = "aside,nav,section,div,header,span,h2,h3,button,a";
let best = null;
let bestArea = -1;
for (const el of D.querySelectorAll(sel)) {
if (!isInView(el)) continue;
const t = norm(safeText(el));
if (!t || !RX_LIBRARY.test(t)) continue;
const root = el.closest("aside") || el.closest("nav") || el.closest("section") || el.closest("div");
if (!root || !isInView(root)) continue;
const r = root.getBoundingClientRect();
if (r.left > 90) continue;
if (r.width < 190 || r.width > 520) continue;
if (r.height < 300) continue;
const area = r.width * r.height;
if (area > bestArea) {
bestArea = area;
best = root;
}
}
return (libRootCache = best);
};
const artistsChipBottom = (root) => {
let contains = null;
for (const n of root.querySelectorAll("button,span,div,a")) {
if (!isInView(n)) continue;
const t = low(safeText(n));
if (t === "исполнители" || t === "artists") return n.getBoundingClientRect().bottom;
if (!contains && (t.includes("исполнители") || t.includes("artists"))) contains = n;
}
return contains ? contains.getBoundingClientRect().bottom : null;
};
const hasAvatar = (row) => {
for (const img of row.querySelectorAll("img")) {
if (!isVisible(img)) continue;
const r = img.getBoundingClientRect();
if (r.width >= 22 && r.width <= 90 && r.height >= 22 && r.height <= 90) return true;
}
for (const el of row.querySelectorAll("div,span,figure")) {
if (!isVisible(el)) continue;
const r = el.getBoundingClientRect();
if (r.width < 22 || r.width > 90 || r.height < 22 || r.height > 90) continue;
try {
const cs = getComputedStyle(el);
const bg = String(cs.backgroundImage || "");
if (bg && bg !== "none") return true;
const br = String(cs.borderRadius || "");
if (br.includes("%")) {
const p = parseFloat(br);
if (!Number.isNaN(p) && p >= 45) return true;
} else {
const px = parseFloat(br);
if (!Number.isNaN(px) && px >= Math.min(r.width, r.height) * 0.35) return true;
}
} catch {}
}
return false;
};
const isBadRowText = (t) => {
const s = low(t);
if (!s) return true;
if (RX_BADROW.test(s)) return true;
return s.length > 80;
};
const firstArtistEntry = () => {
const root = findLibraryRoot();
if (!root) return null;
const rr = root.getBoundingClientRect();
const ft = footerTop();
const cb = artistsChipBottom(root);
const topMin = Math.max(rr.top + 70, cb != null ? cb + 6 : rr.top + 120);
const bottomMax = Math.min(rr.bottom - 6, ft - 10);
const sel =
'a,button,[role="button"],[role="link"],div[role="button"],div[role="link"],div[tabindex="0"],div[role="row"],li';
let best = null;
let bestTop = Infinity;
let bestLeft = Infinity;
for (const el of root.querySelectorAll(sel)) {
if (!isInView(el)) continue;
const r = el.getBoundingClientRect();
if (r.top < topMin || r.bottom > bottomMax) continue;
if (r.left < rr.left - 6 || r.right > rr.right + 8) continue;
if (r.height < 44 || r.height > 110) continue;
if (r.width < 150) continue;
const text = norm(safeText(el));
if (!text) continue;
const name = norm(text.split("\n")[0]);
if (isBadRowText(name)) continue;
if (!hasAvatar(el)) continue;
const a = el.tagName === "A" ? el : el.querySelector("a") || el;
if (!a || !isInView(a)) continue;
const ar = a.getBoundingClientRect();
if (ar.left < rr.left - 6 || ar.right > rr.right + 8) continue;
if (r.top < bestTop || (r.top === bestTop && r.left < bestLeft)) {
best = { el: a, name };
bestTop = r.top;
bestLeft = r.left;
}
}
return best;
};
const artistH1 = () => {
const h1 = D.querySelector("h1");
if (!h1 || !isVisible(h1)) return null;
return norm(safeText(h1)) ? h1 : null;
};
const followButton = () => {
const byTest = D.querySelector('button[data-testid="follow-button"]');
if (byTest && isVisible(byTest)) return byTest;
for (const b of D.querySelectorAll("button")) {
if (!isVisible(b)) continue;
const s = low(safeText(b) + " " + (b.getAttribute("aria-label") || ""));
if (RX_FOLLOW_ANY.test(s)) return b;
}
return null;
};
const artistReady = () => !!artistH1() && !!followButton();
const sortPlayButton = () => {
const direct = D.querySelector('button[title*="Sort Play" i],button[aria-label*="Sort Play" i]');
if (direct && isVisible(direct)) return direct;
let best = null;
let bestTop = Infinity;
let bestLeft = Infinity;
for (const b of D.querySelectorAll("button")) {
if (!isVisible(b)) continue;
const s = low(
safeText(b) + " " + (b.getAttribute("aria-label") || "") + " " + (b.getAttribute("title") || "")
);
if (!s || (!s.includes("sort play") && s !== "sort play")) continue;
const r = b.getBoundingClientRect();
if (r.top < bestTop || (r.top === bestTop && r.left < bestLeft)) {
best = b;
bestTop = r.top;
bestLeft = r.left;
}
}
return best;
};
const iterVisibleMenus = () => {
const sel = '[role="menu"],.main-contextMenu-menu,.main-contextMenu-container';
const nodes = D.querySelectorAll(sel);
const out = [];
for (const el of nodes) {
if (!isInView(el)) continue;
const r = el.getBoundingClientRect();
if (r.width < 80 || r.height < 60) continue;
const tc = safeText(el);
if (!tc || !/\S/.test(tc)) continue;
out.push(el);
}
return out;
};
const anyMenu = () => iterVisibleMenus().length > 0;
const menuContaining = (texts) => {
const wants = texts.map((t) => String(t || "").toLowerCase()).filter(Boolean);
const menus = iterVisibleMenus();
for (const m of menus) {
const t = low(safeText(m));
for (const w of wants) {
if (t.includes(w)) return m;
}
}
return menus[0] || null;
};
const menuItem = (root, predicate) => {
const sel = '[role="menuitem"],button,a,div,span';
let best = null;
let bestTop = Infinity;
let bestLeft = Infinity;
for (const el of root.querySelectorAll(sel)) {
if (!isInView(el)) continue;
const t = norm(safeText(el));
if (!t) continue;
if (!predicate(t, el)) continue;
const r = el.getBoundingClientRect();
if (r.width < 20 || r.height < 14) continue;
if (r.top < bestTop || (r.top === bestTop && r.left < bestLeft)) {
best = el;
bestTop = r.top;
bestLeft = r.left;
}
}
return best;
};
const submenuRightOf = (anchorRect) => {
let best = null;
let bestLeft = Infinity;
let bestTop = Infinity;
for (const m of iterVisibleMenus()) {
const r = m.getBoundingClientRect();
if (r.left < anchorRect.right - 6) continue;
if (r.top > anchorRect.bottom + 30) continue;
if (r.bottom < anchorRect.top - 30) continue;
if (r.left < bestLeft || (r.left === bestLeft && r.top < bestTop)) {
best = m;
bestLeft = r.left;
bestTop = r.top;
}
}
return best;
};
const softReset = () => {
S.step = 0;
S.lastProcessed = "";
};
const step0 = async () => {
const last = low(S.lastProcessed);
if (artistReady()) {
const opened = norm(safeText(artistH1()));
if (opened && last && low(opened) !== last) {
notify("Шаг 0: артист уже открыт — " + opened, false);
return;
}
}
let entry = firstArtistEntry();
if (!entry) throw new Error("Шаг 0: не найден первый видимый артист в левом списке");
if (last && low(entry.name) === last) {
try {
await waitUntil(() => {
const e = firstArtistEntry();
return e && low(e.name) !== last;
}, { timeout: 20000, interval: 220 });
entry = firstArtistEntry();
} catch {
throw new Error("Шаг 0: список артистов ещё не обновился после отписки");
}
}
clickEl(entry.el);
try {
await waitUntil(() => {
if (!artistReady()) return false;
const name = norm(safeText(artistH1()));
return !!name && !(last && low(name) === last);
}, { timeout: 25000, interval: 220 });
} catch {
throw new Error("Шаг 0: страница артиста не открылась или осталась прежней");
}
await scrollTopMain();
const name = norm(safeText(artistH1()));
if (!name) throw new Error("Шаг 0: страница артиста пустая");
notify("Шаг 0: открыт артист — " + name, false);
};
const step1 = async () => {
await scrollTopMain();
installSortPlayHooks().catch(() => {});
const currentArtist = norm(safeText(artistH1()));
const startedAt = now();
let spb;
try {
const btnTimeout = S.auto ? 30000 : 14000;
spb = await waitUntil(() => sortPlayButton(), { timeout: btnTimeout, interval: 200 });
} catch {
throw new Error("Шаг 1: кнопка Sort Play не найдена");
}
clickEl(spb);
try {
await waitUntil(() => anyMenu(), { timeout: 8000, interval: 150 });
} catch {
throw new Error("Шаг 1: меню Sort Play не открылось");
}
const menu = menuContaining(["sort by", "сорт"]);
if (!menu) throw new Error("Шаг 1: не найдено меню Sort Play");
const sortBy = menuItem(menu, (t) => RX_SORTBY.test(t) || RX_SORTBY_SOFT.test(t));
if (!sortBy) throw new Error("Шаг 1: не найден пункт Sort By");
const sbr = sortBy.getBoundingClientRect();
hoverEl(sortBy);
await sleep(120);
hoverEl(sortBy);
await sleep(120);
clickEl(sortBy);
await sleep(120);
hoverEl(sortBy);
let submenu = null;
try {
submenu = await waitUntil(() => submenuRightOf(sbr), { timeout: 6000, interval: 120 });
} catch {}
const direct = menuItem(menu, (t) => RX_PLAYCOUNT.test(t));
if (direct) {
clickEl(direct);
notify("Шаг 1: Sort Play → Play Count", false);
return { artistName: currentArtist, startedAt };
}
if (submenu) {
const pc = menuItem(submenu, (t) => RX_PLAYCOUNT.test(t));
if (pc) {
clickEl(pc);
notify("Шаг 1: Sort Play → Play Count", false);
return { artistName: currentArtist, startedAt };
}
}
const sel = D.querySelector("#sort-type-select");
if (sel && isInView(sel)) {
try {
sel.value = "playCount";
sel.dispatchEvent(new Event("change", { bubbles: true }));
} catch {}
const createBtn = D.querySelector("#customFilterCreatePlaylist");
if (createBtn && isInView(createBtn)) {
clickEl(createBtn);
notify("Шаг 1: Play Count (модалка) — Create Playlist", false);
return { artistName: currentArtist, startedAt };
}
throw new Error("Шаг 1: Play Count выбран, но Create Playlist не найден");
}
throw new Error("Шаг 1: не найден пункт Play Count");
};
const step2 = async () => {
await scrollTopMain();
const current = norm(safeText(artistH1()));
if (current) S.lastProcessed = current;
let fb;
try {
fb = await waitUntil(() => followButton(), { timeout: 12000, interval: 220 });
} catch {
throw new Error("Шаг 2: кнопка Following/Подписан не найдена");
}
const before = low(safeText(fb) + " " + (fb.getAttribute("aria-label") || ""));
const alreadyNot = RX_FOLLOW.test(before) && !RX_FOLLOWING.test(before);
if (alreadyNot) {
notify("Шаг 2: уже не подписан", false);
softReset();
return;
}
if (!RX_FOLLOWING.test(before)) {
notify("Шаг 2: статус подписки не распознан, считаю успешным", false);
softReset();
return;
}
clickEl(fb);
await waitUntil(() => {
const b = followButton();
if (!b) return false;
const t = low(safeText(b) + " " + (b.getAttribute("aria-label") || ""));
return RX_FOLLOW.test(t) && !RX_FOLLOWING.test(t);
}, { timeout: 16000, interval: 260 }).catch(() => false);
notify("Шаг 2: отписка выполнена", false);
softReset();
};
const runCycle = async () => {
if (S.running) return;
S.running = true;
try {
if (S.step === 0) {
await step0();
S.step = 1;
} else if (S.step === 1) {
await step1();
S.step = 2;
} else {
await step2();
S.step = 0;
}
} catch (e) {
const msg = "Ошибка: " + (e && e.message ? e.message : String(e));
S.lastErrorMsg = msg;
S.lastErrorAt = now();
notify(msg, true);
} finally {
S.running = false;
}
};
const stopAuto = (msg) => {
S.auto = false;
S.autoPending = null;
notify(msg || "Автопилот: OFF", false);
};
const autoLoop = async () => {
notify("Автопилот: ON (NumPad0 = стоп)", false);
installSortPlayHooks().catch(() => {});
while (S.auto) {
const errBefore = S.lastErrorAt;
if (S.autoPending) {
try {
notify("Жду завершения предыдущего Sort Play...", false);
await (S.autoPending.wait || waitPlaylistCreated(S.autoPending.artistName, S.autoPending.startedAt));
} catch (e) {
const msg = "Ошибка: " + (e && e.message ? e.message : String(e));
S.lastErrorMsg = msg;
S.lastErrorAt = now();
notify(msg, true);
stopAuto("Автопилот остановлен из-за ошибки ожидания плейлиста.");
break;
}
S.autoPending = null;
await sleep(40);
}
await step0().catch((e) => {
const msg = "Ошибка: " + (e && e.message ? e.message : String(e));
S.lastErrorMsg = msg;
S.lastErrorAt = now();
notify(msg, true);
});
if (!S.auto) break;
if (S.lastErrorAt > errBefore) {
stopAuto("Автопилот остановлен из-за ошибки. Запусти снова через NumPad9.");
break;
}
const started = await step1().catch((e) => {
const msg = "Ошибка: " + (e && e.message ? e.message : String(e));
S.lastErrorMsg = msg;
S.lastErrorAt = now();
notify(msg, true);
return null;
});
if (!S.auto) break;
if (!started) {
stopAuto("Автопилот остановлен из-за ошибки на Sort Play.");
break;
}
const pending = { artistName: started.artistName, startedAt: started.startedAt, done: false, wait: null };
pending.wait = waitPlaylistCreated(pending.artistName, pending.startedAt).finally(() => {
pending.done = true;
});
S.autoPending = pending;
await step2().catch((e) => {
const msg = "Ошибка: " + (e && e.message ? e.message : String(e));
S.lastErrorMsg = msg;
S.lastErrorAt = now();
notify(msg, true);
});
if (S.autoPending === pending && pending.done) {
S.autoPending = null;
await sleep(30);
continue;
}
if (!S.auto) break;
if (S.lastErrorAt > errBefore) {
stopAuto("Автопилот остановлен из-за ошибки. Запусти снова через NumPad9.");
break;
}
}
};
const onKeyDown = (e) => {
const c = e.code;
if (c !== "Numpad5" && c !== "Numpad0" && c !== "Numpad9") return;
if (e.repeat) return;
const t = now();
if (t - S.lastKeyAt < 180) return;
S.lastKeyAt = t;
e.preventDefault();
e.stopPropagation();
try {
e.stopImmediatePropagation();
} catch {}
if (c === "Numpad0") {
S.auto = false;
if (S.autoPending) S.autoPending.paused = true;
S.step = 0;
S.lastProcessed = "";
notify("Сброс: шаг = 0, автопилот = OFF. Если Sort Play ещё создаёт плейлист — дождись завершения.", false);
return;
}
if (c === "Numpad9") {
if (S.auto) {
notify("Автопилот уже включён (NumPad0 = стоп)", false);
return;
}
if (S.autoPending) {
notify("Sort Play ещё не завершился. Жду окончания, затем продолжу.", false);
S.auto = true;
autoLoop();
return;
}
S.auto = true;
autoLoop();
return;
}
if (S.autoPending) {
notify("Сейчас идёт Sort Play (создание плейлиста). Дождись завершения и нажми снова.", true);
return;
}
runCycle();
};
const init = () => {
if (S.inited) return;
S.inited = true;
W.addEventListener("keydown", onKeyDown, true);
};
const boot = async () => {
try {
await waitUntil(() => {
const sp = W.Spicetify;
return !!sp && typeof sp.showNotification === "function";
}, { timeout: 60000, interval: 200 });
init();
} catch {}
};
boot();
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment