-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtrackPreview.js
More file actions
139 lines (121 loc) · 4.8 KB
/
Copy pathtrackPreview.js
File metadata and controls
139 lines (121 loc) · 4.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
/**
* Resolves a track to a playable preview clip.
*
* The repo has no real track audio — the files under public/demystify/genre are
* two-second synthesised blips — so previews come from the iTunes Search API,
* which needs no key, sends `access-control-allow-origin: *`, and returns a
* ~30 second excerpt per track. Nothing is downloaded until a reader presses
* play, and each lookup is cached for the session.
*
* Apple's catalogue does not cover everything, and its search always returns
* *something*. Taking the first result on faith is how "Wyclef Jean" by Young
* Thug ends up playing a Wyclef Jean record. Every result is therefore checked
* against the track we asked for, and a mismatch is treated as no match.
*/
const SEARCH = 'https://itunes.apple.com/search';
const LOOKUP = 'https://itunes.apple.com/lookup';
/** Cache keyed by the resolved request. A confirmed miss is cached too. */
const cache = new Map();
/**
* Search terms have to be looser than our display strings: the catalogue does
* not know "— 2017 Remaster" or "(In My Hands)", and multi-artist credits only
* match on the lead name.
*/
const clean = (value) =>
String(value || '')
.replace(/\s*[—–]\s*.*$/, '')
.replace(/\([^)]*\)/g, ' ')
.replace(/\s+/g, ' ')
.trim();
const leadArtist = (value) => clean(String(value || '').split(',')[0]);
/** Every credited artist, for the looser match test. */
const allArtists = (value) =>
String(value || '')
.split(/,|&|\bfeat\.?\b|\bwith\b/i)
.map((part) => clean(part).toLowerCase())
.filter(Boolean);
/** Strip everything that varies between a display title and a catalogue title. */
const normalise = (value) =>
String(value || '')
.toLowerCase()
.replace(/\([^)]*\)|\[[^\]]*\]/g, ' ')
.replace(/\s*[—–-]\s*(remaster|remastered|original mix|extended|radio|single|live|instrumental|explicit).*$/i, ' ')
.replace(/[^a-z0-9\s]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
export const previewQuery = (track) =>
`${clean(track?.title)} ${leadArtist(track?.artist)}`.trim();
/**
* Does this catalogue result plausibly *are* the track we asked for?
* Title must contain-or-be-contained, and at least one credited artist has to
* appear on either side — enough to reject unrelated songs without discarding
* legitimate "(feat. …)" and remaster variations.
*/
export const isMatch = (track, result) => {
const wanted = normalise(track?.title);
const got = normalise(result?.trackName);
if (!wanted || !got) return false;
if (!got.includes(wanted) && !wanted.includes(got)) return false;
const resultArtist = String(result?.artistName || '').toLowerCase();
const ours = allArtists(track?.artist);
if (!ours.length) return true;
return ours.some(
(name) => resultArtist.includes(name) || name.includes(resultArtist),
);
};
const toPreview = (hit) =>
hit?.previewUrl
? { url: hit.previewUrl, title: hit.trackName, artist: hit.artistName }
: null;
const request = (url, pick) =>
fetch(url)
.then((res) => {
if (!res.ok) throw new Error(`iTunes responded ${res.status}`);
return res.json();
})
.then((body) => pick(body?.results || []));
/**
* `track.preview` overrides the lookup:
* 'none' — this track is not in the catalogue; do not search
* a URL — a clip to play as-is, for tracks the search cannot reach
* a number — an iTunes track id, fetched directly and trusted
* any text — a replacement search term
*
* @returns {Promise<{url,title,artist}|null>} null when nothing confidently matched
*/
export const findPreview = (track) => {
const override = String(track?.preview || '').trim();
if (override.toLowerCase() === 'none') return Promise.resolve(null);
// A pinned clip needs no resolution at all.
if (/^https?:\/\//i.test(override)) {
return Promise.resolve({
url: override,
title: track?.title || '',
artist: track?.artist || '',
});
}
const byId = /^\d+$/.test(override);
const term = override && !byId ? override : previewQuery(track);
if (!byId && !term) return Promise.resolve(null);
const key = byId ? `id:${override}` : `q:${term}`;
if (cache.has(key)) return cache.get(key);
const pending = (
byId
? request(`${LOOKUP}?id=${encodeURIComponent(override)}`, (r) =>
toPreview(r[0]),
)
: request(
`${SEARCH}?term=${encodeURIComponent(term)}&entity=song&limit=5`,
// Take the first result that actually is the track, not merely the
// first result.
(results) => toPreview(results.find((hit) => isMatch(track, hit))),
)
).catch((err) => {
// Network failures are worth retrying; a confirmed miss is not.
cache.delete(key);
throw err;
});
cache.set(key, pending);
return pending;
};
export const clearPreviewCache = () => cache.clear();