Skip to content

Commit 75a8d68

Browse files
authored
feat(fanlink_importer): add new importer for fanlink.tv (#1052)
1 parent 580b23f commit 75a8d68

6 files changed

Lines changed: 211 additions & 0 deletions

File tree

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
- [Import Encyclopedisque releases to MusicBrainz](#encyclopedisque_importer)
1111
- [<img src="assets/icons/typescript.svg" alt="TypeScript" width="16" height="16"> Import FFM releases to MusicBrainz](#ffm_importer)
1212
- [Import FMA releases to MusicBrainz](#fma_importer)
13+
- [<img src="assets/icons/typescript.svg" alt="TypeScript" width="16" height="16"> Import Fanlink releases to MusicBrainz](#fanlink_importer)
1314
- [Import HDtracks releases into MusicBrainz](#hdtracks_importer)
1415
- [Import Loot releases to MusicBrainz](#loot_importer)
1516
- [Import Metal Archives releases into MusicBrainz](#metalarchives_importer)
@@ -99,6 +100,13 @@ Add a button to import https://freemusicarchive.org/ releases to MusicBrainz via
99100
[![Source](assets/buttons/button-source.svg)](https://github.com/murdos/musicbrainz-userscripts/blob/master/fma_importer.user.js)
100101
[![Install](assets/buttons/button-install.svg)](https://raw.github.com/murdos/musicbrainz-userscripts/master/fma_importer.user.js)
101102

103+
## <a name="fanlink_importer"></a> <img src="assets/icons/typescript.svg" alt="TypeScript" width="16" height="16"> Import Fanlink releases to MusicBrainz
104+
105+
Import fanlink.tv smart links with Harmony and add their remaining URL relationships to MusicBrainz.
106+
107+
[![Source](assets/buttons/button-source.svg)](https://github.com/murdos/musicbrainz-userscripts/blob/dist/fanlink_importer.user.js)
108+
[![Install](assets/buttons/button-install.svg)](https://raw.githubusercontent.com/murdos/musicbrainz-userscripts/dist/fanlink_importer.user.js)
109+
102110
## <a name="hdtracks_importer"></a> Import HDtracks releases into MusicBrainz
103111

104112
One-click importing of releases from hdtracks.com into MusicBrainz. Also allows to submit their ISRCs to MusicBrainz releases.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# fanlink.tv importer
2+
3+
This userscript connects a `fanlink.tv` smart-link page to its MusicBrainz release. It can start a release import through Harmony and add provider URLs that are missing from an existing release.
4+
5+
Provider destinations are read from Fanlink's `window.preloadLink` page data because its rendered service rows do not contain links. Redirecting destinations are resolved before the shared smart-link importer normalizes them, checks MusicBrainz, highlights relationships already present, and prepares any missing URL relationships.
6+
7+
Example:
8+
9+
`https://fanlink.tv/CraveYou`
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { followRedirect, runSmartLinkImporter, type ServiceElement } from '~/lib/smart-link-importer';
2+
import { isIgnoredService, isPhysicalMediaLink, normalizeServiceName } from '~/lib/smart-link-importer/logic';
3+
4+
import { extractFanlinkServiceDataFromScript, type FanlinkServiceData } from './logic';
5+
6+
function readServiceData(): FanlinkServiceData[] {
7+
for (const script of document.scripts) {
8+
const links = extractFanlinkServiceDataFromScript(script.textContent);
9+
if (links.length > 0) return links;
10+
}
11+
return [];
12+
}
13+
14+
function serviceFromElement(element: HTMLElement): string {
15+
const imageUrl = element.querySelector<HTMLImageElement>('.link-option-row-img')?.src;
16+
if (!imageUrl) return '';
17+
18+
try {
19+
const filename = new URL(imageUrl).pathname.split('/').pop() ?? '';
20+
return normalizeServiceName(filename.replace(/\.[^.]+$/, ''));
21+
} catch {
22+
return '';
23+
}
24+
}
25+
26+
function collectServiceElements(): ServiceElement[] {
27+
const dataByService = new Map<string, FanlinkServiceData[]>();
28+
for (const data of readServiceData()) {
29+
const services = dataByService.get(data.service) ?? [];
30+
services.push(data);
31+
dataByService.set(data.service, services);
32+
}
33+
34+
const counters = new Map<string, number>();
35+
const elements: ServiceElement[] = [];
36+
for (const element of document.querySelectorAll<HTMLElement>('.link-options a.link-option-row')) {
37+
const service = serviceFromElement(element);
38+
const data = dataByService.get(service)?.shift();
39+
const action = element.querySelector<HTMLElement>('.link-option-row-action')?.textContent.trim() || '';
40+
if (!data || isIgnoredService(service) || isPhysicalMediaLink(service, action)) continue;
41+
42+
const count = (counters.get(service) ?? 0) + 1;
43+
counters.set(service, count);
44+
elements.push({
45+
cacheKey: count === 1 ? service : `${service}:${count}`,
46+
element,
47+
service,
48+
label: element.querySelector<HTMLImageElement>('img[alt]')?.alt || data.label,
49+
action,
50+
sourceUrl: data.sourceUrl,
51+
});
52+
}
53+
return elements;
54+
}
55+
56+
void runSmartLinkImporter({
57+
id: 'fanlink',
58+
siteName: 'Fanlink',
59+
collectServiceElements,
60+
resolveDestination: element => followRedirect(element.sourceUrl).catch(() => element.sourceUrl),
61+
});
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { normalizeServiceName } from '~/lib/smart-link-importer/logic';
2+
3+
export interface FanlinkServiceData {
4+
service: string;
5+
label: string;
6+
sourceUrl: string;
7+
}
8+
9+
function record(value: unknown): Record<string, unknown> | undefined {
10+
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
11+
}
12+
13+
function serviceLabel(serviceName: string): string {
14+
return serviceName
15+
.split(/[-_\s]+/)
16+
.filter(Boolean)
17+
.map(word => `${word.charAt(0).toUpperCase()}${word.slice(1)}`)
18+
.join(' ');
19+
}
20+
21+
/** Read active provider destinations from Fanlink's `window.preloadLink` payload. */
22+
export function extractFanlinkServiceData(payload: unknown): FanlinkServiceData[] {
23+
const services = record(payload)?.['services'];
24+
if (!Array.isArray(services)) return [];
25+
26+
const links: FanlinkServiceData[] = [];
27+
for (const value of services) {
28+
const serviceData = record(value);
29+
const rawService = serviceData?.['service_name'];
30+
const sourceUrl = serviceData?.['url'];
31+
if (typeof rawService !== 'string' || typeof sourceUrl !== 'string' || !sourceUrl || serviceData['active'] === false) continue;
32+
33+
const service = normalizeServiceName(rawService);
34+
if (!service) continue;
35+
links.push({
36+
service,
37+
label: serviceLabel(rawService),
38+
sourceUrl,
39+
});
40+
}
41+
return links;
42+
}
43+
44+
/** Parse the preload assignment from Fanlink's inline page script. */
45+
export function extractFanlinkServiceDataFromScript(source: string): FanlinkServiceData[] {
46+
const serializedPayload = /window\.preloadLink\s*=\s*(\{[\s\S]*?\});\s*window\.preloadCustomDomain\s*=/.exec(source)?.[1];
47+
if (!serializedPayload) return [];
48+
49+
try {
50+
return extractFanlinkServiceData(JSON.parse(serializedPayload) as unknown);
51+
} catch {
52+
return [];
53+
}
54+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"name": "Import Fanlink releases to MusicBrainz",
3+
"description": "Import fanlink.tv smart links with Harmony and add their remaining URL relationships to MusicBrainz.",
4+
"version": "2026.08.28.1",
5+
"author": "Raman Sinclair",
6+
"namespace": "https://github.com/murdos/musicbrainz-userscripts/",
7+
"downloadURL": "https://raw.githubusercontent.com/murdos/musicbrainz-userscripts/dist/fanlink_importer.user.js",
8+
"updateURL": "https://raw.githubusercontent.com/murdos/musicbrainz-userscripts/dist/fanlink_importer.user.js",
9+
"match": ["https://fanlink.tv/*", "https://*.fanlink.tv/*"],
10+
"connect": ["*"],
11+
"grant": ["GM.getValue", "GM.setValue", "GM.xmlHttpRequest", "GM_getValue", "GM_setValue", "GM_xmlhttpRequest"],
12+
"runAt": "document-idle",
13+
"icon": "https://metabrainz.org/static/img/projects/musicbrainz.svg"
14+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { extractFanlinkServiceData, extractFanlinkServiceDataFromScript } from '~/userscripts/fanlink_importer/logic';
4+
5+
describe('fanlink.tv importer logic', () => {
6+
it('extracts active provider destinations from the preload payload', () => {
7+
const payload = {
8+
services: [
9+
{
10+
id: 21718,
11+
url: 'https://mounika.bandcamp.com/track/crave-you-ft-racoon-racoon',
12+
active: true,
13+
service_name: 'bandcamp',
14+
},
15+
{
16+
id: 91222,
17+
url: 'https://music.apple.com/fr/album/crave-you-single/6766943288?at=1001lbRT',
18+
active: true,
19+
service_name: 'apple-music',
20+
},
21+
{
22+
id: 1,
23+
url: 'https://example.com/unavailable',
24+
active: false,
25+
service_name: 'Unavailable Store',
26+
},
27+
],
28+
};
29+
30+
expect(extractFanlinkServiceData(payload)).toEqual([
31+
{
32+
service: 'bandcamp',
33+
label: 'Bandcamp',
34+
sourceUrl: 'https://mounika.bandcamp.com/track/crave-you-ft-racoon-racoon',
35+
},
36+
{
37+
service: 'apple',
38+
label: 'Apple Music',
39+
sourceUrl: 'https://music.apple.com/fr/album/crave-you-single/6766943288?at=1001lbRT',
40+
},
41+
]);
42+
});
43+
44+
it('skips malformed services and unrelated payloads', () => {
45+
expect(extractFanlinkServiceData(null)).toEqual([]);
46+
expect(extractFanlinkServiceData({ services: [{ active: true, service_name: 'spotify' }] })).toEqual([]);
47+
expect(extractFanlinkServiceData({ services: 'not-an-array' })).toEqual([]);
48+
});
49+
50+
it('reads the preload assignment from Fanlink page source', () => {
51+
const source = `
52+
window.preloadLink = {"services":[{"url":"https://open.spotify.com/album/example","active":true,"service_name":"spotify"}]};
53+
window.preloadCustomDomain = null;
54+
`;
55+
56+
expect(extractFanlinkServiceDataFromScript(source)).toEqual([
57+
{
58+
service: 'spotify',
59+
label: 'Spotify',
60+
sourceUrl: 'https://open.spotify.com/album/example',
61+
},
62+
]);
63+
expect(extractFanlinkServiceDataFromScript('window.preloadLink = invalid;')).toEqual([]);
64+
});
65+
});

0 commit comments

Comments
 (0)