Line data Source code
1 1 : // melodex-front-end/src/utils/spotifyExport.js
2 1 :
3 1 : /**
4 1 : * Post Spotify track URIs in fixed-size chunks.
5 1 : * - Keeps original order
6 1 : * - No mutation of input array
7 1 : * - Validates chunkSize
8 1 : * - Awaits each batch sequentially (preserves playlist order guarantees)
9 1 : *
10 1 : * @param {string[]} uris
11 1 : * @param {number} chunkSize
12 1 : * @param {(batch: string[]) => Promise<void>} postFn // e.g., client.post(`/tracks`, { uris: batch })
13 1 : * @returns {Promise<{batches: number, total: number}>}
14 1 : */
15 1 : export async function postUrisInChunks(uris = [], chunkSize = 100, postFn = async () => {}) {
16 8 : const list = Array.isArray(uris) ? [...uris] : [];
17 8 : const size = Number(chunkSize);
18 8 :
19 8 : if (!Number.isFinite(size) || size <= 0) {
20 3 : throw new Error("chunkSize must be a positive integer");
21 3 : }
22 8 : if (list.length === 0) return { batches: 0, total: 0 };
23 4 :
24 4 : let batches = 0;
25 4 : let total = 0;
26 4 :
27 8 : for (let i = 0; i < list.length; i += size) {
28 9 : const batch = list.slice(i, i + size);
29 9 : await postFn(batch);
30 9 : batches += 1;
31 9 : total += batch.length;
32 9 : }
33 4 : return { batches, total };
34 4 : }
35 1 :
36 1 : /** ---------- Filters ---------- **/
37 1 : export function buildFilters(selection = {}) {
38 11 : const norm = (v) => (typeof v === "string" ? v.trim().toLowerCase() : v);
39 11 :
40 11 : const genre = norm(selection.genre);
41 11 : const subgenre = norm(selection.subgenre);
42 11 :
43 11 : // No genre & no subgenre => empty
44 11 : if (!genre && !subgenre) return { type: "none" };
45 6 :
46 6 : // Subgenre without a genre => treat as empty
47 11 : if (!genre && subgenre) return { type: "none" };
48 4 :
49 4 : // Genre present -> build genre filter (subgenre optional)
50 4 : const out = { type: "genre", genre };
51 8 : if (subgenre) out.subgenre = subgenre;
52 4 : return out;
53 4 : }
54 1 :
55 1 : /** ---------- Selector (genre/subgenre/decade) ---------- **/
56 1 :
57 1 : // local normalize (trim → collapse spaces → lowercase)
58 302 : function _norm(s) {
59 302 : return String(s ?? "")
60 302 : .trim()
61 302 : .replace(/\s+/g, " ")
62 302 : .toLowerCase();
63 302 : }
64 1 :
65 1 : // "1990s" -> { start: 1990, end: 1999 }
66 1 : // "1990-1999" -> { start: 1990, end: 1999 }
67 1 : // others / "all decades" -> null (wildcard)
68 18 : function _parseDecadeWindow(decadeRaw) {
69 18 : if (decadeRaw == null) return null;
70 18 : const d = _norm(decadeRaw);
71 18 : if (!d || d === "all decades") return null;
72 12 :
73 12 : const mLabel = d.match(/^(\d{4})s$/); // "1990s"
74 18 : if (mLabel) {
75 7 : const start = parseInt(mLabel[1], 10);
76 7 : return { start, end: start + 9 };
77 7 : }
78 5 :
79 5 : const mRange = d.match(/^(\d{4})\s*-\s*(\d{4})$/); // "1990-1999"
80 15 : if (mRange) {
81 4 : const start = parseInt(mRange[1], 10);
82 4 : const end = parseInt(mRange[2], 10);
83 4 : if (start <= end) return { start, end };
84 4 : }
85 2 : return null;
86 2 : }
87 1 :
88 1 : // Pull a numeric year out of a song's "decade" field.
89 1 : // Accepts number (1995) or strings like "1990s", "1995", "1990-1999".
90 0 : function _extractYear(decadeVal) {
91 0 : if (decadeVal == null) return null;
92 0 : if (typeof decadeVal === "number") return decadeVal;
93 0 : const s = String(decadeVal).trim();
94 0 : const mYear = s.match(/(\d{4})/);
95 0 : return mYear ? parseInt(mYear[1], 10) : null;
96 0 : }
97 1 :
98 1 : /**
99 1 : * Pure selector used by UT-011:
100 1 : * - 'any' genre/subgenre acts as wildcard
101 1 : * - subgenre match is exact; if genre also provided, require BOTH
102 1 : * - decade like "1990s" or "1990-1999" filters to that window
103 1 : * - songs with missing decade are excluded when a decade filter is set
104 1 : */
105 1 : export function selectRankedByRules(songs, rules = {}) {
106 19 : if (!Array.isArray(songs)) return [];
107 18 :
108 19 : const genre = _norm(rules.genre ?? "any");
109 19 : const subgenre = _norm(rules.subgenre ?? "any");
110 19 :
111 19 : const decadeNorm = _norm(rules.decade ?? "all decades");
112 19 : const decadeIsAll = decadeNorm === "all decades";
113 19 :
114 19 : // Window if the decade is a label ("1990s") or a range ("1990-1999")
115 19 : const decadeWindow = _parseDecadeWindow(decadeNorm);
116 19 :
117 19 : const wantGenre = genre !== "any";
118 19 : const wantSub = subgenre !== "any";
119 19 :
120 19 : function matchesDecade(songDecade) {
121 58 : // If user didn’t set a decade, everything passes decade check.
122 58 : if (decadeIsAll) return true;
123 41 :
124 41 : // Numeric year like 1995
125 58 : if (typeof songDecade === "number") {
126 8 : if (!Number.isFinite(songDecade)) return false;
127 8 : if (decadeWindow) {
128 6 : return (
129 6 : songDecade >= decadeWindow.start && songDecade <= decadeWindow.end
130 6 : );
131 6 : }
132 2 : const mRange = decadeNorm.match(/^(\d{4})\s*-\s*(\d{4})$/);
133 8 : if (mRange) {
134 1 : const a = parseInt(mRange[1], 10),
135 1 : b = parseInt(mRange[2], 10);
136 1 : if (a > b) return false;
137 1 : return songDecade >= a && songDecade <= b;
138 1 : }
139 1 : // exact single-year fallback
140 1 : return _norm(String(songDecade)) === decadeNorm;
141 1 : }
142 33 :
143 33 : // String decade like "1990s" or "1995"
144 33 : if (typeof songDecade === "string") {
145 33 : const sNorm = _norm(songDecade);
146 33 :
147 33 : if (decadeWindow) {
148 24 : const mSongLabel = sNorm.match(/^(\d{4})s$/);
149 24 : if (mSongLabel) return parseInt(mSongLabel[1], 10) === decadeWindow.start;
150 0 :
151 0 : const y = _extractYear(songDecade);
152 0 : if (y == null) return false;
153 24 : return y >= decadeWindow.start && y <= decadeWindow.end;
154 24 : }
155 9 :
156 9 : // range fallback
157 9 : const mRange = decadeNorm.match(/^(\d{4})\s*-\s*(\d{4})$/);
158 33 : if (mRange) {
159 4 : const a = parseInt(mRange[1], 10),
160 4 : b = parseInt(mRange[2], 10);
161 4 : if (a > b) return false;
162 0 : const y = _extractYear(songDecade);
163 0 : if (y == null) return false;
164 4 : return y >= a && y <= b;
165 4 : }
166 5 :
167 5 : // exact label fallback
168 5 : return sNorm === decadeNorm;
169 5 : }
170 0 :
171 0 : return false;
172 58 : }
173 19 :
174 19 : return songs.filter((song) => {
175 98 : const sg = _norm(song?.genre);
176 98 : const ss = _norm(song?.subgenre);
177 98 :
178 98 : // genre/subgenre rules
179 98 : if (wantSub) {
180 34 : if (ss !== subgenre) return false;
181 34 : if (wantGenre && sg !== genre) return false;
182 98 : } else if (wantGenre) {
183 12 : if (sg !== genre) return false;
184 12 : }
185 69 :
186 69 : // If a decade filter is set (anything but "all decades"),
187 69 : // exclude items with null/empty decade immediately.
188 92 : if (!decadeIsAll) {
189 52 : const d = song?.decade;
190 52 : if (d == null || String(d).trim() === "") return false;
191 52 : }
192 58 :
193 58 : // decade matching proper
194 90 : if (!matchesDecade(song.decade)) return false;
195 30 :
196 30 : return true;
197 19 : });
198 19 : }
199 1 :
200 1 : /** ---------- Mapping ---------- **/
201 1 :
202 23 : function normalizeWhitespace(str) {
203 23 : return String(str || "")
204 23 : .replace(/\s+/g, " ")
205 23 : .trim();
206 23 : }
207 1 :
208 17 : function normalizeArtist(artist) {
209 17 : return normalizeWhitespace(artist).toLowerCase();
210 17 : }
211 1 :
212 1 : /**
213 1 : * Aggressive scrubbing for titles:
214 1 : * - lowercases
215 1 : * - strips parentheses content `(feat. X)` etc.
216 1 : * - removes common variant suffixes (Remaster, Live, Commentary, Karaoke, Short Film)
217 1 : * - removes "feat./ft./featuring/with ..." tails
218 1 : */
219 17 : function normalizeTitleForSearch(title) {
220 17 : let s = String(title || "").toLowerCase();
221 17 :
222 17 : // Normalize fancy dashes to a simple hyphen
223 17 : s = s.replace(/[–—]/g, "-");
224 17 :
225 17 : // Drop parentheses and their content: (feat. X), (Live), etc.
226 17 : s = s.replace(/\(.*?\)/g, " ");
227 17 :
228 17 : // Drop "feat/ft/featuring/with ..." tails
229 17 : s = s.replace(/\b(feat\.?|ft\.?|featuring|with)\b.*$/g, " ");
230 17 :
231 17 : // Drop common variant suffixes like "- Remastered 2011", "- Live at …"
232 17 : s = s
233 17 : .replace(
234 17 : /-?\s*(remaster(ed)?(\s*\d{4})?|live.*|commentary.*|short film.*|karaoke.*)$/g,
235 17 : " "
236 17 : )
237 17 : // Also catch "... 2011 remaster" style endings
238 17 : .replace(/\s+\d{4}\s+remaster(ed)?$/g, " ")
239 17 : .replace(/\s+/g, " ")
240 17 : .trim();
241 17 :
242 17 : return s;
243 17 : }
244 1 :
245 4 : function isVariantName(name) {
246 4 : const n = String(name || "").toLowerCase();
247 4 : return /\b(remaster(ed)?|live|commentary|short film|karaoke)\b/.test(n);
248 4 : }
249 1 :
250 1 : /**
251 1 : * Pick a single Spotify URI from a search result.
252 1 : * - supports { uri: string } or { items: [{ uri, name, duration_ms }, ...] }
253 1 : * - uses duration ±3000ms when provided
254 1 : * - prefers non-variant names (no "Remaster"/"Live"/etc.)
255 1 : * - if still ambiguous and we have duration, picks closest by duration
256 1 : */
257 17 : function pickUriFromSearchResult(result, item) {
258 17 : if (!result) return null;
259 15 :
260 15 : // Simple shape: { uri: "spotify:track:..." }
261 17 : if (typeof result.uri === "string") {
262 13 : return result.uri;
263 13 : }
264 2 :
265 17 : const candidates = Array.isArray(result.items) ? result.items : [];
266 17 : if (!candidates.length) return null;
267 2 :
268 2 : const durationTarget =
269 17 : item && typeof item.durationMs === "number" ? item.durationMs : null;
270 17 :
271 17 : let pool = candidates;
272 17 :
273 17 : // 1) Use duration ±3000ms if we have a target
274 17 : if (durationTarget != null) {
275 1 : const within = candidates.filter(
276 1 : (c) =>
277 2 : typeof c.duration_ms === "number" &&
278 2 : Math.abs(c.duration_ms - durationTarget) <= 3000
279 1 : );
280 1 : if (within.length) {
281 1 : pool = within;
282 1 : }
283 1 : }
284 2 :
285 2 : // 2) Prefer non-variant names when possible (no “Remaster”, “Live”, etc.)
286 2 : const nonVariant = pool.filter((c) => !isVariantName(c.name));
287 2 : if (nonVariant.length) {
288 2 : pool = nonVariant;
289 2 : }
290 2 :
291 2 : // 3) If still multiple and we have duration, choose the closest by duration
292 17 : if (durationTarget != null && pool.length > 1) {
293 1 : let best = null;
294 1 : let bestDiff = Infinity;
295 1 : for (const c of pool) {
296 2 : if (typeof c.duration_ms !== "number") continue;
297 2 : const diff = Math.abs(c.duration_ms - durationTarget);
298 2 : if (diff < bestDiff) {
299 2 : bestDiff = diff;
300 2 : best = c;
301 2 : }
302 2 : }
303 1 : if (best && typeof best.uri === "string") {
304 1 : return best.uri;
305 1 : }
306 1 : }
307 1 :
308 1 : // 4) Fallback: first candidate in the remaining pool, then first overall
309 17 : const chosen = pool[0] || candidates[0];
310 17 : return chosen && typeof chosen.uri === "string" ? chosen.uri : null;
311 17 : }
312 1 :
313 1 : /**
314 1 : * Map a list of ranked items (possibly Deezer-origin) to Spotify URIs.
315 1 : * Rules:
316 1 : * - Prefer ISRC lookup when available
317 1 : * - Fallback to title+artist search (normalized)
318 1 : * - Scrub parens/punctuation from title; tolerate common variant terms
319 1 : * - Use duration tie-break within ±3000ms when candidates returned
320 1 : * - Skip `removed` or `skipped` items
321 1 : * - Deduplicate by URI, preserving first occurrence (rank order)
322 1 : * - Return shape: { uris: string[] }
323 1 : *
324 1 : * The `searchFn` function may return either:
325 1 : * - `{ uri }` OR
326 1 : * - `{ items: [{ uri, name, duration_ms }, ...] }`
327 1 : */
328 1 : export async function mapDeezerToSpotifyUris(items, searchFn) {
329 12 : if (!Array.isArray(items) || typeof searchFn !== "function") {
330 0 : return { uris: [] };
331 0 : }
332 12 :
333 12 : const seen = new Set();
334 12 : const uris = [];
335 12 :
336 12 : // Filter out null/undefined and removed/skipped up front; preserve ordering.
337 12 : const cleanItems = items.filter(
338 12 : (raw) => raw && !raw.removed && !raw.skipped
339 12 : );
340 12 :
341 12 : for (const raw of cleanItems) {
342 18 : const item = raw || {};
343 18 :
344 18 : const spotifyUri = item.spotifyUri;
345 18 : if (spotifyUri && typeof spotifyUri === "string") {
346 1 : if (!seen.has(spotifyUri)) {
347 1 : seen.add(spotifyUri);
348 1 : uris.push(spotifyUri);
349 1 : }
350 1 : continue;
351 1 : }
352 17 :
353 17 : const isrc = item.isrc;
354 18 : const titleRaw = item.title ?? item.songName ?? "";
355 18 : const artistRaw = item.artist ?? "";
356 18 :
357 18 : const normalizedTitle = normalizeTitleForSearch(titleRaw);
358 18 : const normalizedArtist = normalizeArtist(artistRaw);
359 18 :
360 18 : let result = null;
361 18 :
362 18 : // ISRC path preferred when available
363 18 : if (isrc) {
364 11 : result = await searchFn({ isrc });
365 18 : } else if (normalizedTitle || normalizedArtist) {
366 6 : // Fallback: normalized title + artist
367 6 : result = await searchFn({
368 6 : title: normalizedTitle,
369 6 : artist: normalizedArtist,
370 6 : });
371 6 : } else {
372 0 : // Nothing meaningful to search with
373 0 : continue;
374 0 : }
375 17 :
376 17 : const uri = pickUriFromSearchResult(result, {
377 17 : durationMs: item.durationMs,
378 17 : });
379 17 :
380 18 : if (uri && !seen.has(uri)) {
381 14 : seen.add(uri);
382 14 : uris.push(uri);
383 14 : }
384 18 : }
385 12 :
386 12 : return { uris };
387 12 : }
388 1 :
389 1 : /** ---------- Create Payload ---------- **/
390 1 : /**
391 1 : * Build the final create-playlist payload.
392 1 : * Defaults:
393 1 : * - name: "Melodex Playlist YYYY-MM-DD"
394 1 : * - description: "Generated by Melodex"
395 1 : * - defensively filters URIs to non-empty strings
396 1 : */
397 1 : export function buildCreatePayload(input = {}) {
398 3 : const nameRaw = normalizeWhitespace(input.name ?? "");
399 3 : const descRaw = normalizeWhitespace(input.description ?? "");
400 3 : const rawUris = Array.isArray(input.uris) ? input.uris : [];
401 3 :
402 3 : // Keep only non-empty strings; tests only assert that valid ones survive.
403 3 : const safeUris = rawUris.filter(
404 3 : (u) => typeof u === "string" && u.trim().length > 0
405 3 : );
406 3 :
407 3 : const now = new Date();
408 3 : const yyyy = now.getFullYear();
409 3 : const mm = String(now.getMonth() + 1).padStart(2, "0");
410 3 : const dd = String(now.getDate()).padStart(2, "0");
411 3 :
412 3 : const defaultName = `Melodex Playlist ${yyyy}-${mm}-${dd}`;
413 3 : const defaultDescription = "Generated by Melodex";
414 3 :
415 3 : return {
416 3 : name: nameRaw || defaultName,
417 3 : description: descRaw || defaultDescription,
418 3 : uris: safeUris,
419 3 : };
420 3 : }
|