Beatsource Metadata Inspector

Release info + metadata panel using Beatsource API

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

You will need to install an extension such as Tampermonkey to install this script.

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         Beatsource Metadata Inspector
// @namespace    beatsource-metadata-inspector
// @version      1.2.12
// @description  Release info + metadata panel using Beatsource API
// @match        https://www.beatsource.com/release/*
// @license      MIT
// @grant        none
// ==/UserScript==

(function () {
"use strict";

/******************************************************************
 * CONFIG
 ******************************************************************/

const PANEL_ID = "bs-release-info-panel";

const RELEASE_DATE_FIELDS = [
  "new_release_date",
  "publish_date",
  "pre_order_date",
  "encoded_date",
  "updated"
];

const TRACK_DATE_FIELDS = [
  "new_release_date",
  "publish_date",
  "pre_order_date",
  "encoded_date"
];

/******************************************************************
 * HELPERS
 ******************************************************************/

function log(...a) {
  console.log("[BS Panel]", ...a);
}

function isReleasePage() {
  return /^\/release\/.+/.test(location.pathname);
}

function removePanel() {
  document.getElementById(PANEL_ID)?.remove();
}

function getBuildId() {
  return window.__NEXT_DATA__?.buildId;
}

function getReleaseIdFromUrl() {
  const urlPath = location.pathname;
  const parts = urlPath.split("/");
  return parts[3]; // /release/title/ID
}

function normalize(val) {
  if (!val) return null;
  return String(val).trim();
}

function equalNormalized(a, b) {
  return normalize(a) && normalize(a) === normalize(b);
}

function extractIdentifiers(release) {
  if (!release) return {};

  return {
    upc: normalize(release.upc),
    catalog_number: normalize(release.catalog_number),
    gridName: normalize(release.gridName),
    grid: normalize(release.grid)
  };
}

function determinePrimaryCatalog({ upc, catalog_number, gridName, grid }) {
  if (catalog_number && !equalNormalized(catalog_number, upc)) {
    return { source: "catalog_number", value: catalog_number };
  }

  if (gridName) return { source: "gridName", value: gridName };
  if (grid) return { source: "grid", value: grid };

  return null;
}

function extractDates(obj, fields) {
  const out = {};

  for (const f of fields) {
    const v = obj?.[f];
    if (typeof v === "string" && /^\d{4}-\d{2}-\d{2}/.test(v)) {
      out[f] = v.slice(0, 10);
    }
  }

  return out;
}

function getConflictingDates(trackDates, releaseDates) {
  const conflicts = {};

  for (const key in trackDates) {
    if (!releaseDates[key]) continue;

    if (trackDates[key] !== releaseDates[key]) {
      conflicts[key] = trackDates[key];
    }
  }

  return conflicts;
}

function detectReleaseWarnings(dates) {
  const warnings = [];

  const main = dates.new_release_date;
  if (!main) return warnings;

  // YYYY-01-01 placeholder detection
  if (/^\d{4}-01-01$/.test(main)) {
    warnings.push("⚠ 1 January placeholder date");
  }

  // pre Spotify launch
  if (main < "2008-10-07") {
    warnings.push("⚠ Pre-Spotify launch date");
  }

  // pre iTunes launch
  if (main < "2001-01-09") {
    warnings.push("⚠ Pre-iTunes launch date");
  }

  return warnings;
}

/******************************************************************
 * API FETCH
 ******************************************************************/

async function fetchReleaseData() {
  const buildId = getBuildId();
  const id = getReleaseIdFromUrl();

  if (!buildId || !id) return null;

  try {
    const res = await fetch(
      `/_next/data/${buildId}/release.json?id=${id}`,
      { credentials: "same-origin" }
    );

    if (!res.ok) throw new Error(res.status);

    const json = await res.json();

    return {
      release: json?.pageProps?.release || null,
      tracks: json?.pageProps?.tracks?.results || []
    };

  } catch (e) {
    log("Fetch failed:", e);
    return null;
  }
}

/******************************************************************
 * METADATA EXTRACTION
 ******************************************************************/

function extractReleaseMeta(release) {
  return {
    dates: extractDates(release, RELEASE_DATE_FIELDS),
    warnings: detectReleaseWarnings(
      extractDates(release, RELEASE_DATE_FIELDS)
    )
  };
}

/******************************************************************
 * PANEL UI
 ******************************************************************/

function createPanel(release, tracks) {
  removePanel();

  const meta = extractReleaseMeta(release);
  const releaseDates = meta.dates;

  const container = document.createElement("div");
  container.id = PANEL_ID;

  // Gray container styling (like your preferred scripts)
  container.style.cssText = `
    position: fixed;
    right: 16px;
    top: 80px;
    z-index: 9999;
    width: 340px;
    font-size: 12px;
    font-family: system-ui, sans-serif;
  `;

  const details = document.createElement("details");
  details.open = false;

  details.style.cssText = `
    background: #f2f2f2;
    border: 1px solid #d0d0d0;
    border-radius: 10px;
    padding: 10px;
    color: #222;
    box-shadow: 0 2px 8px rgba(0,0,0,.15);
    max-height: 70vh;
    overflow: auto;
  `;

  const summary = document.createElement("summary");
  summary.textContent = "Release Info";
  summary.style.cssText = `
    cursor: pointer;
    font-weight: 600;
    font-size: 14px;
    margin-bottom: 6px;
  `;

  const body = document.createElement("div");

  /***** RELEASE METADATA *****/

  const ids = extractIdentifiers(release);
  const primaryCatalog = determinePrimaryCatalog(ids);

  let html = `
    <div style="margin-bottom:8px">
  `;

  if (primaryCatalog) {
    html += `<div><b>Primary catalog:</b> ${primaryCatalog.value} (${primaryCatalog.source})</div>`;
  }

  if (ids.catalog_number && equalNormalized(ids.catalog_number, ids.upc)) {
    html += `<div><b>UPC / catalog_number:</b> ${ids.upc} (same value)</div>`;
  } else {
    html += `<div><b>UPC:</b> ${ids.upc ?? "—"}</div>`;
    html += `<div><b>catalog_number:</b> ${ids.catalog_number ?? "—"}</div>`;
  }
  
  if (ids.gridName)
    html += `
      <div><b>Grid Name:</b> ${ids.gridName}</div>
    `;
  
  if (ids.grid )
    html += `
      <div><b>Grid:</b> ${ids.grid }</div>
    `;
  
  html += `<div><b>Release timestamps:</b></div>`;

  RELEASE_DATE_FIELDS.forEach(field => {
    if (releaseDates[field]) {
      html += `
        <div style="margin-left:10px">
          ${field}: ${releaseDates[field]}
        </div>
      `;
    }
  });

  if (meta.warnings.length) {
    html += `<div style="color:#b00020;margin-top:4px">`;

    meta.warnings.forEach(w => {
      html += `<div>${w}</div>`;
    });

    html += `</div>`;
  }
  
  html += `
    <div><b>Tracks:</b> ${tracks.length}</div>
  </div>
  <hr>
  `;

  /***** TRACK LIST *****/
  let tracknumber = 1;

  tracks.forEach(track => {
    const trackDates = extractDates(track, TRACK_DATE_FIELDS);
    const conflicts = getConflictingDates(trackDates, releaseDates);

    html += `
      <div style="margin-bottom:8px">
        ${tracknumber}. ${track.name ?? "Untitled"}
        ${track.mix_name ? `(${track.mix_name})` : ""}
        <br>
        ${track.isrc ? `<span style="opacity:.7">ISRC: ${track.isrc}</span><br>` : ""}
    `;

    if (Object.keys(conflicts).length > 0)
    Object.entries(conflicts).forEach(([k, v]) => {
      html += `
        <div style="color:#b00020;margin-left:10px;opacity:.8">
          ⚠ ${k}: ${v}
        </div>
      `;
    });

    html += `</div>`;

    tracknumber++;
  });

  body.innerHTML = html;

  details.append(summary, body);
  container.appendChild(details);
  document.body.appendChild(container);
}

/******************************************************************
 * MAIN RENDER
 ******************************************************************/

async function render() {
  if (!isReleasePage()) {
    removePanel();
    return;
  }

  const data = await fetchReleaseData();
  if (!data?.release) return;

  createPanel(data.release, data.tracks);
}

/******************************************************************
 * SPA ROUTE DETECTION
 ******************************************************************/

function hookHistoryEvents() {
  const pushState = history.pushState;
  const replaceState = history.replaceState;

  function trigger() {
    setTimeout(render, 200);
  }

  history.pushState = function () {
    pushState.apply(this, arguments);
    trigger();
  };

  history.replaceState = function () {
    replaceState.apply(this, arguments);
    trigger();
  };

  window.addEventListener("popstate", trigger);
}

/******************************************************************
 * INIT
 ******************************************************************/

function init() {
  hookHistoryEvents();
  render();
}

if (document.readyState === "loading")
  document.addEventListener("DOMContentLoaded", init);
else
  init();

})();