Spotify Web Stream Counts

Adds app-style Spotify play counts to artist and album track rows. Hidden on playlists. No analytics, backend, or stored credentials.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

Advertisement:

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

Advertisement:

// ==UserScript==
// @name         Spotify Web Stream Counts
// @namespace    https://open.spotify.com/
// @version      1.0.0
// @description  Adds app-style Spotify play counts to artist and album track rows. Hidden on playlists. No analytics, backend, or stored credentials.
// @author       Atharv Joshi
// @license      MIT
// @match        https://open.spotify.com/*
// @run-at       document-start
// @grant        none
// @noframes
// ==/UserScript==

(() => {
  'use strict';

  const SCRIPT_ID = 'spotify-web-stream-counts';
  const COUNT_CLASS = 'sw-stream-count';
  const ROW_CLASS = 'sw-stream-count-row';
  const NATIVE_CLASS = 'sw-native-stream-count';
  const CACHE_KEY = `${SCRIPT_ID}:counts:v1`;
  const QUERY_OPERATION = 'queryAlbumTracks';
  const QUERY_HASH = '4e7c57acec1683d8a67265042c2afc30268246417b8fb551cea9454caeaa3560';
  const SPOTIFY_PARTNER_HOST = 'api-partner.spotify.com';
  const TRACK_ID_RE = /^[A-Za-z0-9]{22}$/;
  const MAX_CACHE_ENTRIES = 5000;
  const ALBUM_REFRESH_MS = 15 * 60 * 1000;

  const counts = new Map();
  const requestedAlbums = new Map();
  const pendingAlbums = new Set();

  let bearerToken = '';
  let spotifyAppVersion = '';
  let renderQueued = false;
  let cacheSaveTimer = 0;
  let statusTimer = 0;

  const originalFetch = window.fetch.bind(window);
  const OriginalXHR = window.XMLHttpRequest;

  loadCache();
  installStylesWhenReady();
  interceptFetch();
  interceptXHR();
  observePage();

  function interceptFetch() {
    window.fetch = async function spotifyStreamCountsFetch(input, init) {
      try {
        captureRequestHeaders(input, init);
      } catch (error) {
        debug('Could not inspect fetch request headers', error);
      }

      const response = await originalFetch(input, init);

      try {
        const url = getRequestUrl(input);
        if (isSpotifyDataUrl(url)) {
          response.clone().json().then(ingestSpotifyResponse).catch(() => { });
        }
      } catch (error) {
        debug('Could not inspect Spotify fetch response', error);
      }

      return response;
    };
  }

  function interceptXHR() {
    if (!OriginalXHR?.prototype) return;

    const originalOpen = OriginalXHR.prototype.open;
    const originalSetRequestHeader = OriginalXHR.prototype.setRequestHeader;
    const originalSend = OriginalXHR.prototype.send;

    OriginalXHR.prototype.open = function patchedOpen(method, url, ...rest) {
      this.__swStreamCountsUrl = String(url ?? '');
      this.__swStreamCountsHeaders = Object.create(null);
      return originalOpen.call(this, method, url, ...rest);
    };

    OriginalXHR.prototype.setRequestHeader = function patchedSetRequestHeader(name, value) {
      try {
        const key = String(name).toLowerCase();
        this.__swStreamCountsHeaders ??= Object.create(null);
        this.__swStreamCountsHeaders[key] = String(value);
        captureHeader(key, value);
      } catch (_) {
        // Never interfere with Spotify if inspection fails.
      }
      return originalSetRequestHeader.call(this, name, value);
    };

    OriginalXHR.prototype.send = function patchedSend(...args) {
      if (isSpotifyDataUrl(this.__swStreamCountsUrl)) {
        this.addEventListener('load', () => {
          try {
            if (typeof this.response === 'object' && this.response !== null) {
              ingestSpotifyResponse(this.response);
            } else if (typeof this.responseText === 'string' && this.responseText) {
              ingestSpotifyResponse(JSON.parse(this.responseText));
            }
          } catch (_) {
            // Ignore non-JSON responses.
          }
        }, { once: true });
      }
      return originalSend.apply(this, args);
    };
  }

  function captureRequestHeaders(input, init) {
    const merged = new Headers();

    if (typeof Request !== 'undefined' && input instanceof Request) {
      input.headers.forEach((value, key) => merged.set(key, value));
    }

    if (init?.headers) {
      new Headers(init.headers).forEach((value, key) => merged.set(key, value));
    }

    merged.forEach((value, key) => captureHeader(key, value));
  }

  function captureHeader(name, value) {
    const key = String(name).toLowerCase();
    const text = String(value);

    if (key === 'authorization' && /^Bearer\s+\S+/i.test(text)) {
      bearerToken = text;
      processVisibleAlbum();
    } else if (key === 'spotify-app-version' && text.length < 100) {
      spotifyAppVersion = text;
    }
  }

  function getRequestUrl(input) {
    if (typeof input === 'string') return input;
    if (input instanceof URL) return input.href;
    if (typeof Request !== 'undefined' && input instanceof Request) return input.url;
    return String(input?.url ?? '');
  }

  function isSpotifyDataUrl(rawUrl) {
    if (!rawUrl) return false;
    try {
      const url = new URL(rawUrl, location.href);
      return url.hostname === SPOTIFY_PARTNER_HOST || url.pathname.includes('/pathfinder/');
    } catch (_) {
      return String(rawUrl).includes('pathfinder');
    }
  }

  function ingestSpotifyResponse(payload) {
    const discovered = extractTrackCounts(payload);
    if (discovered === 0) return;

    saveCacheSoon();
    queueRender();
  }

  function extractTrackCounts(root) {
    if (!root || typeof root !== 'object') return 0;

    const seen = new WeakSet();
    let added = 0;

    function walk(value) {
      if (!value || typeof value !== 'object') return;
      if (seen.has(value)) return;
      seen.add(value);

      const nestedTrack = value.track && typeof value.track === 'object' ? value.track : null;
      const candidate = nestedTrack ?? value;
      const playcount = candidate.playcount ?? (nestedTrack ? value.playcount : undefined);
      const trackId = extractTrackId(candidate, Boolean(nestedTrack));

      if (trackId && playcount !== undefined && playcount !== null) {
        const normalized = normalizeCount(playcount);
        if (normalized && counts.get(trackId) !== normalized) {
          counts.set(trackId, normalized);
          added += 1;
        }
      }

      if (Array.isArray(value)) {
        for (const item of value) walk(item);
      } else {
        for (const child of Object.values(value)) walk(child);
      }
    }

    walk(root);
    return added;
  }

  function extractTrackId(candidate, cameFromTrackProperty) {
    if (!candidate || typeof candidate !== 'object') return '';

    const uri = String(candidate.uri ?? '');
    const uriMatch = uri.match(/^spotify:track:([A-Za-z0-9]{22})$/);
    if (uriMatch) return uriMatch[1];

    for (const key of ['shareUrl', 'url', 'href']) {
      const match = String(candidate[key] ?? '').match(/\/track\/([A-Za-z0-9]{22})(?:[?/#]|$)/);
      if (match) return match[1];
    }

    const id = String(candidate.id ?? '');
    return cameFromTrackProperty && TRACK_ID_RE.test(id) ? id : '';
  }

  function normalizeCount(value) {
    const digits = String(value).replace(/[^0-9]/g, '');
    if (!digits) return '';
    return digits.replace(/^0+(?=\d)/, '');
  }

  function observePage() {
    const start = () => {
      const observer = new MutationObserver(() => {
        queueRender();
        processVisibleAlbum();
      });

      observer.observe(document.documentElement, {
        childList: true,
        subtree: true,
      });

      window.addEventListener('popstate', onNavigation, { passive: true });
      document.addEventListener('visibilitychange', () => {
        if (!document.hidden) onNavigation();
      });

      setInterval(() => {
        queueRender();
        processVisibleAlbum();
      }, 2500);

      queueRender();
      processVisibleAlbum();
    };

    if (document.documentElement) start();
    else document.addEventListener('DOMContentLoaded', start, { once: true });
  }

  function onNavigation() {
    queueRender();
    processVisibleAlbum();
  }

  function processVisibleAlbum() {
    const albumId = getCurrentAlbumId();
    if (!albumId || !bearerToken || pendingAlbums.has(albumId)) return;

    const lastFetchedAt = requestedAlbums.get(albumId) ?? 0;
    if (Date.now() - lastFetchedAt < ALBUM_REFRESH_MS) return;

    void fetchAlbumCounts(albumId);
  }

  async function fetchAlbumCounts(albumId) {
    if (!TRACK_ID_RE.test(albumId)) return;
    pendingAlbums.add(albumId);

    try {
      const url = new URL('https://api-partner.spotify.com/pathfinder/v1/query');
      url.searchParams.set('operationName', QUERY_OPERATION);
      url.searchParams.set('variables', JSON.stringify({
        uri: `spotify:album:${albumId}`,
        offset: 0,
        limit: 300,
      }));
      url.searchParams.set('extensions', JSON.stringify({
        persistedQuery: {
          version: 1,
          sha256Hash: QUERY_HASH,
        },
      }));

      const headers = new Headers({
        Accept: 'application/json',
        Authorization: bearerToken,
        'App-Platform': 'WebPlayer',
      });
      if (spotifyAppVersion) headers.set('Spotify-App-Version', spotifyAppVersion);

      const response = await originalFetch(url.href, {
        method: 'GET',
        headers,
        credentials: 'include',
      });

      if (response.status === 401 || response.status === 403) {
        bearerToken = '';
        throw new Error(`Spotify authorization expired (${response.status})`);
      }
      if (response.status === 429) {
        throw new Error('Spotify rate-limited the request');
      }
      if (!response.ok) {
        throw new Error(`Spotify returned HTTP ${response.status}`);
      }

      const json = await response.json();
      if (Array.isArray(json?.errors) && json.errors.length > 0) {
        throw new Error(String(json.errors[0]?.message ?? 'Spotify GraphQL error'));
      }

      requestedAlbums.set(albumId, Date.now());
      ingestSpotifyResponse(json);
    } catch (error) {
      debug(`Could not load album ${albumId}`, error);
      showStatus('Streams unavailable · reload Spotify once', 'error', 6000);
    } finally {
      pendingAlbums.delete(albumId);
    }
  }

  function getCurrentAlbumId() {
    const match = location.pathname.match(/^\/album\/([A-Za-z0-9]{22})(?:\/|$)/);
    return match?.[1] ?? '';
  }

  function getVisibleTrackIds() {
    const ids = new Set();
    for (const link of document.querySelectorAll('main a[href*="/track/"], [role="main"] a[href*="/track/"]')) {
      const id = trackIdFromHref(link.getAttribute('href'));
      if (id) ids.add(id);
    }
    return [...ids];
  }

  function queueRender() {
    if (renderQueued) return;
    renderQueued = true;
    requestAnimationFrame(() => {
      renderQueued = false;
      renderCounts();
    });
  }

  function renderCounts() {
    const pageKind = getPageKind();

    // Match the desktop app: playlists and all unsupported page types do not
    // receive a stream-count column. This also cleans up rows when Spotify
    // reuses DOM nodes during single-page navigation.
    if (pageKind === 'other') {
      removeAllInjectedCounts();
      return;
    }

    removeStaleCounts();

    const links = document.querySelectorAll(
      'main a[href*="/track/"], [role="main"] a[href*="/track/"], [data-testid="tracklist-row"] a[href*="/track/"]'
    );
    const seenRows = new WeakSet();

    for (const link of links) {
      const row = link.closest('[data-testid="tracklist-row"], [role="row"]');
      if (!row || seenRows.has(row)) continue;
      seenRows.add(row);

      // Spotify can include several track links in one row. The first one is
      // normally the title and is the only one we use to identify the song.
      const titleLink = row.querySelector('a[href*="/track/"]');
      if (!titleLink) continue;

      const trackId = trackIdFromHref(titleLink.getAttribute('href'));
      if (!trackId) continue;

      const count = counts.get(trackId);
      const existing = row.querySelector(`.${COUNT_CLASS}`);

      if (!count) {
        existing?.remove();
        row.classList.remove(ROW_CLASS);
        continue;
      }

      const formatted = formatCount(count);
      const nativeCount = findNativeCountElement(row, count, formatted);

      // Spotify occasionally renders the play count itself on some artist
      // layouts. Do not duplicate it; simply give the native value a tooltip.
      if (nativeCount) {
        existing?.remove();
        row.classList.remove(ROW_CLASS);
        nativeCount.classList.add(NATIVE_CLASS);
        nativeCount.title = `${formatted} Spotify plays`;
        continue;
      }

      row.classList.add(ROW_CLASS);
      row.dataset.swStreamPage = pageKind;

      const cell = existing ?? createCountCell(trackId);
      cell.dataset.trackId = trackId;
      cell.textContent = formatted;
      cell.title = `${formatted} Spotify plays`;

      if (!existing) row.appendChild(cell);
    }
  }

  function removeStaleCounts() {
    for (const cell of document.querySelectorAll(`.${COUNT_CLASS}`)) {
      const row = cell.closest('[data-testid="tracklist-row"], [role="row"]');
      const titleLink = row?.querySelector('a[href*="/track/"]');
      const currentTrackId = trackIdFromHref(titleLink?.getAttribute('href'));

      if (!row || !currentTrackId || cell.dataset.trackId !== currentTrackId) {
        cell.remove();
        row?.classList.remove(ROW_CLASS);
      }
    }
  }

  function removeAllInjectedCounts() {
    for (const cell of document.querySelectorAll(`.${COUNT_CLASS}`)) {
      cell.remove();
    }

    for (const row of document.querySelectorAll(`.${ROW_CLASS}`)) {
      row.classList.remove(ROW_CLASS);
      delete row.dataset.swStreamPage;
    }

    for (const nativeCount of document.querySelectorAll(`.${NATIVE_CLASS}`)) {
      nativeCount.classList.remove(NATIVE_CLASS);
      if (nativeCount.title?.endsWith(' Spotify plays')) {
        nativeCount.removeAttribute('title');
      }
    }
  }

  function findNativeCountElement(row, rawCount, formattedCount) {
    const rawDigits = String(rawCount);

    for (const element of row.querySelectorAll('span, div')) {
      if (element.classList.contains(COUNT_CLASS)) continue;
      if (element.closest(`.${COUNT_CLASS}`)) continue;
      if (element.querySelector('span, div')) continue;

      const text = String(element.textContent ?? '').trim();
      if (!text || /^\d{1,2}:\d{2}$/.test(text)) continue;

      const digits = text.replace(/[^0-9]/g, '');
      if (text === formattedCount || digits === rawDigits) return element;
    }

    return null;
  }

  function createCountCell(trackId) {
    const cell = document.createElement('span');
    cell.className = COUNT_CLASS;
    cell.dataset.trackId = trackId;
    cell.setAttribute('aria-label', 'Spotify stream count');
    return cell;
  }

  function getPageKind() {
    if (/^\/artist\//.test(location.pathname)) return 'artist';
    if (/^\/album\//.test(location.pathname)) return 'album';
    return 'other';
  }

  function trackIdFromHref(rawHref) {
    if (!rawHref) return '';
    const match = String(rawHref).match(/\/track\/([A-Za-z0-9]{22})(?:[?/#]|$)/);
    return match?.[1] ?? '';
  }

  function formatCount(digits) {
    try {
      return new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 }).format(BigInt(digits));
    } catch (_) {
      const number = Number(digits);
      return Number.isFinite(number) ? number.toLocaleString() : digits;
    }
  }

  function installStylesWhenReady() {
    const install = () => {
      if (document.getElementById(`${SCRIPT_ID}-styles`)) return;
      const style = document.createElement('style');
      style.id = `${SCRIPT_ID}-styles`;
      style.textContent = `
        /* Dedicated app-style play-count column. It is overlaid instead of
           changing Spotify's grid, which makes it much less likely to break
           Spotify's row controls or keyboard navigation. */
        .${ROW_CLASS} {
          position: relative !important;
        }
        .${COUNT_CLASS} {
          position: absolute;
          inset-inline-start: 61%;
          top: 50%;
          width: clamp(104px, 10vw, 142px);
          transform: translateY(-50%);
          overflow: hidden;
          color: var(--text-subdued, #b3b3b3);
          font-family: var(--encore-body-font-stack, CircularSp, CircularSp-Arab, CircularSp-Hebr, CircularSp-Cyrl, CircularSp-Grek, CircularSp-Deva, sans-serif);
          font-size: 14px;
          font-weight: 400;
          font-variant-numeric: tabular-nums;
          line-height: 20px;
          text-align: end;
          text-overflow: ellipsis;
          white-space: nowrap;
          user-select: text;
          z-index: 1;
        }
        .${COUNT_CLASS}:hover {
          color: var(--text-base, #fff);
        }
        .${NATIVE_CLASS} {
          font-variant-numeric: tabular-nums;
        }

        /* Give long titles more room on medium-width windows and hide the
           extra column before it can overlap Spotify's duration/actions. */
        @media (max-width: 1080px) {
          .${COUNT_CLASS} {
            inset-inline-start: 58%;
            width: 112px;
            font-size: 13px;
          }
        }
        @media (max-width: 860px) {
          .${COUNT_CLASS} {
            display: none;
          }
        }

        #sw-stream-count-status {
          position: fixed;
          right: 18px;
          bottom: 104px;
          z-index: 2147483646;
          max-width: min(360px, calc(100vw - 36px));
          padding: 9px 12px;
          border: 1px solid rgba(255, 90, 90, 0.55);
          border-radius: 8px;
          background: rgba(24, 24, 24, 0.96);
          color: #fff;
          box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
          font: 12px/1.35 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
          opacity: 0;
          transform: translateY(5px);
          pointer-events: none;
          transition: opacity 140ms ease, transform 140ms ease;
        }
        #sw-stream-count-status[data-visible="true"] {
          opacity: 1;
          transform: translateY(0);
        }
      `;
      (document.head ?? document.documentElement).appendChild(style);
    };

    if (document.documentElement) install();
    else document.addEventListener('DOMContentLoaded', install, { once: true });
  }

  function showStatus(message, kind = 'neutral', duration = 2500) {
    if (!document.body) {
      document.addEventListener('DOMContentLoaded', () => showStatus(message, kind, duration), { once: true });
      return;
    }

    let status = document.getElementById('sw-stream-count-status');
    if (!status) {
      status = document.createElement('div');
      status.id = 'sw-stream-count-status';
      status.setAttribute('role', 'status');
      status.setAttribute('aria-live', 'polite');
      document.body.appendChild(status);
    }

    status.textContent = message;
    status.dataset.kind = kind;
    status.dataset.visible = 'true';

    clearTimeout(statusTimer);
    statusTimer = window.setTimeout(() => {
      status.dataset.visible = 'false';
    }, duration);
  }

  function loadCache() {
    try {
      const parsed = JSON.parse(sessionStorage.getItem(CACHE_KEY) ?? '[]');
      if (!Array.isArray(parsed)) return;
      for (const entry of parsed) {
        if (!Array.isArray(entry) || entry.length !== 2) continue;
        const [trackId, count] = entry;
        if (TRACK_ID_RE.test(String(trackId)) && /^\d+$/.test(String(count))) {
          counts.set(String(trackId), String(count));
        }
      }
    } catch (_) {
      sessionStorage.removeItem(CACHE_KEY);
    }
  }

  function saveCacheSoon() {
    clearTimeout(cacheSaveTimer);
    cacheSaveTimer = window.setTimeout(() => {
      try {
        const entries = [...counts.entries()].slice(-MAX_CACHE_ENTRIES);
        sessionStorage.setItem(CACHE_KEY, JSON.stringify(entries));
      } catch (_) {
        // Caching is optional; the script works without it.
      }
    }, 500);
  }

  function debug(message, error) {
    // Deliberately logs no access tokens, cookies, or request headers.
    console.debug(`[Spotify Stream Counts] ${message}`, error ?? '');
  }
})();