LCOV - code coverage report
Current view: top level - utils/spotifyExport.js - spotifyExport.js (source / functions) Hit Total Coverage
Test: feature-lcov.info Lines: 401 420 95.5 %
Date: 2025-11-22 00:01:46 Functions: 14 16 87.5 %

          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 : }

Generated by: LCOV version 1.15.alpha0w