|
(() => { |
|
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(); |
|
})(); |