Detects "Artist - Song" style mentions in Reddit comments, links them to Spotify, and lets you collect them into a playlist
// ==UserScript==
// @name Crate
// @version 2.3.1
// @description Detects "Artist - Song" style mentions in Reddit comments, links them to Spotify, and lets you collect them into a playlist
// @match https://www.reddit.com/*
// @match https://old.reddit.com/*
// @grant GM_xmlhttpRequest
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_addStyle
// @grant GM_setClipboard
// @grant GM_listValues
// @grant GM_deleteValue
// @connect accounts.spotify.com
// @connect api.spotify.com
// @license MIT
// @namespace https://greasyfork.org/users/1561930
// ==/UserScript==
(function () {
'use strict';
/* ---------------------------------------------------------------------
* SETUP
* 1. Go to https://developer.spotify.com/dashboard and create an app.
* 2. Grab the Client ID and Client Secret (secret is only used for the
* read-only search lookups; playlist actions use a separate login
* flow that never touches the secret).
* 3. In that app's settings, add this exact Redirect URI:
* https://www.reddit.com/
* (Spotify requires one to be registered for the "connect your
* account" login step used by the playlist feature.)
* 4. The first time this script runs, it will prompt you once for the
* Client ID/Secret and store them locally via GM_setValue.
* ------------------------------------------------------------------- */
const REDIRECT_URI = 'https://www.reddit.com/';
const USER_SCOPES = 'playlist-modify-public playlist-modify-private playlist-read-private';
const CACHE_TTL_MS = 1000 * 60 * 60 * 24 * 7; // 1 week cache for track lookups
const REQUEST_DELAY_MS = 300; // throttle between Spotify search calls
// ---- Handle the OAuth redirect landing (runs in the login popup) --------
// If this page load is Spotify sending us back with ?code=..., grab it,
// hand it to the window that opened the popup, and stop; don't run the
// rest of the script (comment scanning) on this transient redirect page.
if (handleAuthRedirectIfPresent()) return;
function handleAuthRedirectIfPresent() {
const params = new URLSearchParams(window.location.search);
const code = params.get('code');
const state = params.get('state');
if (!code || !window.opener) return false;
const expectedState = GM_getValue('spotify_pkce_state', null);
if (state && state === expectedState) {
window.opener.postMessage({ type: 'spotify-auth-code', code }, '*');
}
document.documentElement.innerHTML =
'<body style="font-family:sans-serif;padding:2rem;background:#111;color:#eee;">Spotify connected. This window will close automatically.</body>';
setTimeout(() => window.close(), 1200);
return true;
}
// Shared character-class fragments; every regex below is built from these
// so a future "allow one more punctuation mark" fix only happens in one place.
const WORD_CHARS = "A-Za-z0-9&.'’%()/: ";
const TITLE_EXTRA_CHARS = ',!';
const TERMINATOR = '[.,!?;)\\n]';
function buildDashRegex(artistStart) {
return new RegExp(
`\\b([${artistStart}][${WORD_CHARS}]{1,40}?)\\s*[-–—]\\s+` +
`([A-Za-z0-9][${WORD_CHARS}${TITLE_EXTRA_CHARS}]{1,40}?)` +
`(?=${TERMINATOR}|$|\\s+[${artistStart}][${WORD_CHARS}]{0,40}?\\s*[-–—]\\s)`,
'g'
);
}
// Matches things like: "Bad Omens - Died For You", "Northlane- Talking Heads"
const SONG_REGEX = buildDashRegex('A-Z');
// Looser variant used only for the manual "scan for more" action in the
// Unmatched review panel; allows a lowercase-starting artist too. Never
// auto-linked in-page; results only ever land in the review list.
const LOOSE_SONG_REGEX = buildDashRegex('A-Za-z');
// "Song Title by Artist", no dash at all. Much noisier ("inspired by",
// "surrounded by trees" also match "by"), so this only ever feeds the
// manual review list, never auto-linked.
const LOOSE_BY_REGEX = new RegExp(
`\\b([A-Z][${WORD_CHARS}]{1,60}?)\\s+by\\s+([A-Z][${WORD_CHARS}]{1,40}?)(?=${TERMINATOR}|$)`,
'gi'
);
const SPOTIFY_SVG = `<svg viewBox="0 0 24 24" width="14" height="14" style="vertical-align:middle;" fill="#1DB954" fill-rule="evenodd"><path d="M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.66 0 12 0zm5.521 17.34c-.24.359-.66.48-1.021.24-2.82-1.74-6.36-2.101-10.561-1.141-.418.122-.779-.179-.899-.539-.12-.421.18-.78.54-.9 4.56-1.021 8.52-.6 11.64 1.32.42.18.479.659.301 1.02zm1.44-3.3c-.301.42-.841.6-1.262.3-3.239-1.98-8.159-2.58-11.939-1.38-.479.12-1.02-.12-1.14-.6-.12-.48.12-1.021.6-1.141 4.32-1.32 9.719-.66 13.439 1.621.361.24.54.78.301 1.2zm.12-3.36C15.24 8.4 8.82 8.16 5.16 9.301c-.6.179-1.2-.181-1.38-.721-.18-.6.18-1.2.72-1.38C8.64 5.94 15.6 6.24 19.681 8.64c.539.3.719 1.02.42 1.56-.3.421-1.02.599-1.62.3z"/></svg>`;
// Same mark, red, used inline for anything sitting in Unmatched: no
// confident match yet, or none found at all.
const SPOTIFY_SVG_RED = SPOTIFY_SVG.replace('fill="#1DB954"', 'fill="#e0384a"');
GM_addStyle(`
:root {
--sl-bg: #16141c;
--sl-surface: rgba(23, 20, 30, 0.92);
--sl-elevated: #26222f;
--sl-elevated-hover: #302b3a;
--sl-border: rgba(255,255,255,0.08);
--sl-border-strong: rgba(255,255,255,0.16);
--sl-text: #f3f1f7;
--sl-text-muted: #9c96ab;
--sl-accent: #1DB954;
--sl-accent-hover: #22e065;
--sl-accent-soft: rgba(29,185,84,0.16);
--sl-radius-lg: 18px;
--sl-radius-md: 12px;
--sl-radius-sm: 9px;
--sl-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
.spotify-link-icon {
display: inline-flex;
align-items: center;
margin-left: 4px;
text-decoration: none;
opacity: 0.85;
transition: opacity 0.15s ease, transform 0.15s ease;
}
.spotify-link-icon:hover { opacity: 1; transform: scale(1.15); }
#sl-toggle-wrap {
position: fixed;
right: 20px;
bottom: 20px;
z-index: 999998;
width: 52px;
height: 52px;
transition: transform 0.22s ease, opacity 0.22s ease;
}
#sl-toggle-wrap.sl-tucked {
opacity: 0.5;
transform: translateX(32px); /* mostly off-screen, a sliver left peeking out near the edge */
}
#sl-toggle-wrap.sl-tucked:hover {
opacity: 1;
transform: translateX(0);
}
#sl-toggle-wrap.sl-tucked #sl-badge { display: none; } /* nothing to count while disabled here */
#sl-toggle-btn {
position: absolute;
inset: 0;
border-radius: 50%;
background: transparent !important;
border: none !important;
cursor: pointer;
filter: drop-shadow(0 6px 16px rgba(0,0,0,0.55));
display: flex;
align-items: center;
justify-content: center;
padding: 0;
transition: transform 0.18s cubic-bezier(.34,1.56,.64,1);
}
#sl-toggle-btn:hover { transform: scale(1.08) rotate(-4deg); }
#sl-toggle-btn:active { transform: scale(0.95); }
.sl-toggle-icon { width: 34px; height: 34px; display: block; }
#sl-badge {
position: absolute;
top: -6px;
right: -6px;
background: #fff;
color: #111;
font: 700 11px/1.6 var(--sl-font);
border-radius: 999px;
padding: 1px 6px;
min-width: 16px;
text-align: center;
box-shadow: 0 0 0 3px var(--sl-bg), 0 2px 6px rgba(0,0,0,0.5);
pointer-events: none;
z-index: 1;
}
#sl-panel {
position: fixed;
right: 20px;
bottom: 84px;
width: 320px;
height: min(78vh, 680px);
background: var(--sl-surface);
backdrop-filter: blur(22px) saturate(160%);
-webkit-backdrop-filter: blur(22px) saturate(160%);
color: var(--sl-text);
border-radius: var(--sl-radius-lg);
border: 1px solid var(--sl-border-strong);
box-shadow: 0 24px 60px -16px rgba(0,0,0,0.65), 0 4px 16px rgba(0,0,0,0.3);
z-index: 999999;
display: flex;
flex-direction: column;
overflow: hidden;
font-family: var(--sl-font);
font-size: 13px;
opacity: 0;
transform: translateY(10px) scale(0.97);
pointer-events: none;
transition: opacity 0.18s ease, transform 0.18s cubic-bezier(.2,.9,.3,1.1);
}
#sl-panel.sl-open { opacity: 1; transform: translateY(0) scale(1); pointer-events: auto; }
#sl-panel button, #sl-panel input, #sl-panel select {
font-family: inherit;
}
#sl-toggle-btn { font-family: var(--sl-font); }
.sl-panel-header {
padding: 14px 16px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--sl-border);
flex-shrink: 0;
}
.sl-wordmark { display: flex; align-items: center; gap: 7px; }
.sl-wordmark-icon { width: 17px; height: 17px; color: var(--sl-accent); flex-shrink: 0; }
#sl-header-title { font-weight: 700; font-size: 14.5px; letter-spacing: 0.2px; }
.sl-live-tooltip {
position: fixed;
z-index: 2147483647;
background: var(--sl-elevated);
color: var(--sl-text);
padding: 5px 9px;
border-radius: 6px;
font: 500 11px/1.4 var(--sl-font);
white-space: nowrap;
pointer-events: none;
box-shadow: 0 4px 14px rgba(0,0,0,0.45);
border: 1px solid var(--sl-border);
opacity: 0;
transform: translateY(4px);
transition: opacity 0.1s ease, transform 0.1s ease;
}
.sl-live-tooltip.sl-live-tooltip-visible {
opacity: 1;
transform: translateY(0);
transition: opacity 0.12s ease 0.3s, transform 0.12s ease 0.3s;
}
.sl-panel-header button {
background: transparent !important; border: none !important; color: var(--sl-text-muted); cursor: pointer; font-size: 15px;
width: 26px; height: 26px; display: flex; align-items: center; justify-content: center;
border-radius: 50%; transition: background 0.15s ease, color 0.15s ease, transform 0.15s ease;
}
.sl-panel-header button:hover { background: var(--sl-elevated) !important; color: var(--sl-text); }
.sl-panel-header button:active { transform: scale(0.9); }
#sl-settings-btn:hover { transform: rotate(45deg); }
.sl-header-actions { display: flex; gap: 2px; align-items: center; }
#sl-unmatched-toolbar { padding: 10px 14px; border-bottom: 1px solid var(--sl-border); flex-shrink: 0; }
#sl-unmatched-toolbar .sl-btn { width: 100%; }
#sl-list-view { flex: 1; display: flex; flex-direction: column; min-height: 0; }
#sl-disabled-view { flex: 1; display: flex; flex-direction: column; min-height: 0; }
.sl-disabled-body {
flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center;
text-align: center; padding: 28px 24px; gap: 6px;
}
.sl-disabled-icon { width: 32px; height: 32px; color: var(--sl-text-muted); opacity: 0.5; margin-bottom: 8px; }
.sl-disabled-title { font-weight: 700; font-size: 14px; color: var(--sl-text); }
.sl-disabled-sub { color: var(--sl-text-muted); font-size: 12.5px; line-height: 1.5; margin-bottom: 10px; max-width: 240px; }
.sl-disabled-body .sl-btn { width: 220px; }
.sl-track-list { flex: 1; overflow-y: auto; padding: 8px; min-height: 0; }
.sl-track-list::-webkit-scrollbar, #sl-settings-view::-webkit-scrollbar { width: 7px; }
.sl-track-list::-webkit-scrollbar-track, #sl-settings-view::-webkit-scrollbar-track { background: transparent; }
.sl-track-list::-webkit-scrollbar-thumb, #sl-settings-view::-webkit-scrollbar-thumb {
background: var(--sl-elevated); border-radius: 10px;
}
.sl-track-list::-webkit-scrollbar-thumb:hover, #sl-settings-view::-webkit-scrollbar-thumb:hover { background: var(--sl-elevated-hover); }
.sl-track-item {
display: flex; align-items: center; gap: 10px;
padding: 7px 8px; border-radius: var(--sl-radius-sm);
transition: background 0.15s ease, transform 0.15s ease;
}
.sl-track-item:hover { background: var(--sl-elevated); transform: translateX(2px); }
.sl-track-item input[type="checkbox"] { accent-color: var(--sl-accent); width: 15px; height: 15px; flex-shrink: 0; }
.sl-track-item img { width: 36px; height: 36px; border-radius: 6px; flex-shrink: 0; background: var(--sl-elevated); object-fit: cover; box-shadow: 0 1px 4px rgba(0,0,0,0.4); }
.sl-track-meta { overflow: hidden; }
.sl-track-name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-weight: 500; }
.sl-track-artist { color: var(--sl-text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 12px; }
.sl-track-source {
color: var(--sl-text-muted); opacity: 0.7; font-size: 11px; font-style: italic;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 2px;
}
.sl-empty {
padding: 40px 20px; color: var(--sl-text-muted); text-align: center; line-height: 1.5;
display: flex; flex-direction: column; align-items: center; gap: 12px;
}
.sl-empty-icon { width: 30px; height: 30px; opacity: 0.35; }
.sl-tabs {
display: flex; gap: 3px; margin: 10px 12px 0; padding: 3px;
background: var(--sl-elevated); border-radius: var(--sl-radius-md); flex-shrink: 0;
}
.sl-tab {
flex: 1; display: flex; align-items: center; justify-content: center; text-align: center;
background: transparent !important; border: none !important; color: var(--sl-text-muted); cursor: pointer;
padding: 7px 6px; font-size: 12.5px; font-weight: 600; border-radius: var(--sl-radius-sm);
transition: background 0.18s ease, color 0.18s ease;
}
.sl-tab:hover { color: var(--sl-text); }
.sl-tab.active {
color: #06170c; font-weight: 700;
background: linear-gradient(135deg, var(--sl-accent-hover), var(--sl-accent)) !important;
box-shadow: 0 2px 10px rgba(29,185,84,0.35);
}
.sl-unmatched-item {
margin: 0 4px 6px; padding: 10px 10px; border-radius: var(--sl-radius-md);
background: rgba(255,255,255,0.02); border: 1px solid var(--sl-border);
}
.sl-unmatched-query { font-size: 12.5px; color: var(--sl-text); margin-bottom: 6px; font-weight: 500; }
.sl-flag-badge {
display: inline-block; margin-left: 6px; padding: 2px 7px; border-radius: 10px;
background: rgba(240, 165, 40, 0.18); color: #f0a528; font-size: 10px; font-weight: 700;
letter-spacing: 0.2px; text-transform: uppercase; vertical-align: middle;
}
.sl-unmatched-row { display: flex; gap: 6px; }
.sl-unmatched-input {
flex: 1; box-sizing: border-box; padding: 6px 9px; background: var(--sl-elevated);
color: var(--sl-text); border: 1px solid var(--sl-border); border-radius: var(--sl-radius-sm); font-size: 12.5px;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.sl-unmatched-input:focus { outline: none; border-color: var(--sl-accent); box-shadow: 0 0 0 3px var(--sl-accent-soft); }
.sl-unmatched-status { margin-top: 4px; font-size: 11.5px; color: var(--sl-text-muted); }
.sl-unmatched-preview {
display: flex; align-items: center; gap: 8px; margin-top: 8px;
padding: 8px; background: var(--sl-elevated); border: 1px solid var(--sl-border); border-radius: var(--sl-radius-sm);
}
.sl-unmatched-preview img { width: 36px; height: 36px; border-radius: 6px; flex-shrink: 0; background: #333; object-fit: cover; }
.sl-unmatched-preview-meta { flex: 1; overflow: hidden; }
.sl-unmatched-preview-actions { display: flex; flex-direction: column; gap: 4px; flex-shrink: 0; }
.sl-unmatched-preview-actions .sl-btn { padding: 5px 10px; font-size: 11.5px; margin-top: 0; }
.sl-panel-footer { border-top: 1px solid var(--sl-border); padding: 12px 14px; flex-shrink: 0; }
.sl-btn {
display: flex; align-items: center; justify-content: center;
width: 100%; box-sizing: border-box; padding: 10px 14px; margin-top: 8px;
background: linear-gradient(135deg, var(--sl-accent-hover), var(--sl-accent)) !important;
color: #06170c !important; border: none !important; border-radius: 20px;
font-weight: 700; font-size: 13px; line-height: 1; letter-spacing: 0.2px;
cursor: pointer; box-shadow: 0 2px 10px rgba(29,185,84,0.3);
transition: filter 0.15s ease, transform 0.12s ease, box-shadow 0.15s ease;
}
.sl-btn:hover:not(:disabled) { filter: brightness(1.08); transform: translateY(-1px); box-shadow: 0 4px 14px rgba(29,185,84,0.4); }
.sl-btn:active:not(:disabled) { transform: scale(0.98) translateY(0); }
.sl-btn:disabled { opacity: 0.45; cursor: default; box-shadow: none; }
.sl-btn.sl-secondary {
background: transparent !important; color: var(--sl-text) !important; box-shadow: none;
border: 1px solid var(--sl-border-strong) !important;
}
.sl-btn.sl-secondary:hover:not(:disabled) { background: var(--sl-elevated) !important; filter: none; }
select#sl-playlist-select, input#sl-new-playlist-name {
width: 100%; box-sizing: border-box; padding: 8px 9px; margin-top: 8px; background: var(--sl-elevated);
color: var(--sl-text); border: 1px solid var(--sl-border); border-radius: var(--sl-radius-sm); font-size: 13px;
font-family: var(--sl-font);
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
select#sl-playlist-select {
appearance: none; -webkit-appearance: none;
padding-right: 30px;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%239c96ab' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 8px center;
background-size: 16px;
cursor: pointer;
}
select#sl-playlist-select:focus, input#sl-new-playlist-name:focus {
outline: none; border-color: var(--sl-accent); box-shadow: 0 0 0 3px var(--sl-accent-soft);
}
.sl-status { margin-top: 8px; color: var(--sl-text-muted); font-size: 12px; line-height: 1.5; }
.sl-status a { color: var(--sl-accent); }
.sl-segmented {
display: flex; gap: 3px; padding: 3px; margin-top: 2px;
background: var(--sl-elevated); border-radius: var(--sl-radius-md);
}
.sl-segment {
flex: 1; display: flex; align-items: center; justify-content: center; text-align: center;
background: transparent !important; border: none !important; color: var(--sl-text-muted); cursor: pointer;
padding: 7px 6px; font-size: 12px; font-weight: 600; border-radius: var(--sl-radius-sm);
transition: background 0.18s ease, color 0.18s ease;
}
.sl-segment:hover { color: var(--sl-text); }
.sl-segment.active {
color: #06170c; font-weight: 700;
background: linear-gradient(135deg, var(--sl-accent-hover), var(--sl-accent)) !important;
box-shadow: 0 2px 10px rgba(29,185,84,0.3);
}
#sl-settings-view { flex: 1; overflow-y: auto; min-height: 0; }
.sl-settings-body { padding: 4px 0; }
.sl-field-label { display: block; margin-top: 12px; margin-bottom: 4px; color: var(--sl-text-muted); font-size: 12px; }
.sl-field-label:first-child { margin-top: 0; }
.sl-settings-body input[type="text"], .sl-settings-body input[type="password"] {
width: 100%; box-sizing: border-box; padding: 8px 9px; background: var(--sl-elevated);
color: var(--sl-text); border: 1px solid var(--sl-border); border-radius: var(--sl-radius-sm); font-size: 13px;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.sl-settings-body input[type="text"]:focus, .sl-settings-body input[type="password"]:focus {
outline: none; border-color: var(--sl-accent); box-shadow: 0 0 0 3px var(--sl-accent-soft);
}
.sl-settings-section { border-bottom: 1px solid var(--sl-border); }
.sl-settings-section:last-child { border-bottom: none; }
.sl-settings-section summary {
list-style: none; cursor: pointer; padding: 13px 16px;
font-weight: 600; font-size: 13px; display: flex; align-items: center;
justify-content: space-between; user-select: none; transition: background 0.15s ease;
}
.sl-settings-section summary::-webkit-details-marker { display: none; }
.sl-settings-section summary::after {
content: '›'; transform: rotate(90deg); transition: transform 0.2s ease;
color: var(--sl-text-muted); font-size: 16px;
}
.sl-settings-section[open] summary::after { transform: rotate(-90deg); }
.sl-settings-section summary:hover { background: var(--sl-elevated); }
.sl-settings-section-body { padding: 0 16px 18px; }
.sl-hint { margin-top: 10px; font-size: 12px; color: var(--sl-text-muted); line-height: 1.6; }
.sl-hint a { color: var(--sl-accent); }
.sl-code-row { display: flex; align-items: center; gap: 6px; margin-top: 6px; }
.sl-code-row code {
flex: 1; background: var(--sl-elevated); border: 1px solid var(--sl-border); border-radius: var(--sl-radius-sm);
padding: 6px 9px; font-size: 12px; color: var(--sl-text); overflow-x: auto; white-space: nowrap;
}
.sl-code-row button {
display: flex; align-items: center; justify-content: center; text-align: center;
background: var(--sl-elevated) !important; color: var(--sl-text) !important; border: 1px solid var(--sl-border) !important; border-radius: var(--sl-radius-sm);
padding: 6px 11px; font-size: 12px; cursor: pointer; flex-shrink: 0; transition: background 0.15s ease;
}
.sl-code-row button:hover { background: var(--sl-elevated-hover) !important; }
.sl-sub-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
.sl-chip {
display: flex; align-items: center; gap: 5px;
background: var(--sl-elevated); border: 1px solid var(--sl-border); border-radius: 14px;
padding: 4px 7px 4px 11px; font-size: 12px; transition: border-color 0.15s ease;
}
.sl-chip:hover { border-color: var(--sl-border-strong); }
.sl-chip button {
display: flex; align-items: center; justify-content: center;
background: transparent !important; border: none !important; color: var(--sl-text-muted); cursor: pointer;
font-size: 11px; line-height: 1; padding: 3px; border-radius: 50%; transition: color 0.15s ease, background 0.15s ease;
}
.sl-chip button:hover { color: var(--sl-text); background: rgba(255,255,255,0.08); }
.sl-empty-chip { color: var(--sl-text-muted); font-size: 12px; }
.sl-sub-add-row { display: flex; gap: 6px; margin-top: 8px; }
.sl-sub-add-row input { flex: 1; margin-top: 0; }
.sl-btn-inline { width: auto; margin-top: 0; padding: 7px 14px; white-space: nowrap; }
@media (prefers-reduced-motion: reduce) {
#sl-toggle-btn, #sl-toggle-wrap, .sl-track-item, .sl-btn, .spotify-link-icon { transition: none !important; }
#sl-toggle-btn:hover { transform: none; }
}
`);
// ---- Credential handling (app-level, for search only) --------------------
// Credentials are entered via the Settings panel (gear icon) and just read
// from storage here.
function getCredentials() {
return {
id: GM_getValue('spotify_client_id', ''),
secret: GM_getValue('spotify_client_secret', ''),
};
}
function hasCredentials() {
const { id, secret } = getCredentials();
return !!(id && secret);
}
const CACHE_KEY_PREFIX = 'spotify_cache_v2_';
function countCachedLookups() {
if (typeof GM_listValues !== 'function') return 0;
return GM_listValues().filter((k) => k.startsWith(CACHE_KEY_PREFIX)).length;
}
function clearSpotifyCache() {
if (typeof GM_listValues !== 'function') return 0;
const keys = GM_listValues().filter((k) => k.startsWith(CACHE_KEY_PREFIX));
keys.forEach((k) => GM_deleteValue(k));
return keys.length;
}
// ---- Subreddit filter -----------------------------------------------------
function getSubredditFilter() {
try {
return JSON.parse(GM_getValue('sl_subreddit_filter', '[]'));
} catch (e) {
return [];
}
}
function currentSubreddit() {
const m = location.pathname.match(/^\/r\/([^/]+)/i);
return m ? m[1].toLowerCase() : '';
}
function isSubredditAllowed() {
const filter = getSubredditFilter();
if (filter.length === 0) return true; // no filter set = run everywhere
return filter.includes(currentSubreddit());
}
// Visually "tucks" the floating toggle mostly off-screen near the edge
// when the tool isn't enabled on the current subreddit, so it's obvious
// at a glance whether anything is actually active here: full visibility
// when enabled, a barely-there sliver when it's not.
function updateToggleTuckedState() {
const wrap = document.getElementById('sl-toggle-wrap');
const toggle = document.getElementById('sl-toggle-btn');
if (!wrap || !toggle) return;
const enabled = isSubredditAllowed();
wrap.classList.toggle('sl-tucked', !enabled);
toggle.setAttribute(
'data-tooltip',
enabled ? 'Crate: detected Spotify tracks' : `Not enabled on r/${currentSubreddit() || 'this subreddit'}, click to add it`
);
}
function saveSubredditFilter(list) {
GM_setValue('sl_subreddit_filter', JSON.stringify(list));
}
function addSubreddit(name) {
const clean = name.trim().replace(/^\/?r\//i, '').toLowerCase();
if (!clean) return;
const filter = getSubredditFilter();
if (!filter.includes(clean)) {
filter.push(clean);
saveSubredditFilter(filter);
}
updateToggleTuckedState();
}
function removeSubreddit(name) {
saveSubredditFilter(getSubredditFilter().filter((s) => s !== name));
updateToggleTuckedState();
}
function renderSubredditChips(view) {
const chipsEl = view.querySelector('#sl-sub-chips');
const filter = getSubredditFilter();
chipsEl.innerHTML = filter.length
? filter.map((s) => `<span class="sl-chip">r/${escapeHtml(s)}<button data-sub="${escapeHtml(s)}" title="Remove">✕</button></span>`).join('')
: '<span class="sl-empty-chip">No filter, running on all subreddits</span>';
chipsEl.querySelectorAll('button[data-sub]').forEach((btn) => {
btn.addEventListener('click', () => {
removeSubreddit(btn.getAttribute('data-sub'));
renderSubredditChips(view);
});
});
const cur = currentSubreddit();
const addCurrentBtn = view.querySelector('#sl-sub-add-current-btn');
if (cur && !filter.includes(cur)) {
addCurrentBtn.style.display = '';
addCurrentBtn.textContent = `+ Add current subreddit (r/${cur})`;
addCurrentBtn.onclick = () => {
addSubreddit(cur);
renderSubredditChips(view);
};
} else {
addCurrentBtn.style.display = 'none';
}
}
// ---- App token (client credentials flow, read-only search) --------------
let tokenPromise = null;
function getToken() {
const cachedToken = GM_getValue('spotify_token', null);
const cachedExpiry = GM_getValue('spotify_token_expiry', 0);
if (cachedToken && Date.now() < cachedExpiry) return Promise.resolve(cachedToken);
if (tokenPromise) return tokenPromise;
const { id, secret } = getCredentials();
if (!id || !secret) return Promise.reject(new Error('No Spotify credentials provided'));
tokenPromise = postTokenRequest(
{ grant_type: 'client_credentials' },
{ Authorization: 'Basic ' + btoa(`${id}:${secret}`) }
).then((json) => {
GM_setValue('spotify_token', json.access_token);
GM_setValue('spotify_token_expiry', Date.now() + (json.expires_in - 60) * 1000);
return json.access_token;
}).finally(() => { tokenPromise = null; });
return tokenPromise;
}
// ---- Match confidence check -----------------------------------------------
// Spotify's search is fuzzy, and for unusual or garbled queries it will
// sometimes return a completely unrelated top result rather than nothing.
// Before trusting a result enough to auto-insert it, compare what Spotify
// returned against what was actually detected in the comment.
function normalizeForCompare(s) {
return (s || '')
.toLowerCase()
.replace(/\(.*?\)/g, '')
.replace(/feat\.?.*$/i, '')
.replace(/[^a-z0-9]+/g, ' ')
.trim()
.replace(/\s+/g, ' ');
}
function levenshtein(a, b) {
const m = a.length, n = b.length;
if (m === 0) return n;
if (n === 0) return m;
const dp = new Array(n + 1);
for (let j = 0; j <= n; j++) dp[j] = j;
for (let i = 1; i <= m; i++) {
let prev = dp[0];
dp[0] = i;
for (let j = 1; j <= n; j++) {
const temp = dp[j];
dp[j] = a[i - 1] === b[j - 1] ? prev : 1 + Math.min(prev, dp[j], dp[j - 1]);
prev = temp;
}
}
return dp[n];
}
function textSimilarity(a, b) {
a = normalizeForCompare(a);
b = normalizeForCompare(b);
if (!a || !b) return 0;
if (a === b) return 1;
return 1 - levenshtein(a, b) / Math.max(a.length, b.length);
}
// Tuned against real false-match examples: every bad match seen so far had
// artist similarity under 0.2; every genuine match (including a multi-artist
// collab) scored at least 0.29. Deliberately biased toward flagging more
// rather than fewer, since a flagged match just costs one confirmation
// click, while a wrong auto-inserted one is silently bad data.
function isConfidentMatch(detectedArtist, detectedTitle, track) {
const titleSim = textSimilarity(detectedTitle, track.name);
const artistSim = textSimilarity(detectedArtist, track.artists);
return titleSim >= 0.3 && artistSim >= 0.25;
}
// ---- Track search with local cache ----------------------------------------
function searchTrackCached(query, opts = {}) {
const cacheKey = CACHE_KEY_PREFIX + query.toLowerCase();
if (!opts.bypassCache) {
const cached = GM_getValue(cacheKey, null);
if (cached) {
const parsed = JSON.parse(cached);
if (Date.now() - parsed.ts < CACHE_TTL_MS) return Promise.resolve(parsed.track);
}
}
return getToken().then((token) => {
return new Promise((resolve) => {
GM_xmlhttpRequest({
method: 'GET',
url: `https://api.spotify.com/v1/search?type=track&limit=1&q=${encodeURIComponent(query)}`,
headers: { Authorization: `Bearer ${token}` },
onload: (res) => {
if (res.status < 200 || res.status >= 300) {
// API error (rate-limited, expired token, etc.), don't cache
// this as "no match," or a transient failure would silently
// poison the result for a full week.
resolve(null);
return;
}
try {
const json = JSON.parse(res.responseText);
const t = json.tracks && json.tracks.items && json.tracks.items[0];
const track = t ? {
id: t.id,
name: t.name,
artists: t.artists.map((a) => a.name).join(', '),
image: (t.album.images[t.album.images.length - 1] || {}).url || '',
url: t.external_urls.spotify,
uri: t.uri,
} : null;
GM_setValue(cacheKey, JSON.stringify({ track, ts: Date.now() }));
resolve(track);
} catch (e) {
resolve(null);
}
},
onerror: () => resolve(null),
});
});
}).catch(() => null);
}
// ---- Queue to throttle requests -------------------------------------------
// Bounded concurrency rather than strictly one-at-a-time: a thread with 30
// song mentions used to take a flat 9s (30 * 300ms) to resolve. With 3
// workers each still individually spaced by REQUEST_DELAY_MS, that drops
// to roughly a third of the time without meaningfully increasing load on
// Spotify's API (still well under any reasonable rate limit).
const queue = [];
const MAX_CONCURRENT_REQUESTS = 3;
let activeWorkers = 0;
function enqueue(fn) {
queue.push(fn);
pumpQueue();
}
function pumpQueue() {
while (activeWorkers < MAX_CONCURRENT_REQUESTS && queue.length > 0) {
activeWorkers++;
const fn = queue.shift();
fn().finally(() => {
setTimeout(() => {
activeWorkers--;
pumpQueue();
}, REQUEST_DELAY_MS);
});
}
}
// ---- DOM scanning -----------------------------------------------------
const PROCESSED_ATTR = 'data-sl-processed';
function candidateTextNodes(root) {
return root.querySelectorAll(`p:not([${PROCESSED_ATTR}]), div.md > *:not([${PROCESSED_ATTR}])`);
}
function isVisible(el) {
// Filters out visually-hidden accessibility duplicates that new-Reddit's
// web components sometimes render alongside the real comment text.
// Checking getClientRects() alone isn't enough; visibility:hidden and
// clipped "sr-only" elements still report non-empty rects.
if (!el || !(el instanceof Element)) return true;
if (el.hasAttribute('aria-hidden') && el.getAttribute('aria-hidden') === 'true') return false;
const cls = el.className && el.className.toString().toLowerCase();
if (cls && /sr-only|visually-hidden|screen-reader/.test(cls)) return false;
const style = window.getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden' || parseFloat(style.opacity) === 0) return false;
const rect = el.getBoundingClientRect();
if (rect.width <= 1 && rect.height <= 1) return false; // classic clipped sr-only pattern
if (el.getClientRects().length === 0) return false;
return true;
}
function nearestStableId(el) {
// Reddit tags comment containers with an id like "t1_xxxxx" (old Reddit)
// or a thingid/id attribute on the custom element (new Reddit). Used to
// tell "the same comment rendered twice" apart from "two different
// comments that happen to mention the same song."
if (!el) return null;
const withId = el.closest('[id]');
if (withId && withId.id) return withId.id;
const withThingId = el.closest('[thingid]');
if (withThingId) return withThingId.getAttribute('thingid');
return null;
}
const seenCommentQueries = new Set();
function makeIcon(track) {
const a = document.createElement('a');
a.href = track.url;
a.target = '_blank';
a.rel = 'noopener noreferrer';
a.className = 'spotify-link-icon';
a.title = 'Open on Spotify';
a.innerHTML = SPOTIFY_SVG;
return a;
}
function makeUnmatchedIcon(query) {
const a = document.createElement('a');
a.href = `https://open.spotify.com/search/${encodeURIComponent(query)}`;
a.target = '_blank';
a.rel = 'noopener noreferrer';
a.className = 'spotify-link-icon';
a.setAttribute('data-tooltip', 'Not confidently matched, check it in Crate');
a.innerHTML = SPOTIFY_SVG_RED;
return a;
}
function getTextNodesIn(el) {
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null);
const nodes = [];
let n;
while ((n = walker.nextNode())) nodes.push(n);
return nodes;
}
// Splits matched spans out of a text node (in descending order, so earlier
// indices stay valid), skipping anything already seen for this comment,
// and hands each surviving match a placeholder comment node to replace
// once resolved. Shared by both the automatic strict pass and the manual
// loose scan; they only differ in what happens after (auto-search vs.
// straight to the review list).
// Reddit's own placeholder text for deleted/removed comments happens to
// grammatically match "Title by Artist" ("Comment deleted by user"), so
// it needs an explicit exclusion rather than relying on the regex alone.
const SYSTEM_MESSAGE_RE = /^comment (deleted|removed) by \S+$/i;
function splitMatchesIntoPlaceholders(textNode, matches, onMatch) {
const stableId = nearestStableId(textNode.parentElement);
const sorted = [...matches].sort((a, b) => b.index - a.index);
for (const m of sorted) {
if (SYSTEM_MESSAGE_RE.test((m.raw || '').trim())) continue;
const query = `${m.artist} ${m.title}`;
if (query.length < 5) continue;
if (stableId) {
const dedupeKey = `${stableId}::${query.toLowerCase()}`;
if (seenCommentQueries.has(dedupeKey)) continue; // same comment rendered twice, or already found by an earlier pass
seenCommentQueries.add(dedupeKey);
}
const matchEnd = m.index + m.length;
const remainder = textNode.splitText(matchEnd);
const placeholder = document.createComment('sl-pending');
textNode.parentNode.insertBefore(placeholder, remainder);
onMatch(query, placeholder, m.artist, m.title);
}
}
function toMatchList(regexMatches) {
return [...regexMatches].map((m) => ({ index: m.index, length: m[0].length, artist: m[1].trim(), title: m[2].trim(), raw: m[0] }));
}
function processTextNode(textNode) {
const text = textNode.nodeValue;
if (!text || text.length < 6) return;
const matches = toMatchList(text.matchAll(SONG_REGEX));
if (matches.length === 0) return;
splitMatchesIntoPlaceholders(textNode, matches, (query, placeholder, artist, title) => {
enqueue(() =>
searchTrackCached(query).then((track) => {
if (!placeholder.parentNode) return;
if (track && isConfidentMatch(artist, title, track)) {
placeholder.parentNode.replaceChild(makeIcon(track), placeholder);
registerTrack(track, query);
} else if (track) {
// Spotify found something, but it doesn't look like a real match
// for what was actually detected, flag it for review instead
// of trusting it blindly.
registerUnmatched(query, placeholder, track);
} else {
registerUnmatched(query, placeholder);
}
})
);
});
}
function processElement(el) {
el.setAttribute(PROCESSED_ATTR, '1');
if (!isVisible(el)) return;
getTextNodesIn(el).forEach(processTextNode);
}
function scan(root = document.body) {
if (!isSubredditAllowed()) return;
candidateTextNodes(root).forEach(processElement);
}
function collectLooseMatches(text) {
const found = [
...toMatchList(text.matchAll(LOOSE_SONG_REGEX)),
// LOOSE_BY_REGEX captures "Title by Artist"; groups are reversed relative to the dash pattern.
...[...text.matchAll(LOOSE_BY_REGEX)].map((m) => ({ index: m.index, length: m[0].length, artist: m[2].trim(), title: m[1].trim(), raw: m[0] })),
];
found.sort((a, b) => b.index - a.index);
// Drop overlaps between the two patterns (keep whichever sorted first).
const kept = [];
for (const m of found) {
const end = m.index + m.length;
const overlaps = kept.some((k) => !(end <= k.index || m.index >= k.index + k.length));
if (!overlaps) kept.push(m);
}
return kept;
}
function processLooseTextNode(textNode) {
const text = textNode.nodeValue;
if (!text || text.length < 6) return;
const matches = collectLooseMatches(text);
if (matches.length === 0) return;
// Loose matches go straight to the review list, never auto-searched
// or auto-linked in-page, since the false-positive rate is higher.
splitMatchesIntoPlaceholders(textNode, matches, (query, placeholder) => registerUnmatched(query, placeholder));
}
function looseScan() {
if (!isSubredditAllowed()) return;
document.querySelectorAll(`[${PROCESSED_ATTR}]`).forEach((el) => {
if (!isVisible(el)) return;
getTextNodesIn(el).forEach(processLooseTextNode);
});
}
// Rather than scanning synchronously inside the observer callback (which
// during a heavy insertion burst, like Reddit loading a page of comments
// at once, means many interleaved layout reads and DOM writes back to
// back), collect what changed and process it once per frame.
const pendingScanNodes = new Set();
let scanFlushScheduled = false;
function scheduleScanFlush() {
if (scanFlushScheduled) return;
scanFlushScheduled = true;
requestAnimationFrame(() => {
scanFlushScheduled = false;
const nodes = [...pendingScanNodes];
pendingScanNodes.clear();
nodes.forEach((node) => { if (node.isConnected) scan(node); });
});
}
const observer = new MutationObserver((mutations) => {
for (const m of mutations) {
for (const node of m.addedNodes) {
if (node.nodeType === 1) pendingScanNodes.add(node);
}
}
if (pendingScanNodes.size > 0) scheduleScanFlush();
});
// =========================================================================
// TRACK STORE + SIDEBAR
// =========================================================================
const trackStore = new Map(); // id -> track
const selectedIds = new Set();
const unmatchedStore = new Map(); // uid -> { id, query, placeholder }
let unmatchedCounter = 0;
function registerTrack(track, sourceText) {
if (!track || !track.id || trackStore.has(track.id)) return;
trackStore.set(track.id, sourceText ? { ...track, sourceText } : track);
selectedIds.add(track.id); // default: selected
renderTrackList();
updateBadge();
}
function registerUnmatched(query, placeholder, suggestedTrack) {
const uid = 'u' + (++unmatchedCounter);
let iconEl = placeholder;
if (placeholder && placeholder.parentNode) {
iconEl = makeUnmatchedIcon(query);
placeholder.parentNode.replaceChild(iconEl, placeholder);
}
unmatchedStore.set(uid, { id: uid, query, placeholder: iconEl, suggestedTrack: suggestedTrack || null });
renderUnmatchedList();
}
function updateBadge() {
const badge = document.getElementById('sl-badge');
if (badge) badge.textContent = trackStore.size;
}
function renderTrackList() {
const list = document.getElementById('sl-track-list');
if (!list) return;
if (trackStore.size === 0) {
list.innerHTML = `
<div class="sl-empty">
<svg viewBox="0 0 24 24" class="sl-empty-icon" fill="none"><path d="M3 8.5 5.5 3h13L21 8.5M3 8.5V19a1 1 0 0 0 1 1h16a1 1 0 0 0 1-1V8.5M3 8.5h18M9 8.5V12a3 3 0 0 0 6 0V8.5" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/></svg>
<div>No tracks detected yet. Scroll through some comments.</div>
</div>
`;
return;
}
list.innerHTML = '';
trackStore.forEach((track) => {
const item = document.createElement('label');
item.className = 'sl-track-item';
item.innerHTML = `
<input type="checkbox" data-id="${track.id}" ${selectedIds.has(track.id) ? 'checked' : ''}>
<img src="${track.image}" alt="">
<div class="sl-track-meta">
<div class="sl-track-name">${escapeHtml(track.name)}</div>
<div class="sl-track-artist">${escapeHtml(track.artists)}</div>
${track.sourceText ? `<div class="sl-track-source">"${escapeHtml(track.sourceText)}"</div>` : ''}
</div>
`;
item.querySelector('input').addEventListener('change', (e) => {
if (e.target.checked) selectedIds.add(track.id);
else selectedIds.delete(track.id);
});
list.appendChild(item);
});
}
const escapeScratchEl = document.createElement('div');
function escapeHtml(str) {
escapeScratchEl.textContent = str;
return escapeScratchEl.innerHTML;
}
function renderUnmatchedList() {
const list = document.getElementById('sl-unmatched-list');
const countEl = document.getElementById('sl-unmatched-count');
if (countEl) countEl.textContent = unmatchedStore.size;
if (!list) return;
if (unmatchedStore.size === 0) {
list.innerHTML = `
<div class="sl-empty">
<svg viewBox="0 0 24 24" class="sl-empty-icon" fill="none"><path d="M20 6 9 17l-5-5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>
<div>Nothing unmatched right now.</div>
</div>
`;
return;
}
list.innerHTML = '';
unmatchedStore.forEach((entry) => {
const item = document.createElement('div');
item.className = 'sl-unmatched-item';
item.innerHTML = `
<div class="sl-unmatched-query">
${escapeHtml(entry.query)}
${entry.suggestedTrack ? '<span class="sl-flag-badge">needs a look</span>' : ''}
</div>
<div class="sl-unmatched-row">
<input type="text" class="sl-unmatched-input" value="${escapeHtml(entry.query)}">
<button class="sl-btn sl-secondary sl-btn-inline sl-search-btn">Search</button>
</div>
<div class="sl-unmatched-preview" style="display:none;"></div>
<div class="sl-unmatched-status"></div>
`;
const input = item.querySelector('.sl-unmatched-input');
const searchBtn = item.querySelector('.sl-search-btn');
const preview = item.querySelector('.sl-unmatched-preview');
const status = item.querySelector('.sl-unmatched-status');
function showPreview(track) {
status.textContent = '';
preview.style.display = '';
preview.innerHTML = `
<img src="${track.image}" alt="">
<div class="sl-unmatched-preview-meta">
<div class="sl-track-name">${escapeHtml(track.name)}</div>
<div class="sl-track-artist">${escapeHtml(track.artists)}</div>
</div>
<div class="sl-unmatched-preview-actions">
<button class="sl-btn sl-btn-inline sl-confirm-btn">This one</button>
<button class="sl-btn sl-secondary sl-btn-inline sl-reject-btn">Not it</button>
</div>
`;
preview.querySelector('.sl-confirm-btn').addEventListener('click', () => {
if (entry.placeholder.parentNode) {
entry.placeholder.parentNode.replaceChild(makeIcon(track), entry.placeholder);
}
registerTrack(track, entry.query);
unmatchedStore.delete(entry.id);
renderUnmatchedList();
});
preview.querySelector('.sl-reject-btn').addEventListener('click', () => {
preview.style.display = 'none';
status.textContent = 'Try a different search.';
});
}
const doSearch = () => {
const q = input.value.trim();
if (!q) return;
searchBtn.disabled = true;
preview.style.display = 'none';
status.textContent = 'Searching…';
searchTrackCached(q, { bypassCache: true }).then((track) => {
searchBtn.disabled = false;
if (!track) {
status.textContent = 'Still no match, try adjusting the search.';
return;
}
// Show what was found and wait for explicit confirmation before
// touching the actual comment or adding it to Matched, a blind
// top search result can easily be the wrong song.
showPreview(track);
});
};
searchBtn.addEventListener('click', doSearch);
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') doSearch(); });
if (entry.suggestedTrack) {
// Spotify found something for this one already, but it didn't look
// enough like a real match to trust automatically, show it as a
// flagged suggestion up front instead of making them search again.
status.textContent = "Found a possible match, but it doesn't look quite right. Double-check it:";
showPreview(entry.suggestedTrack);
}
list.appendChild(item);
});
}
function buildSidebar() {
const wrap = document.createElement('div');
wrap.id = 'sl-toggle-wrap';
const toggle = document.createElement('button');
toggle.id = 'sl-toggle-btn';
toggle.setAttribute('data-tooltip', 'Crate: detected Spotify tracks');
toggle.innerHTML =
'<svg class="sl-toggle-icon" viewBox="0 0 24 24" fill="#1DB954" fill-rule="evenodd"><path d="M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.66 0 12 0zm5.521 17.34c-.24.359-.66.48-1.021.24-2.82-1.74-6.36-2.101-10.561-1.141-.418.122-.779-.179-.899-.539-.12-.421.18-.78.54-.9 4.56-1.021 8.52-.6 11.64 1.32.42.18.479.659.301 1.02zm1.44-3.3c-.301.42-.841.6-1.262.3-3.239-1.98-8.159-2.58-11.939-1.38-.479.12-1.02-.12-1.14-.6-.12-.48.12-1.021.6-1.141 4.32-1.32 9.719-.66 13.439 1.621.361.24.54.78.301 1.2zm.12-3.36C15.24 8.4 8.82 8.16 5.16 9.301c-.6.179-1.2-.181-1.38-.721-.18-.6.18-1.2.72-1.38C8.64 5.94 15.6 6.24 19.681 8.64c.539.3.719 1.02.42 1.56-.3.421-1.02.599-1.62.3z"/></svg>';
wrap.appendChild(toggle);
const badge = document.createElement('span');
badge.id = 'sl-badge';
badge.textContent = '0';
wrap.appendChild(badge); // sibling of the button, not inside it; filter on the button can't clip this
document.body.appendChild(wrap);
const panel = document.createElement('div');
panel.id = 'sl-panel';
panel.innerHTML = `
<div class="sl-panel-header">
<div class="sl-wordmark">
<svg viewBox="0 0 24 24" class="sl-wordmark-icon" fill="none"><path d="M3 8.5 5.5 3h13L21 8.5M3 8.5V19a1 1 0 0 0 1 1h16a1 1 0 0 0 1-1V8.5M3 8.5h18M9 8.5V12a3 3 0 0 0 6 0V8.5" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/></svg>
<span id="sl-header-title">Crate</span>
</div>
<div class="sl-header-actions">
<button id="sl-clear-btn" data-tooltip="Clear this list">🗑</button>
<button id="sl-settings-btn" data-tooltip="Settings">⚙</button>
<button id="sl-close-btn" data-tooltip="Close">✕</button>
</div>
</div>
<div id="sl-list-view">
<div class="sl-tabs">
<button class="sl-tab active" data-tab="matched">Matched</button>
<button class="sl-tab" data-tab="unmatched">Unmatched (<span id="sl-unmatched-count">0</span>)</button>
</div>
<div id="sl-unmatched-toolbar">
<button class="sl-btn sl-secondary sl-btn-inline" id="sl-loose-scan-btn">Scan for more (loose)</button>
</div>
<div id="sl-track-list" class="sl-track-list"></div>
<div id="sl-unmatched-list" class="sl-track-list" style="display:none;"></div>
<div class="sl-panel-footer" id="sl-footer"></div>
</div>
<div id="sl-disabled-view" style="display:none;"></div>
<div id="sl-settings-view" style="display:none;"></div>
`;
document.body.appendChild(panel);
toggle.addEventListener('click', () => {
const isOpening = !panel.classList.contains('sl-open');
panel.classList.toggle('sl-open');
if (isOpening) showPanelForCurrentSubreddit();
});
panel.querySelector('#sl-close-btn').addEventListener('click', () => panel.classList.remove('sl-open'));
panel.querySelector('#sl-settings-btn').addEventListener('click', () => toggleSettingsView());
panel.querySelector('#sl-loose-scan-btn').addEventListener('click', (e) => {
e.currentTarget.textContent = 'Scanning…';
e.currentTarget.disabled = true;
looseScan();
panel.querySelector('.sl-tab[data-tab="unmatched"]').click();
setTimeout(() => {
e.currentTarget.textContent = 'Scan for more (loose)';
e.currentTarget.disabled = false;
}, 400);
});
panel.querySelector('#sl-clear-btn').addEventListener('click', () => {
const showingMatched = panel.querySelector('#sl-track-list').style.display !== 'none';
if (showingMatched) {
trackStore.clear();
selectedIds.clear();
renderTrackList();
updateBadge();
} else {
unmatchedStore.clear();
renderUnmatchedList();
}
});
panel.querySelectorAll('.sl-tab').forEach((tab) => {
tab.addEventListener('click', () => {
panel.querySelectorAll('.sl-tab').forEach((t) => t.classList.remove('active'));
tab.classList.add('active');
const showMatched = tab.dataset.tab === 'matched';
panel.querySelector('#sl-track-list').style.display = showMatched ? '' : 'none';
panel.querySelector('#sl-unmatched-list').style.display = showMatched ? 'none' : '';
});
});
renderTrackList();
renderUnmatchedList();
renderFooter();
}
function showPanelForCurrentSubreddit() {
const listView = document.getElementById('sl-list-view');
const disabledView = document.getElementById('sl-disabled-view');
const settingsView = document.getElementById('sl-settings-view');
const title = document.getElementById('sl-header-title');
const settingsBtn = document.getElementById('sl-settings-btn');
const clearBtn = document.getElementById('sl-clear-btn');
settingsView.style.display = 'none'; // always reset out of settings on a fresh open
title.textContent = 'Crate';
settingsBtn.textContent = '⚙';
settingsBtn.setAttribute('data-tooltip', 'Settings');
if (isSubredditAllowed()) {
disabledView.style.display = 'none';
listView.style.display = '';
clearBtn.style.display = '';
} else {
disabledView.style.display = '';
listView.style.display = 'none';
clearBtn.style.display = 'none';
renderDisabledView();
}
}
function jumpToSubredditFilterSettings() {
document.getElementById('sl-disabled-view').style.display = 'none';
toggleSettingsView();
setTimeout(() => {
const subSection = [...document.querySelectorAll('.sl-settings-section')]
.find((d) => d.querySelector('summary') && d.querySelector('summary').textContent.includes('Subreddit Filter'));
if (subSection) {
subSection.open = true;
subSection.scrollIntoView({ block: 'nearest' });
}
}, 0);
}
function renderDisabledView() {
const view = document.getElementById('sl-disabled-view');
const sub = currentSubreddit();
const label = sub ? `r/${sub}` : 'this subreddit';
view.innerHTML = `
<div class="sl-disabled-body">
<svg viewBox="0 0 24 24" class="sl-disabled-icon" fill="none"><path d="M3 8.5 5.5 3h13L21 8.5M3 8.5V19a1 1 0 0 0 1 1h16a1 1 0 0 0 1-1V8.5M3 8.5h18M9 8.5V12a3 3 0 0 0 6 0V8.5" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/></svg>
<div class="sl-disabled-title">${escapeHtml(label)} isn't in the list</div>
<div class="sl-disabled-sub">Crate only runs on subreddits you've added, so it stays out of the way everywhere else.</div>
<button class="sl-btn" id="sl-enable-here-btn">Add ${escapeHtml(label)}</button>
<button class="sl-btn sl-secondary" id="sl-manage-subs-btn">Manage subreddit list</button>
</div>
`;
view.querySelector('#sl-enable-here-btn').addEventListener('click', () => {
addSubreddit(sub);
showPanelForCurrentSubreddit();
scan(); // this page was never scanned while disabled, so run it now
});
view.querySelector('#sl-manage-subs-btn').addEventListener('click', jumpToSubredditFilterSettings);
}
function toggleSettingsView() {
const listView = document.getElementById('sl-list-view');
const disabledView = document.getElementById('sl-disabled-view');
const settingsView = document.getElementById('sl-settings-view');
const title = document.getElementById('sl-header-title');
const settingsBtn = document.getElementById('sl-settings-btn');
const clearBtn = document.getElementById('sl-clear-btn');
const showingSettings = settingsView.style.display !== 'none';
if (showingSettings) {
settingsView.style.display = 'none';
title.textContent = 'Crate';
settingsBtn.textContent = '⚙';
settingsBtn.setAttribute('data-tooltip', 'Settings');
// Return to whichever view is actually correct for this subreddit,
// rather than assuming the list view (it may not be enabled here).
if (isSubredditAllowed()) {
listView.style.display = '';
disabledView.style.display = 'none';
clearBtn.style.display = '';
} else {
listView.style.display = 'none';
disabledView.style.display = '';
clearBtn.style.display = 'none';
renderDisabledView();
}
} else {
renderSettings();
settingsView.style.display = '';
listView.style.display = 'none';
disabledView.style.display = 'none';
title.textContent = 'Settings';
settingsBtn.textContent = '←';
settingsBtn.setAttribute('data-tooltip', 'Back to tracks');
clearBtn.style.display = 'none'; // clear-list doesn't apply while viewing settings
}
}
function renderSettings() {
const view = document.getElementById('sl-settings-view');
const { id, secret } = getCredentials();
view.innerHTML = `
<div class="sl-settings-body">
<details class="sl-settings-section" ${hasCredentials() ? '' : 'open'}>
<summary>Spotify API Credentials</summary>
<div class="sl-settings-section-body">
<label class="sl-field-label">Spotify Client ID</label>
<input id="sl-set-clientid" type="text" placeholder="Client ID" value="${escapeHtml(id)}">
<label class="sl-field-label">Spotify Client Secret</label>
<input id="sl-set-clientsecret" type="password" placeholder="Client Secret" value="${escapeHtml(secret)}">
<button class="sl-btn" id="sl-save-creds-btn">Save Credentials</button>
<div class="sl-status" id="sl-cred-status">
<a href="https://developer.spotify.com/dashboard" target="_blank" rel="noopener noreferrer">Open Spotify Developer Dashboard →</a>
</div>
<div class="sl-hint">
<div>In your app's <a href="https://developer.spotify.com/dashboard" target="_blank" rel="noopener noreferrer">Settings</a>, add this exact Redirect URI:</div>
<div class="sl-code-row">
<code>${REDIRECT_URI}</code>
<button id="sl-copy-redirect" title="Copy">Copy</button>
</div>
</div>
</div>
</details>
<details class="sl-settings-section">
<summary>Subreddit Filter</summary>
<div class="sl-settings-section-body">
<label class="sl-field-label">Only run on these subreddits</label>
<div class="sl-sub-chips" id="sl-sub-chips"></div>
<div class="sl-sub-add-row">
<input id="sl-sub-input" type="text" placeholder="subreddit name">
<button class="sl-btn sl-secondary sl-btn-inline" id="sl-sub-add-btn">Add</button>
</div>
<button class="sl-btn sl-secondary" id="sl-sub-add-current-btn" style="display:none;"></button>
<div class="sl-status" id="sl-subs-status">Leave empty to run on every subreddit.</div>
</div>
</details>
<details class="sl-settings-section">
<summary>Advanced</summary>
<div class="sl-settings-section-body">
<div class="sl-status" id="sl-cache-status" style="margin-top:0;">
${countCachedLookups()} cached search result(s) stored locally.
</div>
<button class="sl-btn sl-secondary" id="sl-clear-cache-btn">Clear Search Cache</button>
</div>
</details>
${isConnected() ? `
<details class="sl-settings-section">
<summary>Account</summary>
<div class="sl-settings-section-body">
<button class="sl-btn sl-secondary" id="sl-disconnect-btn">Disconnect Spotify Account</button>
</div>
</details>
` : ''}
</div>
`;
view.querySelector('#sl-clear-cache-btn').addEventListener('click', () => {
const n = clearSpotifyCache();
view.querySelector('#sl-cache-status').textContent = `Cleared ${n} cached result(s).`;
});
view.querySelector('#sl-save-creds-btn').addEventListener('click', () => {
const newId = view.querySelector('#sl-set-clientid').value.trim();
const newSecret = view.querySelector('#sl-set-clientsecret').value.trim();
GM_setValue('spotify_client_id', newId);
GM_setValue('spotify_client_secret', newSecret);
view.querySelector('#sl-cred-status').textContent = 'Saved.';
renderFooter();
});
view.querySelector('#sl-copy-redirect').addEventListener('click', (e) => {
GM_setClipboard(REDIRECT_URI);
const btn = e.currentTarget;
btn.textContent = 'Copied!';
setTimeout(() => { btn.textContent = 'Copy'; }, 1200);
});
renderSubredditChips(view);
view.querySelector('#sl-sub-add-btn').addEventListener('click', () => {
const input = view.querySelector('#sl-sub-input');
addSubreddit(input.value);
input.value = '';
renderSubredditChips(view);
});
view.querySelector('#sl-sub-input').addEventListener('keydown', (e) => {
if (e.key === 'Enter') view.querySelector('#sl-sub-add-btn').click();
});
const disconnectBtn = view.querySelector('#sl-disconnect-btn');
if (disconnectBtn) {
disconnectBtn.addEventListener('click', () => {
GM_setValue('spotify_user_token', null);
GM_setValue('spotify_user_refresh', null);
GM_setValue('spotify_user_token_expiry', 0);
renderSettings();
renderFooter();
});
}
}
function renderFooter() {
const footer = document.getElementById('sl-footer');
if (!footer) return;
if (!hasCredentials()) {
footer.innerHTML = `<div class="sl-status">Add your Spotify Client ID/Secret in <a href="#" id="sl-goto-settings">Settings</a> to get started.</div>`;
footer.querySelector('#sl-goto-settings').addEventListener('click', (e) => {
e.preventDefault();
toggleSettingsView();
});
return;
}
if (!isConnected()) {
footer.innerHTML = `<button class="sl-btn" id="sl-connect-btn">Connect Spotify Account</button>`;
footer.querySelector('#sl-connect-btn').addEventListener('click', async () => {
footer.querySelector('#sl-connect-btn').textContent = 'Connecting…';
try {
await connectSpotifyAccount();
renderFooter();
} catch (e) {
setStatus('Connection failed, see console for details.');
console.error(e);
}
});
return;
}
footer.innerHTML = `
<div class="sl-segmented" id="sl-target-toggle">
<button type="button" class="sl-segment active" data-value="existing">Existing playlist</button>
<button type="button" class="sl-segment" data-value="new">New playlist</button>
</div>
<select id="sl-playlist-select"><option>Loading playlists…</option></select>
<input id="sl-new-playlist-name" type="text" placeholder="New playlist name" style="display:none;">
<button class="sl-btn" id="sl-add-btn">Add Selected Tracks</button>
<div class="sl-status" id="sl-status"></div>
`;
const select = footer.querySelector('#sl-playlist-select');
const nameInput = footer.querySelector('#sl-new-playlist-name');
const targetToggle = footer.querySelector('#sl-target-toggle');
let selectedTarget = 'existing';
targetToggle.querySelectorAll('.sl-segment').forEach((seg) => {
seg.addEventListener('click', () => {
targetToggle.querySelectorAll('.sl-segment').forEach((s) => s.classList.remove('active'));
seg.classList.add('active');
selectedTarget = seg.dataset.value;
const isNew = selectedTarget === 'new';
select.style.display = isNew ? 'none' : '';
nameInput.style.display = isNew ? '' : 'none';
});
});
fetchMyPlaylists()
.then((playlists) => {
select.innerHTML = playlists.map((p) => `<option value="${p.id}">${escapeHtml(p.name)}</option>`).join('');
})
.catch(() => { select.innerHTML = '<option>Could not load playlists</option>'; });
footer.querySelector('#sl-add-btn').addEventListener('click', async () => {
const btn = footer.querySelector('#sl-add-btn');
const tracks = [...selectedIds].map((id) => trackStore.get(id)).filter(Boolean);
if (tracks.length === 0) { setStatus('Select at least one track first.'); return; }
btn.disabled = true;
setStatus('Working…');
try {
const isNew = selectedTarget === 'new';
let playlistId;
let existingUris = new Set();
if (isNew) {
const name = nameInput.value.trim() || 'Reddit finds';
const me = await getMe();
const playlist = await createPlaylist(me.id, name);
playlistId = playlist.id;
// brand new playlist, nothing to dedupe against
} else {
playlistId = select.value;
setStatus('Checking for duplicates…');
existingUris = await getPlaylistTrackUris(playlistId);
}
const newTracks = tracks.filter((t) => !existingUris.has(t.uri));
const skipped = tracks.length - newTracks.length;
if (newTracks.length > 0) {
setStatus('Adding tracks…');
await addTracksToPlaylist(playlistId, newTracks.map((t) => t.uri));
}
if (skipped > 0 && newTracks.length > 0) {
setStatus(`Added ${newTracks.length} track(s), skipped ${skipped} already in the playlist.`);
} else if (skipped > 0) {
setStatus(`All ${skipped} selected track(s) were already in the playlist, nothing added.`);
} else {
setStatus(`Added ${newTracks.length} track(s) to the playlist.`);
}
} catch (e) {
console.error(e);
setStatus('Something went wrong, see console for details.');
} finally {
btn.disabled = false;
}
});
}
function setStatus(msg) {
const el = document.getElementById('sl-status');
if (el) el.textContent = msg;
}
// =========================================================================
// SPOTIFY USER LOGIN (Authorization Code + PKCE), needed for playlists
// =========================================================================
function isConnected() {
return !!GM_getValue('spotify_user_refresh', null);
}
function base64UrlEncode(buffer) {
let str = '';
const bytes = new Uint8Array(buffer);
for (let i = 0; i < bytes.byteLength; i++) str += String.fromCharCode(bytes[i]);
return btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
function generateRandomString(length) {
const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let text = '';
crypto.getRandomValues(new Uint8Array(length)).forEach((v) => { text += possible[v % possible.length]; });
return text;
}
async function sha256(plain) {
return crypto.subtle.digest('SHA-256', new TextEncoder().encode(plain));
}
async function connectSpotifyAccount() {
const { id } = getCredentials();
if (!id) throw new Error('No client ID set');
const verifier = generateRandomString(64);
const challenge = base64UrlEncode(await sha256(verifier));
const state = generateRandomString(16);
GM_setValue('spotify_pkce_verifier', verifier);
GM_setValue('spotify_pkce_state', state);
const params = new URLSearchParams({
client_id: id,
response_type: 'code',
redirect_uri: REDIRECT_URI,
code_challenge_method: 'S256',
code_challenge: challenge,
scope: USER_SCOPES,
state,
});
window.open(`https://accounts.spotify.com/authorize?${params}`, 'spotify-auth', 'width=480,height=720');
return new Promise((resolve, reject) => {
function handler(event) {
if (event.data && event.data.type === 'spotify-auth-code') {
window.removeEventListener('message', handler);
exchangeCodeForToken(event.data.code).then(resolve).catch(reject);
}
}
window.addEventListener('message', handler);
});
}
// Shared by getToken/exchangeCodeForToken/getUserToken below; all three
// POST to the same endpoint and only differ in grant params/headers.
function postTokenRequest(bodyParams, extraHeaders) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'POST',
url: 'https://accounts.spotify.com/api/token',
headers: Object.assign({ 'Content-Type': 'application/x-www-form-urlencoded' }, extraHeaders || {}),
data: new URLSearchParams(bodyParams).toString(),
onload: (res) => {
try {
const json = JSON.parse(res.responseText);
if (json.access_token) resolve(json);
else reject(new Error('Token request failed: ' + res.responseText));
} catch (e) {
reject(e);
}
},
onerror: reject,
});
});
}
function exchangeCodeForToken(code) {
const { id } = getCredentials();
const verifier = GM_getValue('spotify_pkce_verifier', '');
return postTokenRequest({
grant_type: 'authorization_code',
code,
redirect_uri: REDIRECT_URI,
client_id: id,
code_verifier: verifier,
}).then((json) => {
GM_setValue('spotify_user_token', json.access_token);
GM_setValue('spotify_user_refresh', json.refresh_token);
GM_setValue('spotify_user_token_expiry', Date.now() + (json.expires_in - 60) * 1000);
return json.access_token;
});
}
function getUserToken() {
const token = GM_getValue('spotify_user_token', null);
const expiry = GM_getValue('spotify_user_token_expiry', 0);
if (token && Date.now() < expiry) return Promise.resolve(token);
const refresh = GM_getValue('spotify_user_refresh', null);
if (!refresh) return Promise.reject(new Error('not_connected'));
const { id } = getCredentials();
return postTokenRequest({ grant_type: 'refresh_token', refresh_token: refresh, client_id: id })
.then((json) => {
GM_setValue('spotify_user_token', json.access_token);
GM_setValue('spotify_user_token_expiry', Date.now() + (json.expires_in - 60) * 1000);
if (json.refresh_token) GM_setValue('spotify_user_refresh', json.refresh_token);
return json.access_token;
});
}
function apiRequest(method, path, body) {
return getUserToken().then((token) => new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method,
url: `https://api.spotify.com/v1${path}`,
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: body ? JSON.stringify(body) : undefined,
onload: (res) => {
try {
resolve(res.responseText ? JSON.parse(res.responseText) : {});
} catch (e) { reject(e); }
},
onerror: reject,
});
}));
}
function getMe() { return apiRequest('GET', '/me'); }
function fetchMyPlaylists() {
return apiRequest('GET', '/me/playlists?limit=50').then((json) => json.items || []);
}
function createPlaylist(userId, name) {
return apiRequest('POST', `/users/${encodeURIComponent(userId)}/playlists`, { name, public: false });
}
function getPlaylistTrackUris(playlistId) {
const uris = new Set();
function fetchPage(offset) {
return apiRequest('GET', `/playlists/${playlistId}/tracks?fields=items(track(uri)),next,total&limit=100&offset=${offset}`)
.then((json) => {
(json.items || []).forEach((item) => { if (item.track && item.track.uri) uris.add(item.track.uri); });
if (json.next) return fetchPage(offset + 100);
});
}
return fetchPage(0).then(() => uris);
}
function addTracksToPlaylist(playlistId, uris) {
// Spotify allows max 100 URIs per request.
const chunks = [];
for (let i = 0; i < uris.length; i += 100) chunks.push(uris.slice(i, i + 100));
return chunks.reduce(
(p, chunk) => p.then(() => apiRequest('POST', `/playlists/${playlistId}/tracks`, { uris: chunk })),
Promise.resolve()
);
}
// ---- Tooltips -------------------------------------------------------------
// Positioned via JS against the viewport (not CSS ::after nested inside the
// panel) so hover/rotate effects on the target button, or the panel's own
// overflow:hidden clipping, can never interfere with where it renders.
function initTooltips() {
let tipEl = null;
function place(target) {
if (!tipEl) return;
const rect = target.getBoundingClientRect();
const tipRect = tipEl.getBoundingClientRect();
let top = rect.bottom + 8;
let left = rect.right - tipRect.width;
if (top + tipRect.height > window.innerHeight - 4) top = rect.top - tipRect.height - 8;
if (left < 4) left = 4;
tipEl.style.top = `${top}px`;
tipEl.style.left = `${left}px`;
}
document.addEventListener('mouseover', (e) => {
const target = e.target.closest('[data-tooltip]');
if (!target || tipEl) return;
const text = target.getAttribute('data-tooltip');
if (!text) return;
tipEl = document.createElement('div');
tipEl.className = 'sl-live-tooltip';
tipEl.textContent = text;
document.body.appendChild(tipEl);
place(target);
requestAnimationFrame(() => tipEl && tipEl.classList.add('sl-live-tooltip-visible'));
});
document.addEventListener('mouseout', (e) => {
const target = e.target.closest('[data-tooltip]');
if (!target || !tipEl) return;
tipEl.remove();
tipEl = null;
});
}
// ---- Boot ---------------------------------------------------------------
window.addEventListener('load', () => {
buildSidebar();
initTooltips();
updateToggleTuckedState();
scan();
observer.observe(document.body, { childList: true, subtree: true });
// Reddit is a single-page app; the URL (and therefore the current
// subreddit) can change without a full reload, so re-check periodically
// rather than only once at load.
setInterval(updateToggleTuckedState, 2000);
});
})();