plx-converter 0.1.0 → 0.1.2

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,156 @@
1
+ import { matchCandidates, matchVideoCandidates, searchQuery } from './matcher.js';
2
+ import { innertube, isMix, parsePlaylistRef } from './ytmusic.js';
3
+ import { continuationToken, parseItems, playlistTitle, toCandidate } from './ytmusic-parse.js';
4
+ // Search filter params, as the web client sends them: catalog songs, and music videos.
5
+ const SONGS = 'EgWKAQIIAWoKEAkQBRAKEAMQBA%3D%3D';
6
+ const VIDEOS = 'EgWKAQIQAWoKEAkQBRAKEAMQBA%3D%3D';
7
+ const secToMs = (s) => (s == null ? null : s * 1000);
8
+ export class YtMusicProvider {
9
+ cookie;
10
+ name = 'YouTube Music';
11
+ constructor(cookie) {
12
+ this.cookie = cookie;
13
+ }
14
+ call(path, body) { return innertube(path, this.cookie, body); }
15
+ // Every page of a playlist, following continuations.
16
+ async browseAll(playlistId) {
17
+ const pages = [];
18
+ const items = [];
19
+ let token = null;
20
+ for (;;) {
21
+ const page = token
22
+ ? await this.call('browse', { continuation: token })
23
+ : await this.call('browse', { browseId: `VL${parsePlaylistRef(playlistId)}` });
24
+ pages.push(page);
25
+ items.push(...parseItems(page));
26
+ token = continuationToken(page);
27
+ if (!token)
28
+ return { pages, items };
29
+ }
30
+ }
31
+ // Music videos are read but not converted: their stated artist is the uploading channel, so
32
+ // matching on it produces confident wrong answers. They come back as `skipped` so the report can
33
+ // show them as their own kind of row rather than silently dropping them.
34
+ async readPlaylist(playlistId) {
35
+ if (isMix(playlistId)) {
36
+ throw new Error('That is a mix, not a playlist. A mix is generated fresh every time it is opened, so it has no fixed contents to convert — open the playlist you want and use its link.');
37
+ }
38
+ const { items } = await this.browseAll(playlistId);
39
+ return {
40
+ tracks: items.filter((i) => i.isSong).map((i) => ({ name: i.title, artist: i.artist, durationMs: secToMs(i.durationSec) })),
41
+ truncated: false,
42
+ skipped: items.filter((i) => !i.isSong).map((i) => ({ name: i.title, artist: i.artist })),
43
+ };
44
+ }
45
+ async playlistName(playlistId) {
46
+ if (isMix(playlistId))
47
+ return parsePlaylistRef(playlistId);
48
+ const page = await this.call('browse', { browseId: `VL${parsePlaylistRef(playlistId)}` });
49
+ return playlistTitle(page) || parsePlaylistRef(playlistId);
50
+ }
51
+ async searchItems(track, params) {
52
+ const response = await this.call('search', { query: searchQuery(track.name, track.artist), params });
53
+ return parseItems(response);
54
+ }
55
+ // Catalog songs first. Only when nothing in the catalog matches does a music video get a look, and
56
+ // then under stricter rules — see matchVideoCandidates (ADR 0003: the provider owns the search
57
+ // strategy, the matcher owns the rules).
58
+ async search(track) {
59
+ const songs = await this.searchItems(track, SONGS);
60
+ const song = matchCandidates(track.name, track.artist, track.durationMs, songs.filter((i) => i.isSong).map(toCandidate));
61
+ if (song)
62
+ return song;
63
+ const videos = await this.searchItems(track, VIDEOS);
64
+ return matchVideoCandidates(track.name, track.artist, track.durationMs, videos.map(toCandidate));
65
+ }
66
+ async createPlaylist(title) {
67
+ const response = await this.call('playlist/create', { title, description: '', privacyStatus: 'PRIVATE' });
68
+ if (!response.playlistId)
69
+ throw new Error('YouTube Music did not return a playlist id');
70
+ return response.playlistId;
71
+ }
72
+ // Not part of the Provider interface — plx never deletes a user's playlist during a conversion.
73
+ // It exists so a live write can be undone, which is the only way to exercise the write path
74
+ // against the real service without leaving litter behind.
75
+ async deletePlaylist(playlistId) {
76
+ await this.call('playlist/delete', { playlistId: parsePlaylistRef(playlistId) });
77
+ }
78
+ async addTracks(playlistId, videoIds) {
79
+ const response = await this.call('browse/edit_playlist', {
80
+ playlistId: parsePlaylistRef(playlistId),
81
+ actions: videoIds.map((videoId) => ({ action: 'ACTION_ADD_VIDEO', addedVideoId: videoId })),
82
+ });
83
+ if (response.status && response.status !== 'STATUS_SUCCEEDED')
84
+ throw new Error(`YouTube Music refused the add (${response.status})`);
85
+ return videoIds.length;
86
+ }
87
+ async getPlaylistTrackIds(playlistId) {
88
+ const { items } = await this.browseAll(playlistId);
89
+ return new Set(items.map((i) => i.videoId));
90
+ }
91
+ async listPlaylists() {
92
+ const response = await this.call('browse', { browseId: 'FEmusic_liked_playlists' });
93
+ const found = [];
94
+ (function walk(node) {
95
+ if (!node || typeof node !== 'object')
96
+ return;
97
+ const record = node;
98
+ const row = record.musicTwoRowItemRenderer;
99
+ const browseId = row?.navigationEndpoint?.browseEndpoint?.browseId;
100
+ const title = row?.title?.runs?.[0]?.text;
101
+ // Anything not a playlist (an artist row, say) has no VL-prefixed browse id.
102
+ if (browseId?.startsWith('VL') && title)
103
+ found.push({ id: browseId.slice(2), title });
104
+ for (const child of Object.values(record))
105
+ walk(child);
106
+ })(response);
107
+ return found;
108
+ }
109
+ // Verification: re-resolve a match by its own id and hand back what the service says it is.
110
+ // `player` reports flat `videoDetails` rather than the deep renderer nesting everything else uses,
111
+ // which makes it both cheaper and far less likely to break.
112
+ async resolveTrack(videoId) {
113
+ const response = await this.call('player', { videoId });
114
+ const details = response.videoDetails;
115
+ if (!details?.title)
116
+ throw new Error('YouTube Music returned nothing for that track');
117
+ const seconds = Number(details.lengthSeconds);
118
+ return {
119
+ name: details.title,
120
+ // A catalog track's uploader is the artist's auto-generated "<Artist> - Topic" channel.
121
+ artist: (details.author ?? '').replace(/\s*-\s*Topic$/, ''),
122
+ durationMs: Number.isFinite(seconds) ? seconds * 1000 : null,
123
+ };
124
+ }
125
+ // Reorder capability (ADR 0002), keyed on track id. YouTube Music moves by `setVideoId`, an opaque
126
+ // per-item handle that is *not* the track id — the same song added twice has two of them. That
127
+ // lookup happens here, inside the provider; the handle never reaches the conversion interface.
128
+ async reorder(playlistId, trackIds) {
129
+ const desired = [...new Set(trackIds)].filter(Boolean);
130
+ if (desired.length < 2)
131
+ return false;
132
+ const { items } = await this.browseAll(playlistId);
133
+ const handles = new Map();
134
+ for (const item of items)
135
+ if (item.setVideoId && !handles.has(item.videoId))
136
+ handles.set(item.videoId, item.setVideoId);
137
+ const movable = desired.filter((id) => handles.has(id));
138
+ const current = items.map((i) => i.videoId).filter((id) => movable.includes(id));
139
+ if (current.length === movable.length && movable.every((id, i) => current[i] === id))
140
+ return false;
141
+ // Walk backwards placing each track before the one that should follow it, so a single pass
142
+ // leaves the run in source order without needing to know where anything moved to.
143
+ for (let i = movable.length - 2; i >= 0; i -= 1) {
144
+ await this.call('browse/edit_playlist', {
145
+ playlistId: parsePlaylistRef(playlistId),
146
+ actions: [{
147
+ action: 'ACTION_MOVE_VIDEO_BEFORE',
148
+ setVideoId: handles.get(movable[i]),
149
+ movedSetVideoIdSuccessor: handles.get(movable[i + 1]),
150
+ }],
151
+ });
152
+ await new Promise((resolve) => setTimeout(resolve, 100));
153
+ }
154
+ return true;
155
+ }
156
+ }
@@ -0,0 +1,130 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { browserHeaders, fetchJson } from './http.js';
3
+ const ORIGIN = 'https://music.youtube.com';
4
+ const INNERTUBE = `${ORIGIN}/youtubei/v1`;
5
+ // The cookies the YouTube Music web client sends. Google needs a bundle rather than the single
6
+ // cookie the other two providers use, which is why plx stores a whole cookie header for it and why
7
+ // the manual path is one paste instead of a prompt per cookie.
8
+ //
9
+ // SAPISID (or its __Secure- twin) is the one that must be present: request signing is derived from
10
+ // it. The rest carry the session and are sent as-is.
11
+ export const YTMUSIC_COOKIES = [
12
+ 'SAPISID', '__Secure-1PAPISID', '__Secure-3PAPISID',
13
+ 'SID', '__Secure-1PSID', '__Secure-3PSID',
14
+ // The *SIDTS pair is not optional on a current Google session: without it the *PSID cookies are
15
+ // treated as unbound and the request authenticates as logged out, with a 200 and no hint why.
16
+ '__Secure-1PSIDTS', '__Secure-3PSIDTS',
17
+ 'HSID', 'SSID', 'APISID',
18
+ 'LOGIN_INFO', 'SIDCC', '__Secure-1PSIDCC', '__Secure-3PSIDCC',
19
+ 'PREF', 'VISITOR_INFO1_LIVE', 'YSC',
20
+ ];
21
+ // The single joiner both browser backends route through: whatever cookies were found, in one header
22
+ // string. Only the names above are kept, in that order, so the header is stable regardless of the
23
+ // order a cookie store happened to yield them. Returns '' when the essential cookie is missing —
24
+ // a bundle without it cannot be signed, so it is not a credential.
25
+ export function cookieHeader(found) {
26
+ const pairs = YTMUSIC_COOKIES.filter((name) => found[name]).map((name) => `${name}=${found[name]}`);
27
+ return sapisidFrom(pairs.join('; ')) ? pairs.join('; ') : '';
28
+ }
29
+ // The value request signing is derived from. Accepts the __Secure- variants, which is what a
30
+ // browser that has migrated to partitioned cookies will have instead of a bare SAPISID.
31
+ export function sapisidFrom(header) {
32
+ for (const name of ['SAPISID', '__Secure-3PAPISID', '__Secure-1PAPISID']) {
33
+ const match = header.match(new RegExp(`(?:^|;\\s*)${name}=([^;]+)`));
34
+ if (match)
35
+ return match[1];
36
+ }
37
+ return '';
38
+ }
39
+ // SAPISIDHASH: sha1 over "<unix seconds> <SAPISID> <origin>", the way the web client signs its own
40
+ // requests. Timestamp is a parameter so the hash is testable against a known vector.
41
+ export function sapisidHash(sapisid, origin = ORIGIN, nowSec = Math.floor(Date.now() / 1000)) {
42
+ const digest = createHash('sha1').update(`${nowSec} ${sapisid} ${origin}`).digest('hex');
43
+ return `SAPISIDHASH ${nowSec}_${digest}`;
44
+ }
45
+ export function ytmusicHeaders(cookie) {
46
+ return {
47
+ ...browserHeaders,
48
+ Accept: '*/*',
49
+ 'Content-Type': 'application/json',
50
+ Authorization: sapisidHash(sapisidFrom(cookie)),
51
+ 'X-Goog-AuthUser': '0',
52
+ 'x-origin': ORIGIN,
53
+ Cookie: cookie,
54
+ };
55
+ }
56
+ // InnerTube wants a client version; it is lenient about the exact value but not about the shape.
57
+ // Derived from today's date the way the web client's own version is, so it ages with the calendar
58
+ // instead of going stale as a pinned constant.
59
+ export function clientVersion(now = new Date()) {
60
+ const date = now.toISOString().slice(0, 10).replaceAll('-', '');
61
+ return `1.${date}.01.00`;
62
+ }
63
+ export const innertubeContext = () => ({ context: { client: { clientName: 'WEB_REMIX', clientVersion: clientVersion(), hl: 'en', gl: 'US' } } });
64
+ export async function innertube(path, cookie, body = {}) {
65
+ return fetchJson(`${INNERTUBE}/${path}?prettyPrint=false`, {
66
+ method: 'POST',
67
+ headers: ytmusicHeaders(cookie),
68
+ body: JSON.stringify({ ...innertubeContext(), ...body }),
69
+ });
70
+ }
71
+ // First `accountName: { runs: [{ text }] }` anywhere in the response. The account-menu renderer is
72
+ // nested about six levels deep and the nesting is not a contract, so this walks rather than indexes.
73
+ export function accountNameFrom(value) {
74
+ if (!value || typeof value !== 'object')
75
+ return '';
76
+ const node = value;
77
+ const runs = node.accountName?.runs;
78
+ if (runs?.[0]?.text)
79
+ return runs[0].text;
80
+ for (const child of Object.values(node)) {
81
+ const found = accountNameFrom(child);
82
+ if (found)
83
+ return found;
84
+ }
85
+ return '';
86
+ }
87
+ const EXPIRED = 'Your YouTube Music session has expired. Log in at music.youtube.com again, then re-run auto-fetch or paste a fresh cookie header.';
88
+ // Prove the credential against the live service, resolving to the signed-in account name.
89
+ //
90
+ // Google answers a rejected session with **200 and a signed-out menu**, not an error status, so the
91
+ // only reliable proof is that the response actually names an account. Trusting the status code here
92
+ // reports a dead session as working.
93
+ export async function validateSession(cookie) {
94
+ if (!sapisidFrom(cookie))
95
+ throw new Error('That cookie header has no SAPISID — copy the whole Cookie line from a music.youtube.com request.');
96
+ let raw;
97
+ try {
98
+ raw = await innertube('account/account_menu', cookie);
99
+ }
100
+ catch (error) {
101
+ const message = error instanceof Error ? error.message : String(error);
102
+ if (/\b(401|403)\b/.test(message))
103
+ throw new Error(EXPIRED);
104
+ throw new Error(`Could not reach YouTube Music (${message}).`);
105
+ }
106
+ const account = accountNameFrom(raw);
107
+ if (!account)
108
+ throw new Error(EXPIRED);
109
+ return account;
110
+ }
111
+ // --- Playlist ids ----------------------------------------------------------
112
+ // A YouTube Music playlist id *is* a YouTube playlist id, so both hosts name the same thing.
113
+ export function parsePlaylistRef(ref) {
114
+ const input = ref.trim();
115
+ if (/^https?:\/\//i.test(input)) {
116
+ try {
117
+ const list = new URL(input).searchParams.get('list');
118
+ if (list)
119
+ return list;
120
+ const last = new URL(input).pathname.replace(/\/+$/, '').split('/').pop() ?? '';
121
+ return last.replace(/^VL/, '');
122
+ }
123
+ catch { /* fall through to the raw value */ }
124
+ }
125
+ return input.replace(/^VL/, '');
126
+ }
127
+ // A mix (radio) regenerates on every visit: its contents are not reproducible, so it is not a
128
+ // playlist and plx refuses it as a source. Every mix id starts with RD — RDCLAK, RDAMVM, RDEM…
129
+ // Checked on the id so the reason is explicit, rather than surfacing as an empty read.
130
+ export const isMix = (playlistId) => /^RD/i.test(parsePlaylistRef(playlistId));
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "plx-converter",
3
- "version": "0.1.0",
4
- "description": "Spotify ⇄ Deezer playlist converter CLI",
3
+ "version": "0.1.2",
4
+ "description": "Convert playlists between Spotify, Deezer, and YouTube Music using your own browser session",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "bin": {
@@ -26,6 +26,7 @@
26
26
  "dependencies": {
27
27
  "@clack/prompts": "^0.10.1",
28
28
  "dotenv": "^16.4.7",
29
+ "plx-converter": "^0.1.1",
29
30
  "zod": "^3.24.2"
30
31
  },
31
32
  "devDependencies": {
package/dist/converter.js DELETED
@@ -1,115 +0,0 @@
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/reverse.js DELETED
@@ -1,87 +0,0 @@
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
- }