// ==UserScript== // @name Spotify Hidden Releases // @namespace https://github.com/karpuzikov/userscripts // @version 0.3.6 // @description Finds hidden Spotify releases, checks MusicBrainz linkage, and opens unlinked releases in Harmony. // @author karpuzikov // @match https://open.spotify.com/artist/* // @match https://open.spotify.com/intl-*/artist/* // @grant GM_xmlhttpRequest // @grant GM_getValue // @grant GM_setValue // @grant GM_setClipboard // @connect musicbrainz.org // @updateURL https://raw.githubusercontent.com/karpuzikov/userscripts/main/browser-tools/spotify-hidden-releases/Spotify_Hidden_Releases.user.js // @downloadURL https://raw.githubusercontent.com/karpuzikov/userscripts/main/browser-tools/spotify-hidden-releases/Spotify_Hidden_Releases.user.js // @run-at document-idle // ==/UserScript== (() => { 'use strict'; const V='0.3.6',BTN='kz-shr-btn',MOD='kz-shr-mod',CACHE_MS=12*3600000; const HARMONY_LOGO_SVG=''; let state=null,lastPath=location.pathname,mbLast=0; const sleep=ms=>new Promise(r=>setTimeout(r,ms)); const aid=()=>location.pathname.match(/\/artist\/([A-Za-z0-9]{22})/)?.[1]||null; const album=id=>`https://open.spotify.com/album/${id}`; const embed=id=>`https://open.spotify.com/embed/album/${id}`; const harmony=id=>`https://harmony.pulsewidth.org.uk/release?url=${encodeURIComponent(album(id))}>in=®ion=&deezer=&spotify=&tidal=&qobuz=`; const uniq=a=>[...new Set((a||[]).filter(Boolean))]; const norm=s=>String(s||'').normalize('NFKD').replace(/[\u0300-\u036f]/g,'').toLowerCase().replace(/[^a-z0-9]+/g,' ').trim(); const artistMatches=(credit,names)=>{const hay=` ${norm(credit)} `;return (names||[]).some(n=>{const needle=norm(n);return needle&&hay.includes(` ${needle} `)})}; const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); function artistName(){return document.querySelector('main h1,h1')?.textContent?.trim()||document.title.replace(/\s*[-|]\s*Spotify\s*$/i,'').trim()} function albumIds(text){const out=new Set(),re=/(?:spotify:album:|open\.spotify\.com\/(?:intl-[a-z]{2,3}\/)?album\/|\/album\/)([A-Za-z0-9]{22})/gi;let m;while((m=re.exec(String(text||''))))out.add(m[1]);return out} function gm(url){return new Promise((ok,no)=>GM_xmlhttpRequest({method:'GET',url,timeout:45000,headers:{Accept:'application/json','User-Agent':`Spotify-Hidden-Releases/${V} (https://github.com/karpuzikov/userscripts)`},onload:ok,onerror:()=>no(new Error('Network error')),ontimeout:()=>no(new Error('Timeout'))}))} async function mb(url){const wait=1050-(Date.now()-mbLast);if(wait>0)await sleep(wait);mbLast=Date.now();while(true){const r=await gm(url);if(r.status===503){const retry=Math.max(5,Number(r.responseHeaders?.match(/retry-after:\s*(\d+)/i)?.[1]||5));await sleep(retry*1000);continue}if(r.status===404)return null;if(r.status<200||r.status>=300)throw new Error(`MusicBrainz HTTP ${r.status}`);try{return JSON.parse(r.responseText||'{}')}catch{throw new Error('Invalid MusicBrainz response')}}} function renderedDiscographyIds(id){const path=`/artist/${id}/discography/all`;return new Promise(resolve=>{const f=document.createElement('iframe');f.src=path;f.setAttribute('aria-hidden','true');f.style.cssText='position:fixed;left:-10000px;top:-10000px;width:1400px;height:1000px;opacity:0;pointer-events:none';document.body.appendChild(f);const out=new Set;let ticks=0,stable=0,lastSize=-1,done=false,atBottom=false;const finish=()=>{if(done)return;done=true;clearInterval(timer);f.remove();resolve(out)};const sample=()=>{ticks++;try{const d=f.contentDocument;if(d){for(const a of d.querySelectorAll('main a[href*="/album/"]')){const x=String(a.getAttribute('href')||'').match(/\/album\/([A-Za-z0-9]{22})/)?.[1];if(x)out.add(x)}const sc=d.querySelector('[data-overlayscrollbars-viewport]')||d.scrollingElement||d.documentElement,step=Math.max(600,Math.floor((sc.clientHeight||900)*0.75)),max=Math.max(0,(sc.scrollHeight||0)-(sc.clientHeight||0));if((sc.scrollTop||0)=5&&ticks>=8)||ticks>=48)finish()};const timer=setInterval(sample,400);f.onload=()=>setTimeout(sample,500)})} async function visibleIds(id){const out=new Set;try{for(const x of await renderedDiscographyIds(id))out.add(x)}catch{}if(out.size)return out;for(const p of [`/artist/${id}/discography/all`,`/artist/${id}/discography/album`,`/artist/${id}/discography/single`,`/artist/${id}/discography/compilation`])try{const r=await fetch(p,{credentials:'include',cache:'no-store'});if(r.ok)albumIds(await r.text()).forEach(x=>out.add(x))}catch{}return out} async function mbArtist(id){for(const u of [`https://open.spotify.com/artist/${id}`,`https://open.spotify.com/artist/${id}/`]){const d=await mb(`https://musicbrainz.org/ws/2/url?resource=${encodeURIComponent(u)}&inc=artist-rels&fmt=json`);const rel=(d?.relations||[]).find(x=>x['target-type']==='artist'&&x.artist?.id);if(rel)return rel.artist}return null} function relSpotifyIds(rels){const out=new Set;for(const r of rels||[])albumIds(r?.url?.resource||r?.target||'').forEach(x=>out.add(x));return out} async function mbReleaseLinks(albumId){const d=await mb(`https://musicbrainz.org/ws/2/url?resource=${encodeURIComponent(album(albumId))}&inc=release-rels&fmt=json`),out=new Map;for(const r of d?.relations||[]){if(r?.['target-type']!=='release'||!r.release?.id)continue;out.set(r.release.id,{id:r.release.id,title:r.release.title||r.release.id,disambiguation:r.release.disambiguation||''})}return[...out.values()]} async function collectMb(id,currentName,setStatus){const artist=await mbArtist(id);const candidates=new Map,historical=[];if(!artist)return{candidates,historical,error:'MusicBrainz artist link not found'};const sameProfileCredit=names=>(names||[]).some(n=>norm(n)===norm(currentName));let offset=0;do{setStatus('Reading MusicBrainz release groups...',`${offset} loaded`,20);const d=await mb(`https://musicbrainz.org/ws/2/release-group?artist=${artist.id}&inc=artist-credits+url-rels&limit=100&offset=${offset}&fmt=json`),rows=d?.['release-groups']||[];for(const r of rows){const credits=r['artist-credit']||[],names=uniq(credits.map(x=>x.name||x.artist?.name)),targetNames=uniq(credits.filter(x=>x.artist?.id===artist.id).map(x=>x.name||x.artist?.name));if(!sameProfileCredit(targetNames))continue;if(r.title)historical.push({title:r.title,names:[currentName]});for(const sid of relSpotifyIds(r.relations))candidates.set(sid,{id:sid,title:r.title||'',artist:names.join(', '),sources:['MusicBrainz release-group Spotify URL']})}offset+=rows.length;if(!rows.length||offset>=Number(d?.['release-group-count']||0))break}while(true); offset=0;do{setStatus('Reading MusicBrainz releases...',`${offset} loaded`,35);const d=await mb(`https://musicbrainz.org/ws/2/release?artist=${artist.id}&inc=artist-credits+url-rels&limit=100&offset=${offset}&fmt=json`),rows=d?.releases||[];for(const r of rows){const credits=r['artist-credit']||[],names=uniq(credits.map(x=>x.name||x.artist?.name)),targetNames=uniq(credits.filter(x=>x.artist?.id===artist.id).map(x=>x.name||x.artist?.name));if(!sameProfileCredit(targetNames))continue;if(r.title)historical.push({title:r.title,names:[currentName]});for(const sid of relSpotifyIds(r.relations)){const prev=candidates.get(sid);const mbRelease={id:r.id,title:r.title||r.id,disambiguation:r.disambiguation||''};const item={id:sid,title:r.title||'',artist:names.join(', '),sources:['MusicBrainz release Spotify URL'],mbLinked:true,mbReleases:[mbRelease]};if(prev){prev.sources=uniq([...prev.sources,...item.sources]);prev.mbLinked=true;prev.mbReleases=[...(prev.mbReleases||[]),mbRelease].filter((x,i,a)=>a.findIndex(y=>y.id===x.id)===i)}else candidates.set(sid,item)}}offset+=rows.length;if(!rows.length||offset>=Number(d?.['release-count']||0))break}while(true); return{candidates,historical:dedupeHistory(historical),artist}} function dedupeHistory(rows){const m=new Map;for(const r of rows){for(const n of r.names||[]){const k=`${norm(r.title)}|${norm(n)}`;if(!m.has(k))m.set(k,{title:r.title,name:n})}}return[...m.values()]} function filteredSearchAlbumIds(doc,title,artist){const out=new Set(),nt=norm(title),na=norm(artist);for(const a of doc.querySelectorAll('a[href*="/album/"]')){const id=String(a.getAttribute('href')||'').match(/\/album\/([A-Za-z0-9]{22})/)?.[1];if(!id)continue;let node=a,txt='';for(let i=0;i<6&&node;i++,node=node.parentElement){txt=norm(node.textContent||'');if(txt.length>1500)break;if((!nt||txt.includes(nt))&&(!na||artistMatches(txt,[na]))){out.add(id);break}}}return out} async function searchSpotifyUi(title,artist){const query=`album:"${String(title||'').replace(/"/g,' ')}" artist:"${String(artist||'').replace(/"/g,' ')}"`,path=`/search/${encodeURIComponent(query)}/albums`;return new Promise(resolve=>{const f=document.createElement('iframe');f.src=path;f.setAttribute('aria-hidden','true');f.style.cssText='position:fixed;left:-10000px;top:-10000px;width:1200px;height:800px;opacity:0;pointer-events:none';document.body.appendChild(f);let ticks=0,done=false,best=new Set;const finish=ids=>{if(done)return;done=true;clearInterval(timer);f.remove();resolve(ids)};const sample=()=>{try{const d=f.contentDocument;if(!d)return;const found=filteredSearchAlbumIds(d,title,artist);if(found.size)best=found;if(found.size&&ticks>=3)finish(found)}catch{}};const timer=setInterval(()=>{ticks++;sample();if(ticks>=16)finish(best)},500);f.onload=()=>setTimeout(sample,500)})} function spotifyArtistIdsFromDoc(d){const out=new Set;for(const el of d.querySelectorAll('meta[property="music:musician"],a[data-testid="creator-link"][href*="/artist/"]')){const raw=el.content||el.getAttribute('href')||'',x=String(raw).match(/\/artist\/([A-Za-z0-9]{22})/)?.[1];if(x)out.add(x)}return[...out]} function renderedAlbumArtistIds(albumId){return new Promise(resolve=>{const f=document.createElement('iframe');f.src=`/album/${albumId}`;f.setAttribute('aria-hidden','true');f.style.cssText='position:fixed;left:-10000px;top:-10000px;width:1200px;height:800px;opacity:0;pointer-events:none';document.body.appendChild(f);let ticks=0,done=false;const finish=ids=>{if(done)return;done=true;clearInterval(timer);f.remove();resolve(ids)};const sample=()=>{ticks++;try{const d=f.contentDocument;if(d){const out=new Set(spotifyArtistIdsFromDoc(d));for(const a of d.querySelectorAll('a[data-testid="creator-link"][href*="/artist/"]')){const x=String(a.getAttribute('href')||'').match(/\/artist\/([A-Za-z0-9]{22})/)?.[1];if(x)out.add(x)}if(out.size)return finish([...out])}}catch{}if(ticks>=16)finish([])};const timer=setInterval(sample,400);f.onload=()=>setTimeout(sample,400)})} async function verify(item){let html='';try{const r=await fetch(album(item.id),{credentials:'include',cache:'no-store'});if(r.ok)html=await r.text()}catch{}if(!html)try{const r=await fetch(embed(item.id),{credentials:'omit',cache:'no-store'});if(r.ok)html=await r.text()}catch{}if(!html)return null;const d=new DOMParser().parseFromString(html,'text/html'),og=p=>d.querySelector(`meta[property="${p}"]`)?.content?.trim()||'',desc=og('og:description');item.title||=og('og:title').replace(/\s*[-|]\s*Spotify\s*$/i,'').trim();item.image||=og('og:image');item.artist||=desc.match(/^(.+?)\s*[·•-]\s*(?:Album|EP|Single)\b/i)?.[1]?.trim()||'';item.spotifyArtistIds=spotifyArtistIdsFromDoc(d);if(!item.spotifyArtistIds.length)item.spotifyArtistIds=await renderedAlbumArtistIds(item.id);return item} function status(a,b='',p=0){const m=modal();m.querySelector('#kz-s').textContent=a;m.querySelector('#kz-d').textContent=b;m.querySelector('#kz-p').style.width=`${Math.max(0,Math.min(100,p))}%`} async function discover(){const id=aid(),name=artistName(),errors=[];status('Reading visible Spotify discography...',name,5);const visible=await visibleIds(id);status('Resolving artist in MusicBrainz...',name,10);let md;try{md=await collectMb(id,name,status)}catch(e){md={candidates:new Map,historical:[]};errors.push(e.message)}if(md.error)errors.push(md.error);const candidates=md.candidates; const searches=md.historical.slice(0,40);let i=0;for(const h of searches){i++;status('Searching Spotify with historical artist credits...',`${i}/${searches.length}: ${h.title} - ${h.name}`,50+Math.round(i/Math.max(1,searches.length)*25));try{const found=await searchSpotifyUi(h.title,h.name);for(const sid of found){if(visible.has(sid))continue;const prev=candidates.get(sid);if(prev){prev.sources=uniq([...prev.sources,`Spotify search: ${h.name}`]);prev.expectedArtists=uniq([...(prev.expectedArtists||[]),h.name]);if(!prev.mbLinked)prev.fromSearch=true}else candidates.set(sid,{id:sid,title:h.title||'',artist:h.name||'',sources:[`Spotify search: ${h.name}`],expectedArtists:[h.name],fromSearch:true})}}catch(e){errors.push(`Spotify search: ${e.message}`)}} for(const sid of GM_getValue(`kz-shr-manual:${id}`,[])||[]){const prev=candidates.get(sid)||{id:sid,title:'',artist:'',sources:[]};prev.sources=uniq([...prev.sources,'Manual']);prev.manual=true;candidates.set(sid,prev)} const hidden=[...candidates.values()].filter(x=>!visible.has(x.id)||x.manual),results=[];let n=0;for(const item of hidden){n++;status('Verifying hidden Spotify pages...',`${n}/${hidden.length}`,76+Math.round(n/Math.max(1,hidden.length)*12));const v=await verify(item);if(!v)continue;if(!(v.spotifyArtistIds||[]).includes(id))continue;if(v.fromSearch&&!v.mbLinked&&!artistMatches(v.artist,v.expectedArtists))continue;if(v.mbLinked&&v.mbReleases?.length){v.mbReleases=[...v.mbReleases].filter((x,i,a)=>x?.id&&a.findIndex(y=>y?.id===x.id)===i)}else{status('Checking MusicBrainz links...',`${n}/${hidden.length}: ${v.title||v.id}`,88+Math.round(n/Math.max(1,hidden.length)*11));try{v.mbReleases=await mbReleaseLinks(v.id);v.mbLinked=v.mbReleases.length>0}catch(e){v.mbReleases=[];v.mbLinked=false;v.mbCheckError=e.message;errors.push(`MusicBrainz ${v.id}: ${e.message}`)}}results.push(v)}results.sort((a,b)=>(a.title||'').localeCompare(b.title||''));return{results,errors:uniq(errors),stats:{visible:visible.size,candidates:candidates.size,mb:candidates.size,searches:searches.length,linked:results.filter(x=>x.mbLinked).length,unlinked:results.filter(x=>!x.mbLinked).length}}} function css(){if(document.getElementById('kz-shr-css'))return;const s=document.createElement('style');s.id='kz-shr-css';s.textContent=`#${BTN}{border:0;border-radius:999px;background:#1ed760;color:#000;font:700 14px Arial;padding:11px 18px;margin-left:12px;cursor:pointer}#${MOD}{position:fixed;inset:0;z-index:2147483646;background:#000b;display:grid;place-items:center;padding:20px;color:#fff;font:13px Arial}#${MOD}[hidden]{display:none}.p{width:min(1050px,96vw);max-height:92vh;background:#121212;border:1px solid #333;border-radius:14px;overflow:hidden;display:flex;flex-direction:column}.h,.t,.s{padding:14px 18px;border-bottom:1px solid #292929}.h{display:flex;align-items:center;gap:10px}.h b{font-size:21px}.h span{color:#999;margin-right:auto}.b{border:1px solid #444;border-radius:999px;background:#252525;color:#fff;padding:9px 13px;font-weight:700;cursor:pointer;text-decoration:none}.t{display:grid;grid-template-columns:1fr auto 1.4fr auto;gap:9px}.i{background:#202020;color:#fff;border:1px solid #444;border-radius:8px;padding:10px}.s small{color:#aaa}.bar{height:4px;background:#292929;margin-top:9px}.bar i{display:block;height:100%;background:#1ed760;width:0}.note{padding:10px 18px;color:#aaa;border-bottom:1px solid #292929}.list{overflow:auto;padding:12px 18px 18px;display:grid;gap:9px}.card{display:grid;grid-template-columns:64px 1fr auto;gap:12px;align-items:center;background:#191919;border:1px solid #2e2e2e;border-radius:10px;padding:10px}.cover{width:64px;height:64px;object-fit:cover;border-radius:6px;background:#292929}.title{font-size:15px;font-weight:800}.meta{color:#aaa;margin-top:4px}.badges{display:flex;flex-wrap:wrap;gap:5px;margin-top:6px}.badge{background:#303030;border-radius:999px;padding:3px 7px;font-size:11px}.mb-ok{background:#153d24;color:#9ee6ad}.mb-no{background:#4a2424;color:#ffb1b1}.mbicon,.harmony{display:inline-flex;align-items:center;justify-content:center;width:30px;height:30px;border-radius:7px;background:#252525;border:1px solid #444;vertical-align:middle}.mbicon img{width:20px;height:20px}.harmony svg{width:22px;height:22px;display:block}.err{padding:8px 18px;color:#ffb0b0}.empty{padding:35px;text-align:center;color:#aaa}`;document.head.appendChild(s)} function modal(){css();let m=document.getElementById(MOD);if(m)return m;m=document.createElement('div');m.id=MOD;m.hidden=true;m.innerHTML=`
Spotify Hidden Releasesv${V} - Under construction
Ready.
Each release is checked against MusicBrainz. Linked releases open directly in MusicBrainz; unlinked releases get a Harmony lookup button.
`;document.body.appendChild(m);m.querySelector('#kz-x').onclick=()=>m.hidden=true;m.querySelector('#kz-r').onclick=()=>scan(true);m.querySelector('#kz-f').oninput=render;m.querySelector('#kz-mf').onchange=render;m.querySelector('#kz-a').onclick=saveManual;return m} function render(){const m=modal(),l=m.querySelector('#kz-l'),q=norm(m.querySelector('#kz-f').value),mf=m.querySelector('#kz-mf')?.value||'all';if(!state){l.innerHTML='
Run a scan.
';return}m.querySelector('#kz-e').innerHTML=state.errors?.length?`
Partial scan: ${esc(state.errors.join('; '))}
`:'';const r=state.results.filter(x=>(!q||norm(`${x.title} ${x.artist} ${x.id} ${(x.sources||[]).join(' ')}`).includes(q))&&(mf==='all'||(mf==='linked'&&x.mbLinked)||(mf==='unlinked'&&!x.mbLinked)));l.innerHTML=r.length?r.map(x=>{const mb=(x.mbReleases||[]).map(rel=>`MusicBrainz`).join('');const hy=!x.mbLinked?`${HARMONY_LOGO_SVG}`:'';return`
${x.image?``:'
'}
${esc(x.title||x.id)}
${esc(x.artist||'')} - ${x.id}
Hidden from visible discography${x.mbLinked?`MusicBrainz linked${x.mbReleases.length>1?` (${x.mbReleases.length})`:''}`:'Not linked to MusicBrainz'}${(x.sources||[]).slice(0,6).map(s=>`${esc(s)}`).join('')}
${mb}${hy} Open Embed
`}).join(''):'
No releases match the current filters.
';l.querySelectorAll('.copy').forEach(b=>b.onclick=()=>GM_setClipboard(b.dataset.u,'text'))} async function scan(force=false){const id=aid();if(!id)return;const m=modal();m.hidden=false;const ck=GM_getValue(`kz-shr-cache-v36:${id}`,null);if(!force&&ck?.time&&Date.now()-ck.time