plx-converter 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,115 @@
1
+ import { EMBED_TRACK_LIMIT, readPlaylist } from './spotify.js';
2
+ import { writeCsv } from './csv.js';
3
+ export class Converter {
4
+ deezer;
5
+ spotifyToken;
6
+ rows = [];
7
+ output;
8
+ constructor(deezer, spotifyToken, output) {
9
+ this.deezer = deezer;
10
+ this.spotifyToken = spotifyToken;
11
+ this.output = output;
12
+ }
13
+ record(row) { this.rows.push(row); }
14
+ async matchPlaylist(playlist, dryRun = false) {
15
+ let result;
16
+ try {
17
+ result = await readPlaylist(playlist.uri, this.spotifyToken);
18
+ }
19
+ catch (error) {
20
+ this.record({ playlist: playlist.name, title: '', artist: '', isrc: null, matched: false, note: `gagal baca playlist: ${error instanceof Error ? error.name : 'Error'}` });
21
+ return { matchedIds: [], total: 0, truncated: false };
22
+ }
23
+ if (result.truncated) {
24
+ this.record({ playlist: playlist.name, title: '', artist: '', isrc: null, matched: false, note: `PERINGATAN: terpotong di ${EMBED_TRACK_LIMIT} lagu; sisanya tidak dikonversi` });
25
+ console.log(`[${playlist.name}] ⚠️ Terpotong di ${EMBED_TRACK_LIMIT} lagu (fallback embed).`);
26
+ }
27
+ const matchedIds = [];
28
+ for (let i = 0; i < result.tracks.length; i += 1) {
29
+ const track = result.tracks[i];
30
+ let match = null;
31
+ for (let attempt = 0; attempt < 3; attempt += 1) {
32
+ try {
33
+ match = await this.deezer.search(track);
34
+ break;
35
+ }
36
+ catch (error) {
37
+ if (attempt === 2)
38
+ console.log(` ⚠️ search gagal: ${error instanceof Error ? error.name : 'Error'}`);
39
+ else
40
+ await new Promise((resolve) => setTimeout(resolve, 2 ** attempt * 1000));
41
+ }
42
+ }
43
+ if (match) {
44
+ matchedIds.push(match.id);
45
+ this.record({ playlist: playlist.name, title: track.name, artist: track.artist, isrc: null, matched: true, deezer_id: match.id, method: match.method, note: dryRun ? 'dry-run' : null });
46
+ console.log(` [${i + 1}/${result.tracks.length}] ✓ ${match.method} — ${track.name} — ${track.artist}`);
47
+ }
48
+ else {
49
+ this.record({ playlist: playlist.name, title: track.name, artist: track.artist, isrc: null, matched: false, note: dryRun ? 'dry-run: tidak ketemu' : 'tidak ketemu' });
50
+ console.log(` [${i + 1}/${result.tracks.length}] ✗ tidak ditemukan — ${track.name} — ${track.artist}`);
51
+ }
52
+ await new Promise((resolve) => setTimeout(resolve, 300));
53
+ }
54
+ console.log(`[${playlist.name}] ${matchedIds.length}/${result.tracks.length} lagu cocok.`);
55
+ return { matchedIds, total: result.tracks.length, truncated: result.truncated };
56
+ }
57
+ async writePlaylist(name, ids, title = `[plx] ${name}`) {
58
+ if (!ids.length) {
59
+ console.log(`[${name}] Tidak ada lagu yang cocok — playlist tidak dibuat.`);
60
+ return;
61
+ }
62
+ const id = await this.deezer.createPlaylist(title);
63
+ const unique = [...new Set(ids)];
64
+ for (let start = 0; start < unique.length; start += 100) {
65
+ await this.deezer.addTracks(id, unique.slice(start, start + 100));
66
+ await new Promise((resolve) => setTimeout(resolve, 200));
67
+ }
68
+ console.log(`[${name}] playlist Deezer dibuat: ${title} (${unique.length} lagu)`);
69
+ }
70
+ // Append to an existing playlist, skipping tracks already present (dedupe), then reorder 1:1.
71
+ async writeToExisting(name, playlistId, ids) {
72
+ if (!ids.length) {
73
+ console.log(`[${name}] Tidak ada lagu yang cocok.`);
74
+ return;
75
+ }
76
+ const existing = await this.deezer.getPlaylistTrackIds(playlistId);
77
+ // Dedupe within the batch too — Deezer rejects any chunk containing a repeated id.
78
+ const unique = [...new Set(ids)];
79
+ const batchDupes = ids.length - unique.length;
80
+ const alreadyThere = unique.filter((id) => existing.has(id)).length;
81
+ const toAdd = unique.filter((id) => !existing.has(id));
82
+ let added = 0;
83
+ if (toAdd.length) {
84
+ for (let start = 0; start < toAdd.length; start += 100) {
85
+ added += await this.deezer.addTracks(playlistId, toAdd.slice(start, start + 100));
86
+ await new Promise((resolve) => setTimeout(resolve, 200));
87
+ }
88
+ }
89
+ const parts = [];
90
+ if (alreadyThere)
91
+ parts.push(`${alreadyThere} sudah ada`);
92
+ if (batchDupes)
93
+ parts.push(`${batchDupes} duplikat dihilangkan`);
94
+ console.log(`[${name}] ${added} lagu baru ditambahkan${parts.length ? ` (${parts.join(', ')})` : ''}.`);
95
+ await this.reorderToMatch(name, playlistId, ids);
96
+ }
97
+ // Reorder the target to mirror the source order (1:1 sync). Moves each matched track into place
98
+ // from the front, so matched tracks land in source order and anything extra is pushed to the end.
99
+ async reorderToMatch(name, playlistId, ids) {
100
+ const desired = [...new Set(ids)].filter((id) => id);
101
+ if (desired.length < 2)
102
+ return;
103
+ const current = await this.deezer.getPlaylistTrackOrder(playlistId);
104
+ const movable = new Set(desired);
105
+ const kept = current.filter((id) => movable.has(id));
106
+ if (kept.length === desired.length && desired.every((id, i) => kept[i] === id))
107
+ return;
108
+ for (let i = 0; i < desired.length; i += 1) {
109
+ await this.deezer.moveTrack(playlistId, desired[i], i === 0 ? null : desired[i - 1]);
110
+ await new Promise((resolve) => setTimeout(resolve, 100));
111
+ }
112
+ console.log(`[${name}] urutan playlist disamakan dengan sumber.`);
113
+ }
114
+ async writeReport() { await writeCsv(this.output, this.rows); }
115
+ }
package/dist/csv.js ADDED
@@ -0,0 +1,10 @@
1
+ import { writeFile } from 'node:fs/promises';
2
+ const fields = ['playlist', 'title', 'artist', 'isrc', 'matched', 'deezer_id', 'method', 'note'];
3
+ const cell = (value) => {
4
+ const text = value == null ? '' : String(value);
5
+ return /[",\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
6
+ };
7
+ export async function writeCsv(path, rows) {
8
+ const lines = [fields.join(','), ...rows.map((row) => fields.map((field) => cell(row[field])).join(','))];
9
+ await writeFile(path, `${lines.join('\n')}\n`, 'utf8');
10
+ }
package/dist/deezer.js ADDED
@@ -0,0 +1,145 @@
1
+ import { z } from 'zod';
2
+ import { fetchJson, retry } from './http.js';
3
+ import { firstArtist, matchCandidates, searchQuery, stripFeat } from './matcher.js';
4
+ const gqlSchema = z.object({ errors: z.array(z.object({ message: z.string() })).optional(), data: z.unknown() });
5
+ const API = 'https://api.deezer.com';
6
+ const GQL = 'https://pipe.deezer.com/api';
7
+ export function parseDeezerPlaylistId(ref) {
8
+ const input = ref.trim();
9
+ if (input.includes('deezer.com'))
10
+ return new URL(input).pathname.replace(/\/+$/, '').split('/').pop() ?? input;
11
+ return input;
12
+ }
13
+ // Resolve a Deezer share shortlink (link.deezer.com/s/<code>) to its playlist id.
14
+ export async function resolveDeezerPlaylistId(ref) {
15
+ const input = ref.trim();
16
+ if (input.includes('link.deezer.com')) {
17
+ const response = await fetch(input);
18
+ const match = decodeURIComponent(response.url).match(/playlist\/(\d+)/);
19
+ if (match)
20
+ return match[1];
21
+ }
22
+ return parseDeezerPlaylistId(input);
23
+ }
24
+ function jwtExpiryMs(jwt) {
25
+ try {
26
+ const payload = jwt.split('.')[1];
27
+ const padded = payload + '='.repeat((4 - (payload.length % 4)) % 4);
28
+ const decoded = JSON.parse(Buffer.from(padded, 'base64url').toString('utf8'));
29
+ return typeof decoded.exp === 'number' && decoded.exp > 0 ? decoded.exp * 1000 : Date.now() + 3600_000;
30
+ }
31
+ catch {
32
+ return Date.now() + 3600_000;
33
+ }
34
+ }
35
+ export class DeezerClient {
36
+ arl;
37
+ jwt;
38
+ jwtExpiresAt = 0;
39
+ constructor(arl) {
40
+ this.arl = arl;
41
+ }
42
+ async auth(force = false) {
43
+ if (!force && this.jwt && Date.now() < this.jwtExpiresAt - 30_000)
44
+ return this.jwt;
45
+ const response = await fetch('https://auth.deezer.com/login/arl?jo=p&rto=c&i=c', { method: 'POST', headers: { Cookie: `arl=${this.arl}` } });
46
+ if (!response.ok)
47
+ throw new Error(`Deezer auth failed: ${response.status}`);
48
+ const data = await response.json();
49
+ if (!data.jwt)
50
+ throw new Error('Deezer JWT missing');
51
+ this.jwt = data.jwt;
52
+ this.jwtExpiresAt = jwtExpiryMs(data.jwt);
53
+ return this.jwt;
54
+ }
55
+ async gql(query, operationName, variables) {
56
+ const run = async (jwt) => gqlSchema.parse(await fetchJson(GQL, { method: 'POST', headers: { Authorization: `Bearer ${jwt}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ query, operationName, variables }) }));
57
+ let data = await run(await this.auth());
58
+ if (data.errors?.length && /not valid anymore|re-authenticate/i.test(data.errors[0].message))
59
+ data = await run(await this.auth(true));
60
+ if (data.errors?.length)
61
+ throw new Error(`${operationName}: ${data.errors[0].message}`);
62
+ return data.data;
63
+ }
64
+ async getMe() { return this.gql('query GetMe { me { id } }', 'GetMe', {}).then((x) => x.me); }
65
+ async search(track) {
66
+ const toCandidates = (data) => data.data.map((x) => ({ id: String(x.id), title: x.title, artist: x.artist.name, duration: x.duration }));
67
+ const free = `${API}/search?${new URLSearchParams({ q: searchQuery(track.name, track.artist), limit: '5' })}`;
68
+ const data = await retry(async () => fetchJson(free));
69
+ const match = matchCandidates(track.name, track.artist, track.durationMs, toCandidates(data));
70
+ if (match)
71
+ return match;
72
+ // Free-text relevance ranks covers/remixes above the real K-pop track; fall back to field syntax.
73
+ const field = `${API}/search?${new URLSearchParams({ q: `track:"${stripFeat(track.name)}" artist:"${firstArtist(track.artist)}"`, limit: '5' })}`;
74
+ const data2 = await retry(async () => fetchJson(field));
75
+ const match2 = matchCandidates(track.name, track.artist, track.durationMs, toCandidates(data2));
76
+ if (match2)
77
+ return match2;
78
+ return this.searchByAlbum(track);
79
+ }
80
+ // Artist → albums → album tracks, for tracks the search index never surfaces (e.g. "Salty & Sweet").
81
+ async searchByAlbum(track) {
82
+ const artistName = firstArtist(track.artist);
83
+ const artistRes = await retry(async () => fetchJson(`${API}/search/artist?${new URLSearchParams({ q: artistName, limit: '1' })}`));
84
+ const artist = artistRes.data[0];
85
+ if (!artist)
86
+ return null;
87
+ const albumsRes = await retry(async () => fetchJson(`${API}/artist/${artist.id}/albums?${new URLSearchParams({ limit: '50' })}`));
88
+ for (const album of albumsRes.data.slice(0, 25)) {
89
+ const tracksRes = await retry(async () => fetchJson(`${API}/album/${album.id}/tracks?${new URLSearchParams({ limit: '50' })}`));
90
+ const m = matchCandidates(track.name, track.artist, track.durationMs, tracksRes.data.map((x) => ({ id: String(x.id), title: x.title, artist: x.artist.name, duration: x.duration })));
91
+ if (m)
92
+ return m;
93
+ }
94
+ return null;
95
+ }
96
+ async createPlaylist(title) { const x = await this.gql('mutation CreatePlaylist($title:String!,$description:String="",$isPrivate:Boolean!=false,$isCollaborative:Boolean!=false){createPlaylist(input:{title:$title,description:$description,isPrivate:$isPrivate,isCollaborative:$isCollaborative}){playlist{id title}}}', 'CreatePlaylist', { title, description: '', isPrivate: true, isCollaborative: false }); return x.createPlaylist.playlist.id; }
97
+ async addTracks(id, trackIds) {
98
+ const x = await this.gql('mutation AddTracksToPlaylist($playlistId:String!,$trackIds:[String!]!){addTracksToPlaylist(input:{playlistId:$playlistId,trackIds:$trackIds}){__typename ... on PlaylistAddTracksOutput{addedTrackIds} ... on PlaylistAddTracksError{isNotAllowed}}}', 'AddTracksToPlaylist', { playlistId: id, trackIds });
99
+ const out = x.addTracksToPlaylist;
100
+ if (out.__typename === 'PlaylistAddTracksError')
101
+ throw new Error(out.isNotAllowed ? 'Tidak diizinkan mengubah playlist ini (bukan milikmu / hanya di-follow)' : 'Gagal menambah lagu');
102
+ if (!out.addedTrackIds || out.addedTrackIds.length !== trackIds.length)
103
+ throw new Error(`Hanya ${out.addedTrackIds?.length ?? 0}/${trackIds.length} lagu yang benar-benar ditambahkan`);
104
+ return out.addedTrackIds.length;
105
+ }
106
+ // The user's own playlists. ponytail: capped at first 50 — personal use, paginate only if someone hits it.
107
+ async listPlaylists() {
108
+ const data = await this.gql('query GetUserPlaylists($first:Int=50){me{playlists(first:$first){edges{node{id title}}}}}', 'GetUserPlaylists', { first: 50 });
109
+ return data.me.playlists.edges.map((e) => e.node);
110
+ }
111
+ // Full set of track ids currently in a playlist (paginated) — needed for dedupe.
112
+ async getPlaylistTrackIds(playlistId) {
113
+ return new Set(await this.getPlaylistTrackOrder(playlistId));
114
+ }
115
+ // Ordered list of track ids currently in a playlist (paginated) — preserves order.
116
+ async getPlaylistTrackOrder(playlistId) {
117
+ const ids = [];
118
+ let after = null;
119
+ for (;;) {
120
+ const data = await this.gql('query GetPlaylist($playlistId:String!,$first:Int=50,$after:String){playlist(playlistId:$playlistId){tracks(first:$first,after:$after){edges{node{id}} pageInfo{hasNextPage endCursor}}}}', 'GetPlaylist', { playlistId, first: 50, after });
121
+ for (const e of data.playlist.tracks.edges)
122
+ ids.push(e.node.id);
123
+ if (!data.playlist.tracks.pageInfo.hasNextPage || !data.playlist.tracks.pageInfo.endCursor)
124
+ return ids;
125
+ after = data.playlist.tracks.pageInfo.endCursor;
126
+ }
127
+ }
128
+ // Full track metadata (title/artist/duration) in playlist order — source read for the reverse flow.
129
+ async getPlaylistTracks(playlistId) {
130
+ const tracks = [];
131
+ let after = null;
132
+ for (;;) {
133
+ const data = await this.gql('query GetPlaylistTracks($playlistId:String!,$first:Int=50,$after:String){playlist(playlistId:$playlistId){tracks(first:$first,after:$after){edges{node{id title duration contributorNames}} pageInfo{hasNextPage endCursor}}}}', 'GetPlaylistTracks', { playlistId, first: 50, after });
134
+ for (const e of data.playlist.tracks.edges)
135
+ tracks.push({ name: e.node.title, artist: e.node.contributorNames[0] ?? '', durationMs: e.node.duration * 1000 });
136
+ if (!data.playlist.tracks.pageInfo.hasNextPage || !data.playlist.tracks.pageInfo.endCursor)
137
+ return tracks;
138
+ after = data.playlist.tracks.pageInfo.endCursor;
139
+ }
140
+ }
141
+ // Move a track to be after `afterTrackId` (null = move to front).
142
+ async moveTrack(playlistId, trackId, afterTrackId) {
143
+ await this.gql('mutation MoveTrackInPlaylist($playlistId:String!,$trackId:String!,$afterTrackId:String){moveTrackInPlaylist(input:{playlistId:$playlistId,trackId:$trackId,afterTrackId:$afterTrackId}){__typename}}', 'MoveTrackInPlaylist', { playlistId, trackId, afterTrackId });
144
+ }
145
+ }
package/dist/http.js ADDED
@@ -0,0 +1,39 @@
1
+ export class HttpError extends Error {
2
+ status;
3
+ constructor(status, message) {
4
+ super(message);
5
+ this.status = status;
6
+ this.name = 'HttpError';
7
+ }
8
+ }
9
+ export async function fetchText(url, init = {}, timeoutMs = 30_000) {
10
+ const controller = new AbortController();
11
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
12
+ try {
13
+ const response = await fetch(url, { ...init, signal: controller.signal });
14
+ if (!response.ok)
15
+ throw new HttpError(response.status, `${response.status} ${response.statusText}`);
16
+ return await response.text();
17
+ }
18
+ finally {
19
+ clearTimeout(timer);
20
+ }
21
+ }
22
+ export async function fetchJson(url, init = {}, timeoutMs = 30_000) {
23
+ return JSON.parse(await fetchText(url, init, timeoutMs));
24
+ }
25
+ export async function retry(operation, attempts = 3) {
26
+ for (let attempt = 0;; attempt += 1) {
27
+ try {
28
+ return await operation();
29
+ }
30
+ catch (error) {
31
+ if (attempt >= attempts - 1)
32
+ throw error;
33
+ await new Promise((resolve) => setTimeout(resolve, 2 ** attempt * 1000));
34
+ }
35
+ }
36
+ }
37
+ export const browserHeaders = {
38
+ 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/126.0.0.0 Safari/537.36',
39
+ };
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export * from './types.js';
2
+ export * from './matcher.js';
@@ -0,0 +1,25 @@
1
+ export function normalize(value) {
2
+ return value.normalize('NFKD').replace(/\p{M}/gu, '').toLowerCase().replace(/\(.*?\)/g, '').replace(/[\[(]\s*(feat\.?|ft\.?)\s*/gi, '').replace(/\]/g, '').replace(/[^\p{L}\p{N}_\s]/gu, '').replace(/\s+/g, ' ').trim();
3
+ }
4
+ export function firstArtist(artist) { return artist.split(',')[0].trim(); }
5
+ function artistKey(artist) { return normalize(artist).replace(/\s+/g, ''); }
6
+ export function stripFeat(title) { return title.replace(/\s*[([]\s*(feat|ft)\.?[^)\]]*[)\]]/gi, '').trim(); }
7
+ export function matchDurationMs(spotifyMs, deezerSec, toleranceMs = 3000) { return spotifyMs == null || deezerSec == null || Math.abs(spotifyMs - deezerSec * 1000) <= toleranceMs; }
8
+ export function searchQuery(title, artist) { return `${firstArtist(artist)} ${stripFeat(title)}`.trim(); }
9
+ export function matchCandidates(title, artist, durationMs, candidates) {
10
+ const nt = normalize(stripFeat(title));
11
+ const na = artistKey(firstArtist(artist));
12
+ for (const c of candidates)
13
+ if (nt === normalize(c.title) && na === artistKey(c.artist) && matchDurationMs(durationMs, c.duration))
14
+ return { id: c.id, title: c.title, artist: c.artist, method: 'exact' };
15
+ for (const c of candidates)
16
+ if (nt === normalize(c.title) && na === artistKey(c.artist))
17
+ return { id: c.id, title: c.title, artist: c.artist, method: 'fuzzy-duration' };
18
+ for (const c of candidates) {
19
+ const ct = normalize(c.title);
20
+ if (na === artistKey(c.artist) && nt && ct && (nt.includes(ct) || ct.includes(nt)))
21
+ return { id: c.id, title: c.title, artist: c.artist, method: 'fuzzy-title' };
22
+ }
23
+ return null;
24
+ }
25
+ export function matchTrack(track, candidates) { return matchCandidates(track.name, track.artist, track.durationMs, candidates); }
@@ -0,0 +1,87 @@
1
+ import { addTracks, createPlaylist, fetchTrackUris, resolveTrackMeta, searchTrack } from './spotify.js';
2
+ import { matchCandidates, searchQuery } from './matcher.js';
3
+ import { writeCsv } from './csv.js';
4
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
5
+ // Match a Deezer playlist's tracks to Spotify track URIs, then resolve each matched
6
+ // URI back to its real Spotify metadata and compare to the source. `verified` counts
7
+ // matches whose resolved title+artist still match the source (precision); a matched
8
+ // URI whose metadata diverges is flagged `note: '⚠️ cek ulang (mungkin salah track)'`.
9
+ export async function reverseMatch(deezer, token, sourceId, sourceName) {
10
+ const tracks = await deezer.getPlaylistTracks(sourceId);
11
+ const rows = [];
12
+ const matchedUris = [];
13
+ let verified = 0;
14
+ for (let i = 0; i < tracks.length; i += 1) {
15
+ const track = tracks[i];
16
+ let match = null;
17
+ for (let attempt = 0; attempt < 3; attempt += 1) {
18
+ try {
19
+ const candidates = await searchTrack(searchQuery(track.name, track.artist), token);
20
+ match = matchCandidates(track.name, track.artist, track.durationMs, candidates);
21
+ break;
22
+ }
23
+ catch (error) {
24
+ if (attempt === 2)
25
+ console.log(` ⚠️ search gagal: ${error instanceof Error ? error.name : 'Error'}`);
26
+ else
27
+ await sleep(2 ** attempt * 1000);
28
+ }
29
+ }
30
+ if (match) {
31
+ let note = null;
32
+ try {
33
+ const meta = await resolveTrackMeta(match.id);
34
+ if (matchCandidates(track.name, track.artist, track.durationMs, [{ id: match.id, title: meta.name, artist: meta.artist, duration: meta.durationMs != null ? Math.round(meta.durationMs / 1000) : null }]))
35
+ verified += 1;
36
+ else
37
+ note = '⚠️ cek ulang (mungkin salah track)';
38
+ }
39
+ catch {
40
+ note = '⚠️ verifikasi gagal';
41
+ }
42
+ matchedUris.push(match.id);
43
+ rows.push({ playlist: sourceName, title: track.name, artist: track.artist, isrc: null, matched: true, deezer_id: match.id, method: match.method, note });
44
+ console.log(` [${i + 1}/${tracks.length}] ✓ ${match.method} — ${track.name} — ${track.artist}${note ? ` — ${note}` : ''}`);
45
+ }
46
+ else {
47
+ rows.push({ playlist: sourceName, title: track.name, artist: track.artist, isrc: null, matched: false, note: 'tidak ketemu' });
48
+ console.log(` [${i + 1}/${tracks.length}] ✗ tidak ditemukan — ${track.name} — ${track.artist}`);
49
+ }
50
+ await sleep(300);
51
+ }
52
+ const missed = tracks.length - matchedUris.length;
53
+ console.log(`[${sourceName}] ${matchedUris.length}/${tracks.length} cocok (${verified} terverifikasi${missed ? `, ${missed} gagal match` : ''}).`);
54
+ return { rows, matchedUris, total: tracks.length };
55
+ }
56
+ // Deezer playlist (source) → new Spotify playlist (target).
57
+ export async function reverseConvert(deezer, token, sourceId, sourceName, output) {
58
+ const { rows, matchedUris } = await reverseMatch(deezer, token, sourceId, sourceName);
59
+ if (matchedUris.length) {
60
+ const uri = await createPlaylist(`[plx] ${sourceName}`, token);
61
+ for (let start = 0; start < matchedUris.length; start += 100) {
62
+ await addTracks(uri, matchedUris.slice(start, start + 100), token);
63
+ await sleep(200);
64
+ }
65
+ console.log(`[${sourceName}] playlist Spotify dibuat: ${uri} (${matchedUris.length} lagu)`);
66
+ }
67
+ await writeCsv(output, rows);
68
+ }
69
+ // Deezer playlist (source) → an existing Spotify playlist (target). Dedupe + append (no reorder — Spotify has no move primitive in scope).
70
+ export async function reverseWriteToExisting(deezer, token, sourceId, sourceName, targetUri, output) {
71
+ const { rows, matchedUris } = await reverseMatch(deezer, token, sourceId, sourceName);
72
+ if (!matchedUris.length) {
73
+ await writeCsv(output, rows);
74
+ return;
75
+ }
76
+ const targetId = targetUri.split(':').pop() ?? targetUri;
77
+ const existing = new Set(await fetchTrackUris(targetId, token));
78
+ const toAdd = [...new Set(matchedUris)].filter((u) => !existing.has(u));
79
+ if (toAdd.length) {
80
+ for (let start = 0; start < toAdd.length; start += 100) {
81
+ await addTracks(targetUri, toAdd.slice(start, start + 100), token);
82
+ await sleep(200);
83
+ }
84
+ }
85
+ console.log(`[${sourceName}] ${toAdd.length} lagu baru ditambahkan (${matchedUris.length - toAdd.length} sudah ada, dilewati).`);
86
+ await writeCsv(output, rows);
87
+ }
@@ -0,0 +1,227 @@
1
+ import { createHmac } from 'node:crypto';
2
+ import { z } from 'zod';
3
+ import { browserHeaders, fetchJson, fetchText } from './http.js';
4
+ const PATHFINDER_URL = 'https://api-partner.spotify.com/pathfinder/v1/query';
5
+ const FETCH_PLAYLIST_SHA = 'a65e12194ed5fc443a1cdebed5fabe33ca5b07b987185d63c72483867ad13cb4';
6
+ export const EMBED_TRACK_LIMIT = 100;
7
+ const pathfinderItemData = z.object({
8
+ uri: z.string().optional(),
9
+ name: z.string().optional(),
10
+ artists: z.object({ items: z.array(z.object({ profile: z.object({ name: z.string().optional() }) })) }).optional(),
11
+ trackDuration: z.object({ totalMilliseconds: z.number().optional() }).optional(),
12
+ });
13
+ const pathfinderItem = z.object({ itemV2: z.object({ data: pathfinderItemData.optional() }).optional() });
14
+ const pathfinderContent = z.object({ totalCount: z.number(), items: z.array(pathfinderItem) });
15
+ const pathfinderTrackSchema = z.object({
16
+ errors: z.array(z.object({ message: z.string() })).optional(),
17
+ data: z.object({ playlistV2: z.object({ content: pathfinderContent }) }),
18
+ });
19
+ async function nextData(url, cookie) {
20
+ const headers = cookie ? { ...browserHeaders, Cookie: cookie } : browserHeaders;
21
+ const html = await fetchText(url, { headers });
22
+ const match = html.match(/<script id="__NEXT_DATA__"[^>]*>(.*?)<\/script>/s);
23
+ if (!match)
24
+ throw new Error('__NEXT_DATA__ not found');
25
+ return JSON.parse(match[1]);
26
+ }
27
+ export async function anonymousToken() {
28
+ const data = await nextData('https://open.spotify.com/embed/track/4uLU6hMCjMI75M1A2tKUQC');
29
+ const token = data.props?.pageProps?.state?.settings?.session?.accessToken;
30
+ if (!token)
31
+ throw new Error('Spotify anonymous token missing');
32
+ return token;
33
+ }
34
+ export async function playlistName(id) {
35
+ try {
36
+ const entity = (await nextData(`https://open.spotify.com/embed/playlist/${id}`)).props?.pageProps?.state?.data?.entity;
37
+ if (typeof entity?.name === 'string' && entity.name)
38
+ return entity.name;
39
+ }
40
+ catch { /* fallback below */ }
41
+ return `playlist-${id.slice(0, 8)}`;
42
+ }
43
+ export async function fetchTracksEmbed(id) {
44
+ const entity = (await nextData(`https://open.spotify.com/embed/playlist/${id}`)).props?.pageProps?.state?.data?.entity;
45
+ const list = Array.isArray(entity?.trackList) ? entity.trackList : [];
46
+ const tracks = list.map((item) => {
47
+ const track = item;
48
+ return { name: String(track.title ?? ''), artist: String(track.subtitle ?? ''), durationMs: typeof track.duration === 'number' ? track.duration : null };
49
+ });
50
+ return { tracks, truncated: tracks.length >= EMBED_TRACK_LIMIT };
51
+ }
52
+ export async function fetchTracks(id, token) {
53
+ const tracks = [];
54
+ for (let offset = 0;;) {
55
+ const variables = { uri: `spotify:playlist:${id}`, offset, limit: 100, enableWatchFeedEntrypoint: false };
56
+ const params = new URLSearchParams({ operationName: 'fetchPlaylist', variables: JSON.stringify(variables), extensions: JSON.stringify({ persistedQuery: { version: 1, sha256Hash: FETCH_PLAYLIST_SHA } }) });
57
+ const raw = pathfinderTrackSchema.parse(await fetchJson(`${PATHFINDER_URL}?${params}`, { headers: { ...browserHeaders, Authorization: `Bearer ${token}`, 'app-platform': 'WebPlayer' } }));
58
+ if (raw.errors?.length)
59
+ throw new Error(`pathfinder: ${raw.errors[0].message}`);
60
+ const content = raw.data.playlistV2.content;
61
+ const items = content.items;
62
+ for (const item of items) {
63
+ const data = item.itemV2?.data;
64
+ if (!data?.uri)
65
+ continue;
66
+ tracks.push({ name: data.name ?? '', artist: data.artists?.items?.[0]?.profile?.name ?? '', durationMs: data.trackDuration?.totalMilliseconds ?? null });
67
+ }
68
+ offset += items.length;
69
+ if (!items.length || offset >= content.totalCount)
70
+ return { tracks, truncated: false };
71
+ }
72
+ }
73
+ export async function readPlaylist(id, token) {
74
+ try {
75
+ return await fetchTracks(id, token);
76
+ }
77
+ catch {
78
+ return fetchTracksEmbed(id);
79
+ }
80
+ }
81
+ export function parsePlaylistId(ref) {
82
+ const input = ref.trim();
83
+ if (input.includes('open.spotify.com'))
84
+ return new URL(input).pathname.replace(/\/+$/, '').split('/').pop() ?? input;
85
+ if (input.startsWith('spotify:playlist:'))
86
+ return input.split(':').pop() ?? input;
87
+ return input;
88
+ }
89
+ // Resolve a spotify:track:ID URI to its real title/artist/duration via the anonymous
90
+ // embed endpoint — used to verify reverse-flow matches (precision check).
91
+ export async function resolveTrackMeta(uri) {
92
+ const id = uri.split(':').pop() ?? uri;
93
+ const entity = (await nextData(`https://open.spotify.com/embed/track/${id}`)).props?.pageProps?.state?.data?.entity;
94
+ const artists = Array.isArray(entity?.artists) ? entity.artists : [];
95
+ return {
96
+ name: typeof entity?.name === 'string' ? entity.name : '',
97
+ artist: artists[0]?.name ?? '',
98
+ durationMs: typeof entity?.duration === 'number' ? entity.duration : null,
99
+ };
100
+ }
101
+ // --- Authenticated write (reverse flow: Deezer → Spotify) ---
102
+ const PATHFINDER_V2_URL = 'https://api-partner.spotify.com/pathfinder/v2/query';
103
+ const SPCLIENT_URL = 'https://spclient.wg.spotify.com';
104
+ // Persisted-query hashes for write/search — captured from the web player; they rotate (see CONTINUATION.md).
105
+ const ADD_TO_PLAYLIST_SHA = '47b2a1234b17748d332dd0431534f22450e9ecbb3d5ddcdacbd83368636a0990';
106
+ const SEARCH_TRACKS_SHA = 'bc1ca2fcd0ba1013a0fc88e6cc4f190af501851e3dafd3e1ef85840297694428';
107
+ const LIBRARY_V3_SHA = '973e511ca44261fda7eebac8b653155e7caee3675abb4fb110cc1b8c78b091c3';
108
+ const ADD_ITEMS_TO_ROOTLIST_SHA = 'bd9c5cae1ee80ebca05d7ed12fd394216f49a20ce72d9dc762868df0f14522ea';
109
+ const writeHeaders = (token) => ({ ...browserHeaders, Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', 'Accept': 'application/json', 'app-platform': 'WebPlayer' });
110
+ // Authenticated token from a logged-in browser session (sp_dc cookie).
111
+ // The web player obtains its API token at /api/token with a TOTP derived from a
112
+ // rotating "nuance" secret + Spotify server time; the embed __NEXT_DATA__ token
113
+ // only covers playback/reads, not search+write mutations.
114
+ const NUANCE_URL = 'https://gist.githubusercontent.com/raw/22ed9c6ba463899e933427f7de1f0eef/nuances.json';
115
+ function base32Decode(input) {
116
+ const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
117
+ let bits = 0;
118
+ let value = 0;
119
+ const out = [];
120
+ for (const c of input.replace(/=+$/, '').toUpperCase()) {
121
+ const idx = alphabet.indexOf(c);
122
+ if (idx === -1)
123
+ continue;
124
+ value = (value << 5) | idx;
125
+ bits += 5;
126
+ if (bits >= 8) {
127
+ out.push((value >>> (bits - 8)) & 0xff);
128
+ bits -= 8;
129
+ }
130
+ }
131
+ return Buffer.from(out);
132
+ }
133
+ function totp(secret, timestampSec) {
134
+ const counter = Math.floor(timestampSec / 30);
135
+ const msg = Buffer.alloc(8);
136
+ msg.writeUInt32BE(Math.floor(counter / 0x100000000), 0);
137
+ msg.writeUInt32BE(counter >>> 0, 4);
138
+ const mac = createHmac('sha1', base32Decode(secret)).update(msg).digest();
139
+ const offset = mac[mac.length - 1] & 0xf;
140
+ const code = ((mac[offset] & 0x7f) << 24) | ((mac[offset + 1] & 0xff) << 16) | ((mac[offset + 2] & 0xff) << 8) | (mac[offset + 3] & 0xff);
141
+ return String(code % 1_000_000).padStart(6, '0');
142
+ }
143
+ async function tokenFromSpDc(spDc) {
144
+ const nuances = await fetchJson(NUANCE_URL);
145
+ const nuance = nuances.reduce((best, n) => (best && best.v >= n.v ? best : n), undefined);
146
+ if (!nuance)
147
+ throw new Error('Spotify nuance secret not found');
148
+ const serverTime = await fetchJson('https://open.spotify.com/api/server-time');
149
+ const otp = totp(nuance.s, serverTime.serverTime);
150
+ const url = `https://open.spotify.com/api/token?reason=transport&productType=web-player&totp=${otp}&totpServer=${otp}&totpVer=${nuance.v}`;
151
+ const token = await fetchJson(url, { headers: { ...browserHeaders, Cookie: `sp_dc=${spDc}` } });
152
+ if (!token.accessToken)
153
+ throw new Error('Spotify token missing');
154
+ return token.accessToken;
155
+ }
156
+ // Authenticated write token from an sp_dc cookie (full web-player scope: search + write).
157
+ export function authenticatedToken(spDc) { return tokenFromSpDc(spDc); }
158
+ // Spotify search, shaped as DeezerCandidate (duration in seconds) so matchCandidates is reusable.
159
+ export async function searchTrack(term, token) {
160
+ const body = { variables: { searchTerm: term, offset: 0, limit: 10, numberOfTopResults: 10, includePreReleases: false, includeAudiobooks: false, includeAuthors: false }, operationName: 'searchTracks', extensions: { persistedQuery: { version: 1, sha256Hash: SEARCH_TRACKS_SHA } } };
161
+ const raw = await fetchJson(PATHFINDER_V2_URL, { method: 'POST', headers: writeHeaders(token), body: JSON.stringify(body) });
162
+ if (raw.errors?.length)
163
+ throw new Error(`searchTracks: ${raw.errors[0].message}`);
164
+ const items = raw.data?.searchV2?.tracksV2?.items ?? [];
165
+ return items.flatMap((it) => {
166
+ const d = it.item?.data;
167
+ if (!d?.uri || !d.name)
168
+ return [];
169
+ return [{ id: d.uri, title: d.name, artist: d.artists?.items?.[0]?.profile?.name ?? '', duration: d.duration?.totalMilliseconds != null ? Math.round(d.duration.totalMilliseconds / 1000) : null }];
170
+ });
171
+ }
172
+ export async function createPlaylist(name, token) {
173
+ const body = { ops: [{ kind: 'UPDATE_LIST_ATTRIBUTES', updateListAttributes: { newAttributes: { values: { name, description: '' } } } }] };
174
+ const raw = await fetchJson(`${SPCLIENT_URL}/playlist/v2/playlist?format=json`, { method: 'POST', headers: writeHeaders(token), body: JSON.stringify(body) });
175
+ if (!raw.uri)
176
+ throw new Error('Spotify create playlist: no uri');
177
+ await addItemsToRootlist(raw.uri, token);
178
+ return raw.uri;
179
+ }
180
+ // The REST create above makes a playlist but does NOT attach it to the account's
181
+ // library/rootlist — without this follow it never shows in the sidebar.
182
+ async function addItemsToRootlist(uri, token) {
183
+ const body = { variables: { uris: [uri] }, operationName: 'addItemsToRootlist', extensions: { persistedQuery: { version: 1, sha256Hash: ADD_ITEMS_TO_ROOTLIST_SHA } } };
184
+ const raw = await fetchJson(PATHFINDER_V2_URL, { method: 'POST', headers: writeHeaders(token), body: JSON.stringify(body) });
185
+ if (raw.errors?.length)
186
+ throw new Error(`addItemsToRootlist: ${raw.errors[0].message}`);
187
+ }
188
+ export async function addTracks(playlistUri, trackUris, token) {
189
+ const body = { variables: { playlistItemUris: trackUris, playlistUri, newPosition: { moveType: 'BOTTOM_OF_PLAYLIST', fromUid: null } }, operationName: 'addToPlaylist', extensions: { persistedQuery: { version: 1, sha256Hash: ADD_TO_PLAYLIST_SHA } } };
190
+ const raw = await fetchJson(PATHFINDER_V2_URL, { method: 'POST', headers: writeHeaders(token), body: JSON.stringify(body) });
191
+ if (raw.errors?.length)
192
+ throw new Error(`addToPlaylist: ${raw.errors[0].message}`);
193
+ }
194
+ // The user's own (writable) playlists. Filters out Liked Songs and followed playlists (canEditItems: false).
195
+ export async function listPlaylists(token) {
196
+ const body = { variables: { limit: 50, offset: 0, folderUri: null }, operationName: 'libraryV3', extensions: { persistedQuery: { version: 1, sha256Hash: LIBRARY_V3_SHA } } };
197
+ const raw = await fetchJson(PATHFINDER_V2_URL, { method: 'POST', headers: writeHeaders(token), body: JSON.stringify(body) });
198
+ if (raw.errors?.length)
199
+ throw new Error(`libraryV3: ${raw.errors[0].message}`);
200
+ const items = raw.data?.me?.libraryV3?.items ?? [];
201
+ return items.flatMap((it) => {
202
+ const d = it.item?.data;
203
+ if (it.item?.__typename !== 'PlaylistResponseWrapper' || !d?.uri || !d.name || d.currentUserCapabilities?.canEditItems !== true)
204
+ return [];
205
+ return [{ uri: d.uri, name: d.name }];
206
+ });
207
+ }
208
+ // Track URIs currently in a playlist — for dedupe on the reverse flow. Reuses the proven v1 fetchPlaylist read path.
209
+ export async function fetchTrackUris(id, token) {
210
+ const uris = [];
211
+ for (let offset = 0;;) {
212
+ const variables = { uri: `spotify:playlist:${id}`, offset, limit: 100, enableWatchFeedEntrypoint: false };
213
+ const params = new URLSearchParams({ operationName: 'fetchPlaylist', variables: JSON.stringify(variables), extensions: JSON.stringify({ persistedQuery: { version: 1, sha256Hash: FETCH_PLAYLIST_SHA } }) });
214
+ const raw = pathfinderTrackSchema.parse(await fetchJson(`${PATHFINDER_URL}?${params}`, { headers: { ...browserHeaders, Authorization: `Bearer ${token}`, 'app-platform': 'WebPlayer' } }));
215
+ if (raw.errors?.length)
216
+ throw new Error(`pathfinder: ${raw.errors[0].message}`);
217
+ const content = raw.data.playlistV2.content;
218
+ for (const item of content.items) {
219
+ const u = item.itemV2?.data?.uri;
220
+ if (u)
221
+ uris.push(u);
222
+ }
223
+ offset += content.items.length;
224
+ if (!content.items.length || offset >= content.totalCount)
225
+ return uris;
226
+ }
227
+ }