Skip to content

Commit 857960f

Browse files
authored
fix(beatport_importer): avoid SPA navigation bugs via fetch() interception (#838)
- in the previous #744 PR I implemented an SPA navigation interception in order to re-run the necessary bits of the script when navigation happens; however there was a bug in that implementation: navigating to a release page after a fresh load from the home page would not trigger the script. The reason is: `__NEXT_DATA__` never gets updated upon such navigations. - an easy solution is to run the data fetch request towards Beatport API every time such navigation happens, not only when the Release ID from the state matches the one from the URL. However, since Beatport already runs a fetch request to get the release data for itself, it makes more sense to intercept that request and grab the data we need from it, and then return it back to Beaport. This way it is possible to speed up script execution and avoid running an unnecessary copycat query towards the API.
1 parent fd53dd3 commit 857960f

File tree

5 files changed

+100
-35
lines changed

5 files changed

+100
-35
lines changed

src/lib/shared/spa-navigation.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
type SubscribeToSPANavigationProps = {
2-
beforeNavigate?: () => void;
32
onNavigate: () => Promise<void>;
43
delay?: number;
54
};
@@ -8,12 +7,11 @@ type SubscribeToSPANavigationProps = {
87
* Subscribe to Single Page Application (SPA) navigation events.
98
* Works with frameworks like Next.js that use pushState/replaceState for client-side routing.
109
*
11-
* @param beforeNavigate - Callback function to execute before navigation occurs
1210
* @param onNavigate - Callback function to execute when navigation occurs
1311
* @param delay - Delay in milliseconds before calling onNavigate (default: 200ms)
1412
* @returns Cleanup function to unsubscribe from navigation events
1513
*/
16-
export function subscribeToSPANavigation({ beforeNavigate, onNavigate, delay = 200 }: SubscribeToSPANavigationProps): () => void {
14+
export function subscribeToSPANavigation({ onNavigate, delay = 200 }: SubscribeToSPANavigationProps): () => void {
1715
let currentUrl = window.location.href;
1816
const originalPushState = history.pushState.bind(history);
1917
const originalReplaceState = history.replaceState.bind(history);
@@ -22,7 +20,6 @@ export function subscribeToSPANavigation({ beforeNavigate, onNavigate, delay = 2
2220
const newUrl = window.location.href;
2321
if (newUrl !== currentUrl) {
2422
currentUrl = newUrl;
25-
beforeNavigate?.();
2623
setTimeout(() => {
2724
void onNavigate();
2825
}, delay);
@@ -44,7 +41,6 @@ export function subscribeToSPANavigation({ beforeNavigate, onNavigate, delay = 2
4441
// Listen for browser navigation (back/forward)
4542
const popstateHandler = () => {
4643
currentUrl = window.location.href;
47-
beforeNavigate?.();
4844
setTimeout(() => {
4945
void onNavigate();
5046
}, delay);

src/userscripts/beatport_importer/index.ts

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
1-
import { type ArtistCredit, type Disc, type Label, type Release, type Track, type URL } from '~/types/importers';
2-
import { MBImport } from '~/lib/mbimport';
31
import { Logger, LogLevel } from '~/lib/logger';
2+
import { MBImport } from '~/lib/mbimport';
43
import { MBImportStyle } from '~/lib/mbimportstyle';
54
import { subscribeToSPANavigation } from '~/lib/shared/spa-navigation';
6-
import { getBeatportReleaseData } from './utils/getBeatportReleaseData';
5+
import { type ArtistCredit, type Disc, type Label, type Release, type Track, type URL } from '~/types/importers';
6+
import { getBeatportReleaseData, installFetchInterceptor } from './utils/getBeatportReleaseData';
7+
78
import type { BeatportReleaseData, BeatportTrackData } from './types';
89

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

12+
// Capture Beatport's release fetches to avoid duplicate requests on SPA navigation
13+
installFetchInterceptor(LOGGER);
14+
1115
// prevent JQuery conflicts, see http://wiki.greasespot.net/@grant
1216
window.$ = window.jQuery = jQuery.noConflict(true);
1317

@@ -22,11 +26,19 @@ const cleanup = () => {
2226
$(`#${MB_IMPORT_BARCODE_ELEMENT}`).remove();
2327
};
2428

25-
async function processReleasePage() {
26-
const releaseData = await getBeatportReleaseData(LOGGER);
29+
async function processReleasePage(ranFrom: string) {
30+
cleanup();
2731

2832
const isReleasePage = window.location.pathname.includes('/release/');
29-
if (!releaseData || !isReleasePage) {
33+
if (!isReleasePage) {
34+
return;
35+
}
36+
37+
console.log('ranFrom', ranFrom);
38+
39+
const releaseData = await getBeatportReleaseData(LOGGER);
40+
if (!releaseData || !releaseData.pageProps.release) {
41+
LOGGER.error('Could not find release data on the release page');
3042
return;
3143
}
3244

@@ -61,16 +73,15 @@ async function processReleasePage() {
6173

6274
// Subscribe to SPA navigation events
6375
subscribeToSPANavigation({
64-
beforeNavigate: cleanup,
65-
onNavigate: processReleasePage,
76+
onNavigate: () => processReleasePage('SPA navigation'),
6677
});
6778

6879
$(document).ready(() => {
6980
MBImportStyle();
7081

7182
// Process initial page load
7283
setTimeout(() => {
73-
void processReleasePage();
84+
void processReleasePage('initial page load');
7485
}, 1000);
7586
});
7687

src/userscripts/beatport_importer/meta.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "Import Beatport releases to MusicBrainz",
33
"description": "One-click importing of releases from beatport.com/release pages into MusicBrainz",
4-
"version": "2026.03.14.1",
4+
"version": "2026.03.14.2",
55
"author": "VxJasonxV",
66
"namespace": "https://github.com/murdos/musicbrainz-userscripts/",
77
"downloadURL": "https://raw.githubusercontent.com/murdos/musicbrainz-userscripts/dist/beatport_importer.user.js",

src/userscripts/beatport_importer/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ export type BeatportSSRState = {
100100

101101
export type BeatportPageData = {
102102
pageProps: {
103-
release: BeatportReleaseData;
103+
release: BeatportReleaseData | undefined;
104104
dehydratedState: {
105105
queries: Array<{
106106
queryKey: string;
Lines changed: 77 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,92 @@
11
import type { BeatportPageData, BeatportSSRState } from '../types';
22
import { Logger } from '~/lib/logger';
33

4-
export const getBeatportReleaseData = async (logger: Logger): Promise<BeatportPageData | null> => {
5-
const initialNextDataElement = document.getElementById('__NEXT_DATA__');
6-
if (!initialNextDataElement) {
4+
/**
5+
* Cache for release data intercepted from Beatport's own fetch requests.
6+
* When the user navigates via SPA, Beatport fetches the release JSON — we capture
7+
* it here to avoid making a duplicate request.
8+
*/
9+
const interceptedReleaseCache = new Map<string, BeatportPageData>();
10+
11+
/**
12+
* Install a fetch interceptor to capture Beatport's release data responses.
13+
* Call once at script load, before any navigation can occur.
14+
*/
15+
export function installFetchInterceptor(logger: Logger): void {
16+
const originalFetch = window.fetch.bind(window);
17+
window.fetch = async function (input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
18+
const response = await originalFetch(input, init);
19+
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
20+
const releaseMatch = url.match(/beatport\.com\/_next\/data\/[^/]+\/[a-z]{2}(?:-[a-z]{2})?\/release\/[^/]+\/(\d+)\.json/);
21+
const releaseId = releaseMatch?.[1];
22+
if (releaseId && response.ok) {
23+
response
24+
.clone()
25+
.json()
26+
.then((data: unknown) => {
27+
const pageData = data as BeatportPageData;
28+
const releaseIdFromData = pageData.pageProps.release?.id.toString();
29+
if (releaseIdFromData === releaseId) {
30+
interceptedReleaseCache.set(releaseId, pageData);
31+
}
32+
})
33+
.catch((error: unknown) => {
34+
logger.error('Error parsing release data: ', error as Error);
35+
});
36+
}
37+
return response;
38+
};
39+
}
40+
41+
function getLocaleFromPath(): string {
42+
const match = window.location.pathname.match(/^\/([a-z]{2}(?:-[a-z]{2})?)\//);
43+
return match?.[1] ?? 'en';
44+
}
45+
46+
async function fetchReleaseFromNextDataApi(buildId: string, releaseId: string, logger: Logger): Promise<BeatportPageData | null> {
47+
const locale = getLocaleFromPath();
48+
const name_placeholder = '0'; // NextJS ignores this parameter
49+
const pageDataURL = `https://www.beatport.com/_next/data/${buildId}/${locale}/release/${name_placeholder}/${releaseId}.json?id=${releaseId}`;
50+
try {
51+
const response = await fetch(pageDataURL);
52+
const pageData = (await response.json()) as unknown as BeatportPageData;
53+
return pageData;
54+
} catch (error) {
55+
logger.error('Error fetching release data:', error);
756
return null;
857
}
9-
const data = JSON.parse(initialNextDataElement.innerHTML) as unknown as BeatportSSRState;
58+
}
1059

11-
const buildId = data.buildId;
12-
const initialReleaseId = data.props.pageProps.release.id.toString();
60+
export const getBeatportReleaseData = async (logger: Logger): Promise<BeatportPageData | null> => {
1361
const releaseIdFromURL = window.location.pathname.match(/release\/[^/]+\/(\d+)/)?.[1];
14-
1562
if (!releaseIdFromURL) {
1663
return null;
1764
}
18-
if (releaseIdFromURL === initialReleaseId) {
19-
return data.props;
20-
} else if (releaseIdFromURL !== initialReleaseId) {
21-
const name_placeholder = '0'; // NextJS ignores this parameter
22-
const pageDataURL = `https://www.beatport.com/_next/data/${buildId}/en/release/${name_placeholder}/${releaseIdFromURL}.json`;
23-
try {
24-
const response = await fetch(pageDataURL);
25-
const pageData = (await response.json()) as unknown as BeatportPageData;
26-
return pageData;
27-
} catch (error) {
28-
logger.error('Error fetching release data:', error);
29-
return null;
65+
66+
// Use intercepted response from Beatport's own fetch (SPA navigation) to avoid duplicate request
67+
const cached = interceptedReleaseCache.get(releaseIdFromURL);
68+
if (cached) {
69+
interceptedReleaseCache.delete(releaseIdFromURL);
70+
return cached;
71+
}
72+
73+
const initialNextDataElement = document.getElementById('__NEXT_DATA__');
74+
if (initialNextDataElement) {
75+
const data = JSON.parse(initialNextDataElement.innerHTML) as unknown as BeatportSSRState;
76+
const initialReleaseId = data.props.pageProps.release?.id.toString();
77+
78+
// __NEXT_DATA__ has matching release (direct load or refresh)
79+
if (initialReleaseId === releaseIdFromURL) {
80+
return data.props;
81+
}
82+
83+
// __NEXT_DATA__ is from a different page (e.g. Home) or different release - fetch from API
84+
const buildId = data.buildId;
85+
if (buildId) {
86+
return fetchReleaseFromNextDataApi(buildId, releaseIdFromURL, logger);
3087
}
3188
}
3289

90+
logger.error('Cannot fetch release data: no __NEXT_DATA__ or buildId found');
3391
return null;
3492
};

0 commit comments

Comments
 (0)