Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions src/lib/shared/spa-navigation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
type SubscribeToSPANavigationProps = {
beforeNavigate?: () => void;
onNavigate: () => Promise<void>;
delay?: number;
};

/**
* Subscribe to Single Page Application (SPA) navigation events.
* Works with frameworks like Next.js that use pushState/replaceState for client-side routing.
*
* @param beforeNavigate - Callback function to execute before navigation occurs
* @param onNavigate - Callback function to execute when navigation occurs
* @param delay - Delay in milliseconds before calling onNavigate (default: 200ms)
* @returns Cleanup function to unsubscribe from navigation events
*/
export function subscribeToSPANavigation({ beforeNavigate, onNavigate, delay = 200 }: SubscribeToSPANavigationProps): () => void {
let currentUrl = window.location.href;
const originalPushState = history.pushState.bind(history);
const originalReplaceState = history.replaceState.bind(history);

const handleNavigation = () => {
const newUrl = window.location.href;
if (newUrl !== currentUrl) {
currentUrl = newUrl;
beforeNavigate?.();
setTimeout(() => {
void onNavigate();
}, delay);
}
};

// Intercept pushState (used by Next.js and most SPAs)
history.pushState = function (...args) {
originalPushState.apply(history, args);
handleNavigation();
};

// Intercept replaceState
history.replaceState = function (...args) {
originalReplaceState.apply(history, args);
handleNavigation();
};

// Listen for browser navigation (back/forward)
const popstateHandler = () => {
currentUrl = window.location.href;
beforeNavigate?.();
setTimeout(() => {
void onNavigate();
}, delay);
};
window.addEventListener('popstate', popstateHandler);

// Return cleanup function
return () => {
history.pushState = originalPushState;
history.replaceState = originalReplaceState;
window.removeEventListener('popstate', popstateHandler);
};
}
87 changes: 59 additions & 28 deletions src/userscripts/beatport_importer/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,41 +2,75 @@ import { type ArtistCredit, type Disc, type Label, type Release, type Track, typ
import { MBImport } from '~/lib/mbimport';
import { Logger, LogLevel } from '~/lib/logger';
import { MBImportStyle } from '~/lib/mbimportstyle';
import type { BeatportPageData, BeatportReleaseData, BeatportTrackData } from './types';
import { subscribeToSPANavigation } from '~/lib/shared/spa-navigation';
import { getBeatportReleaseData } from './utils/getBeatportReleaseData';
import type { BeatportReleaseData, BeatportTrackData } from './types';

const LOGGER = new Logger('beatport_importer', LogLevel.INFO);

// prevent JQuery conflicts, see http://wiki.greasespot.net/@grant
window.$ = window.jQuery = jQuery.noConflict(true);

$(document).ready(() => {
MBImportStyle();
const MB_IMPORT_ELEMENT = 'div.musicbrainz-import';
const MB_IMPORT_BARCODE_ELEMENT = 'mb-import-barcode';

/**
* Remove existing MusicBrainz import UI to avoid duplicates
*/
const cleanup = () => {
$(MB_IMPORT_ELEMENT).remove();
$(`#${MB_IMPORT_BARCODE_ELEMENT}`).remove();
};

async function processReleasePage() {
const releaseData = await getBeatportReleaseData(LOGGER);

const isReleasePage = window.location.pathname.includes('/release/');
if (!releaseData || !isReleasePage) {
return;
}

const release_url = window.location.href.replace('/?.*$/', '').replace(/#.*$/, '');

const data = JSON.parse(document.getElementById('__NEXT_DATA__')!.innerHTML) as unknown as BeatportPageData;
const release_data = data.props.pageProps.release;
try {
const release = releaseData.pageProps.release;

// Reversing is less reliable, but the API does not provide track numbers.
const tracks_table = release_data.tracks.reverse();
// Reversing is less reliable, but the API does not provide track numbers.
const tracks_table = release.tracks.reverse();

const tracks_release = $.grep(data.props.pageProps.dehydratedState.queries, element =>
element ? /tracks/g.test(element.queryKey) : false,
)[0];
const tracks_data_array = tracks_release?.state?.data.results;
if (!tracks_data_array) {
LOGGER.error('Could not find tracks data');
return;
const tracks_release = $.grep(releaseData.pageProps.dehydratedState.queries, element =>
element ? /tracks/g.test(element.queryKey) : false,
)[0];
const tracks_data_array = tracks_release?.state?.data.results;
if (!tracks_data_array) {
LOGGER.error('Could not find tracks data');
return;
}
const tracks_data = $.map(tracks_table, (url: string) =>
$.grep(tracks_data_array, element => (element ? element.url === url : false)),
) as BeatportTrackData[];
const isrcs = tracks_data.map(track => track.isrc || null);

const mbrelease = retrieveReleaseInfo(release_url, release, tracks_data);

insertMBButtons(mbrelease, release_url, isrcs);
} catch (error) {
LOGGER.error('Error processing release page:', error);
}
const tracks_data = $.map(tracks_table, (url: string) =>
$.grep(tracks_data_array, element => (element ? element.url === url : false)),
) as BeatportTrackData[];
const isrcs = tracks_data.map(track => track.isrc || null);
}

const mbrelease = retrieveReleaseInfo(release_url, release_data, tracks_data);
// Subscribe to SPA navigation events
subscribeToSPANavigation({
beforeNavigate: cleanup,
onNavigate: processReleasePage,
});

$(document).ready(() => {
MBImportStyle();

// Process initial page load
setTimeout(() => {
insertLink(mbrelease, release_url, isrcs);
void processReleasePage();
}, 1000);
});

Expand Down Expand Up @@ -128,15 +162,15 @@ function retrieveReleaseInfo(release_url: string, release_data: BeatportReleaseD
}

// Insert button into page under label information
function insertLink(mbrelease: Release, release_url: string, isrcs: (string | null)[]): void {
function insertMBButtons(mbrelease: Release, release_url: string, isrcs: (string | null)[]): void {
const edit_note = MBImport.makeEditNote(release_url, 'Beatport');
const parameters = MBImport.buildFormParameters(mbrelease, edit_note);

const mbUI = $(
`<div class="interior-release-chart-content-item musicbrainz-import">${MBImport.buildFormHTML(
parameters,
)}${MBImport.buildSearchButton(mbrelease)}</div>`,
).hide();
);

$(
'<form class="musicbrainz_import"><button type="submit" title="Submit ISRCs to MusicBrainz with kepstin’s MagicISRC"><span>Submit ISRCs</span></button></form>',
Expand All @@ -149,7 +183,7 @@ function insertLink(mbrelease: Release, release_url: string, isrcs: (string | nu
.appendTo(mbUI);

$('div[title="Collection controls"]').append(mbUI);
$('div.musicbrainz-import').css({ display: 'flex', gap: '5px', flexWrap: 'wrap' });
$(MB_IMPORT_ELEMENT).css({ display: 'flex', gap: '5px', flexWrap: 'wrap' });
$('form.musicbrainz_import button').css({ width: '120px' });

const lastReleaseInfo = $('div[class^="ReleaseDetailCard-style__Info"]').last();
Expand All @@ -159,13 +193,10 @@ function insertLink(mbrelease: Release, release_url: string, isrcs: (string | nu
</a>`
: '[none]';
const releaseInfoBarcode = $(
`<div class="${lastReleaseInfo.attr('class')}">
`<div class="${lastReleaseInfo.attr('class')}" id="${MB_IMPORT_BARCODE_ELEMENT}">
<p>Barcode</p>
<span>${spanHTML}</span>
</div>`,
).hide();
);
lastReleaseInfo.after(releaseInfoBarcode);

mbUI.slideDown();
releaseInfoBarcode.slideDown();
}
4 changes: 2 additions & 2 deletions src/userscripts/beatport_importer/meta.json
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
{
"name": "Import Beatport releases to MusicBrainz",
"description": "One-click importing of releases from beatport.com/release pages into MusicBrainz",
"version": "2025.11.15.1",
"version": "2026.03.14.1",
"author": "VxJasonxV",
"namespace": "https://github.com/murdos/musicbrainz-userscripts/",
"downloadURL": "https://raw.githubusercontent.com/murdos/musicbrainz-userscripts/dist/beatport_importer.user.js",
"updateURL": "https://raw.githubusercontent.com/murdos/musicbrainz-userscripts/dist/beatport_importer.user.js",
"match": ["https://www.beatport.com/release/*"],
"match": ["https://www.beatport.com/*"],
"require": ["https://ajax.googleapis.com/ajax/libs/jquery/2.1.4/jquery.min.js"],
"icon": "https://raw.githubusercontent.com/murdos/musicbrainz-userscripts/master/assets/images/Musicbrainz_import_logo.png"
}
27 changes: 15 additions & 12 deletions src/userscripts/beatport_importer/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,20 +93,23 @@ export type BeatportTrackData = {
url?: string;
};

export type BeatportSSRState = {
buildId?: string;
props: BeatportPageData;
};

export type BeatportPageData = {
props: {
pageProps: {
release: BeatportReleaseData;
dehydratedState: {
queries: Array<{
queryKey: string;
state?: {
data: {
results: BeatportTrackData[];
};
pageProps: {
release: BeatportReleaseData;
dehydratedState: {
queries: Array<{
queryKey: string;
state?: {
data: {
results: BeatportTrackData[];
};
}>;
};
};
}>;
};
};
};
34 changes: 34 additions & 0 deletions src/userscripts/beatport_importer/utils/getBeatportReleaseData.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { BeatportPageData, BeatportSSRState } from '../types';
import { Logger } from '~/lib/logger';

export const getBeatportReleaseData = async (logger: Logger): Promise<BeatportPageData | null> => {
const initialNextDataElement = document.getElementById('__NEXT_DATA__');
if (!initialNextDataElement) {
return null;
}
const data = JSON.parse(initialNextDataElement.innerHTML) as unknown as BeatportSSRState;

const buildId = data.buildId;
const initialReleaseId = data.props.pageProps.release.id.toString();
const releaseIdFromURL = window.location.pathname.match(/release\/[^/]+\/(\d+)/)?.[1];

if (!releaseIdFromURL) {
return null;
}
if (releaseIdFromURL === initialReleaseId) {
return data.props;
} else if (releaseIdFromURL !== initialReleaseId) {
const name_placeholder = '0'; // NextJS ignores this parameter
const pageDataURL = `https://www.beatport.com/_next/data/${buildId}/en/release/${name_placeholder}/${releaseIdFromURL}.json`;
try {
const response = await fetch(pageDataURL);
const pageData = (await response.json()) as unknown as BeatportPageData;
return pageData;
} catch (error) {
logger.error('Error fetching release data:', error);
return null;
}
}

return null;
};
Loading