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.
- package/LICENSE +21 -0
- package/README.md +60 -0
- package/dist/args.js +52 -0
- package/dist/browser.js +161 -0
- package/dist/cli.js +312 -0
- package/dist/config.js +67 -0
- package/dist/converter.js +115 -0
- package/dist/csv.js +10 -0
- package/dist/deezer.js +145 -0
- package/dist/http.js +39 -0
- package/dist/index.js +2 -0
- package/dist/matcher.js +25 -0
- package/dist/reverse.js +87 -0
- package/dist/spotify.js +227 -0
- package/dist/types.js +1 -0
- package/package.json +41 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Native Muttaqien
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# plx
|
|
2
|
+
|
|
3
|
+
Spotify ⇄ Deezer playlist converter CLI.
|
|
4
|
+
|
|
5
|
+
Converts playlists in both directions — reads Spotify **without an account, Premium, or OAuth**; writes via your session cookies.
|
|
6
|
+
|
|
7
|
+
> ⚠️ **Personal, non-commercial use.** Uses unofficial web endpoints that can change or break without notice. Not affiliated with Spotify or Deezer.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install -g plx
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Requires Node.js ≥ 22.
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
plx # interactive menu (both directions)
|
|
21
|
+
plx --url "https://open.spotify.com/playlist/ID" # Spotify → Deezer
|
|
22
|
+
plx --url "<URL>" --dry-run # preview matches without writing
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
```text
|
|
26
|
+
Options:
|
|
27
|
+
-u, --url <URL|ID> playlist URL/URI/ID (repeatable)
|
|
28
|
+
-o, --output <file> CSV report path (default: conversion_report.csv)
|
|
29
|
+
-d, --dry-run match only, don't write
|
|
30
|
+
-h, --help show help
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Credentials
|
|
34
|
+
|
|
35
|
+
| Cookie | Env var | Needed for |
|
|
36
|
+
|---|---|---|
|
|
37
|
+
| Deezer `arl` | `DEEZER_ARL` | writing to Deezer |
|
|
38
|
+
| Spotify `sp_dc` | `SPOTIFY_DC` | writing to Spotify (reverse flow) |
|
|
39
|
+
|
|
40
|
+
Set in `.env`, or paste when prompted. Reading a Spotify playlist needs **no credentials**. Full setup: [`docs/CONFIG.md`](docs/CONFIG.md).
|
|
41
|
+
|
|
42
|
+
## Development
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
npm install
|
|
46
|
+
npm run typecheck
|
|
47
|
+
npm test
|
|
48
|
+
npm run lint
|
|
49
|
+
npm run build
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Documentation
|
|
53
|
+
|
|
54
|
+
- [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) — design, endpoint contracts, invariants.
|
|
55
|
+
- [`docs/CONFIG.md`](docs/CONFIG.md) — setup, env vars, config directory.
|
|
56
|
+
- [`docs/CONTINUATION.md`](docs/CONTINUATION.md) — handoff log.
|
|
57
|
+
|
|
58
|
+
## License
|
|
59
|
+
|
|
60
|
+
MIT
|
package/dist/args.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export const HELP_TEXT = `plx — Spotify → Deezer playlist converter
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
plx interactive menu
|
|
5
|
+
plx --url <URL|ID> [--url ...] convert specific playlist(s) — no Spotify account or OAuth needed
|
|
6
|
+
|
|
7
|
+
Options:
|
|
8
|
+
-u, --url <URL|ID> Spotify playlist URL, URI, or ID (repeatable)
|
|
9
|
+
-o, --output <file> CSV report path (default: conversion_report.csv)
|
|
10
|
+
-d, --dry-run match only; do not create Deezer playlists
|
|
11
|
+
-h, --help show this help
|
|
12
|
+
|
|
13
|
+
Examples:
|
|
14
|
+
plx --url https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M --dry-run
|
|
15
|
+
plx
|
|
16
|
+
`;
|
|
17
|
+
export function parseArgs(argv) {
|
|
18
|
+
const options = { urls: [], dryRun: false, output: 'conversion_report.csv', help: false };
|
|
19
|
+
const takeValue = (flag, i, inline) => inline !== undefined ? { value: inline, next: i + 1 } : { value: argv[i + 1], next: i + 2 };
|
|
20
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
21
|
+
const arg = argv[i];
|
|
22
|
+
if (arg === '--help' || arg === '-h') {
|
|
23
|
+
options.help = true;
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
if (arg === '--dry-run' || arg === '-d') {
|
|
27
|
+
options.dryRun = true;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
const flagMatch = arg.match(/^(--\w+|\w)=(.+)$/);
|
|
31
|
+
const flag = flagMatch ? flagMatch[1] : arg;
|
|
32
|
+
const inline = flagMatch ? flagMatch[2] : undefined;
|
|
33
|
+
if (flag === '--url' || flag === '-u') {
|
|
34
|
+
const { value, next } = takeValue('--url', i, inline);
|
|
35
|
+
if (value)
|
|
36
|
+
options.urls.push(value);
|
|
37
|
+
i = next - 1;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (flag === '--output' || flag === '-o') {
|
|
41
|
+
const { value, next } = takeValue('--output', i, inline);
|
|
42
|
+
if (value)
|
|
43
|
+
options.output = value;
|
|
44
|
+
i = next - 1;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (arg.startsWith('-'))
|
|
48
|
+
throw new Error(`Unknown flag: ${arg}`);
|
|
49
|
+
throw new Error(`Unexpected argument: ${arg}`);
|
|
50
|
+
}
|
|
51
|
+
return options;
|
|
52
|
+
}
|
package/dist/browser.js
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { createDecipheriv, pbkdf2Sync } from 'node:crypto';
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
4
|
+
import { homedir, platform } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
7
|
+
const CHROMIUM_BROWSERS = [
|
|
8
|
+
{ dir: 'BraveSoftware/Brave-Browser', keychain: 'Brave Safe Storage' },
|
|
9
|
+
{ dir: 'Google/Chrome', keychain: 'Chrome Safe Storage' },
|
|
10
|
+
{ dir: 'Microsoft Edge', keychain: 'Microsoft Edge Safe Storage' },
|
|
11
|
+
{ dir: 'Chromium', keychain: 'Chromium Safe Storage' },
|
|
12
|
+
{ dir: 'Vivaldi', keychain: 'Vivaldi Safe Storage' },
|
|
13
|
+
{ dir: 'com.operasoftware.Opera', keychain: 'Opera Safe Storage' },
|
|
14
|
+
];
|
|
15
|
+
// Chromium v10 blob: 3-byte "v10" prefix + AES-128-CBC (IV = 16 × 0x20, PKCS7).
|
|
16
|
+
// Key = PBKDF2-HMAC-SHA1(keychain pw string, "saltysalt", 1003, 16 bytes).
|
|
17
|
+
// DB version ≥ 24 prepends a 32-byte SHA256 domain hash to the plaintext.
|
|
18
|
+
const PBKDF2_SALT = 'saltysalt';
|
|
19
|
+
const PBKDF2_ITER = 1003;
|
|
20
|
+
const PBKDF2_KEYLEN = 16;
|
|
21
|
+
export function decryptCookie(blob, key, stripDomainHash = false) {
|
|
22
|
+
if (blob.length < 3 || blob.subarray(0, 3).toString('latin1') !== 'v10')
|
|
23
|
+
return null;
|
|
24
|
+
try {
|
|
25
|
+
const decipher = createDecipheriv('aes-128-cbc', key, Buffer.alloc(16, 0x20));
|
|
26
|
+
let plain = Buffer.concat([decipher.update(blob.subarray(3)), decipher.final()]);
|
|
27
|
+
if (stripDomainHash)
|
|
28
|
+
plain = plain.subarray(32);
|
|
29
|
+
const value = plain.toString('utf8');
|
|
30
|
+
// arl is hex, sp_dc is base64 — both printable ASCII. Reject key-mismatch garbage.
|
|
31
|
+
return /^[\x20-\x7e]*$/.test(value) ? value : null;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function keychainPassword(service) {
|
|
38
|
+
if (platform() !== 'darwin')
|
|
39
|
+
return null;
|
|
40
|
+
try {
|
|
41
|
+
// This call pops the macOS Keychain "Allow" permission dialog on first use.
|
|
42
|
+
return execFileSync('security', ['find-generic-password', '-w', '-s', service], { encoding: 'utf8', timeout: 60_000 }).replace(/\n$/, '');
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return null; // keychain denied / no such item — skip this browser
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const COOKIE_QUERY = `SELECT name, host_key, encrypted_value FROM cookies WHERE (name = 'arl' AND host_key = '.deezer.com') OR (name = 'sp_dc' AND host_key = '.spotify.com')`;
|
|
49
|
+
function readChromium(browser) {
|
|
50
|
+
const dbPath = join(homedir(), 'Library', 'Application Support', browser.dir, 'Default', 'Cookies');
|
|
51
|
+
if (!existsSync(dbPath))
|
|
52
|
+
return {};
|
|
53
|
+
const pw = keychainPassword(browser.keychain);
|
|
54
|
+
if (pw == null)
|
|
55
|
+
return {};
|
|
56
|
+
const key = pbkdf2Sync(pw, PBKDF2_SALT, PBKDF2_ITER, PBKDF2_KEYLEN, 'sha1');
|
|
57
|
+
const db = new DatabaseSync(dbPath, { readOnly: true });
|
|
58
|
+
try {
|
|
59
|
+
const meta = db.prepare("SELECT value FROM meta WHERE key = 'version'").get();
|
|
60
|
+
const dbVersion = meta?.value ? Number(meta.value) : 0;
|
|
61
|
+
const out = {};
|
|
62
|
+
const rows = db.prepare(COOKIE_QUERY).all();
|
|
63
|
+
for (const row of rows) {
|
|
64
|
+
const value = decryptCookie(Buffer.from(row.encrypted_value), key, dbVersion >= 24);
|
|
65
|
+
if (value) {
|
|
66
|
+
if (row.name === 'arl')
|
|
67
|
+
out.arl = value;
|
|
68
|
+
else if (row.name === 'sp_dc')
|
|
69
|
+
out.spDc = value;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return {}; // locked DB / changed schema — skip this browser
|
|
76
|
+
}
|
|
77
|
+
finally {
|
|
78
|
+
db.close();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
// Safari `Cookies.binarycookies` layout (mirrors browser_cookie3):
|
|
82
|
+
// "cook" magic + big-endian page table, then little-endian cookie records with
|
|
83
|
+
// null-terminated host/name/value strings at cookie-relative offsets.
|
|
84
|
+
const SAFARI_PATHS = [
|
|
85
|
+
join(homedir(), 'Library', 'Containers', 'com.apple.Safari', 'Data', 'Library', 'Cookies', 'Cookies.binarycookies'),
|
|
86
|
+
join(homedir(), 'Library', 'Cookies', 'Cookies.binarycookies'),
|
|
87
|
+
];
|
|
88
|
+
function readCStr(buf, start) {
|
|
89
|
+
let end = start;
|
|
90
|
+
while (end < buf.length && buf[end] !== 0)
|
|
91
|
+
end++;
|
|
92
|
+
return buf.subarray(start, end).toString('utf8');
|
|
93
|
+
}
|
|
94
|
+
export function parseSafariCookies(buf) {
|
|
95
|
+
if (buf.length < 8 || buf.subarray(0, 4).toString('latin1') !== 'cook')
|
|
96
|
+
return {};
|
|
97
|
+
const totalPages = buf.readUInt32BE(4);
|
|
98
|
+
const pageSizes = [];
|
|
99
|
+
for (let i = 0; i < totalPages; i++)
|
|
100
|
+
pageSizes.push(buf.readUInt32BE(8 + i * 4));
|
|
101
|
+
const out = {};
|
|
102
|
+
let base = 8 + totalPages * 4;
|
|
103
|
+
for (let p = 0; p < totalPages; p++) {
|
|
104
|
+
const page = buf.subarray(base, base + pageSizes[p]);
|
|
105
|
+
base += pageSizes[p];
|
|
106
|
+
if (page.length < 8 || page.readUInt32BE(0) !== 0x00000100)
|
|
107
|
+
continue;
|
|
108
|
+
const nCookies = page.readUInt32LE(4);
|
|
109
|
+
for (let i = 0; i < nCookies; i++) {
|
|
110
|
+
const cookieOffset = page.readUInt32LE(8 + i * 4);
|
|
111
|
+
const host = readCStr(page, cookieOffset + page.readUInt32LE(cookieOffset + 16));
|
|
112
|
+
const name = readCStr(page, cookieOffset + page.readUInt32LE(cookieOffset + 20));
|
|
113
|
+
const value = readCStr(page, cookieOffset + page.readUInt32LE(cookieOffset + 28));
|
|
114
|
+
if (!out.arl && name === 'arl' && host.includes('deezer.com'))
|
|
115
|
+
out.arl = value;
|
|
116
|
+
else if (!out.spDc && name === 'sp_dc' && host.includes('spotify.com'))
|
|
117
|
+
out.spDc = value;
|
|
118
|
+
if (out.arl && out.spDc)
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
function readSafari() {
|
|
125
|
+
for (const path of SAFARI_PATHS) {
|
|
126
|
+
if (!existsSync(path))
|
|
127
|
+
continue;
|
|
128
|
+
try {
|
|
129
|
+
return parseSafariCookies(readFileSync(path));
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return {}; // TCC-denied or unreadable — skip
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return {};
|
|
136
|
+
}
|
|
137
|
+
// Auto-retrieve credentials from the first logged-in browser that has them.
|
|
138
|
+
// Never throws — a missing browser, locked DB, denied keychain, or TCC block
|
|
139
|
+
// just yields {}. Callers persist what's found; manual paste stays the fallback.
|
|
140
|
+
export function fetchBrowserCredentials() {
|
|
141
|
+
if (platform() !== 'darwin')
|
|
142
|
+
return {};
|
|
143
|
+
const result = {};
|
|
144
|
+
for (const browser of CHROMIUM_BROWSERS) {
|
|
145
|
+
const found = readChromium(browser);
|
|
146
|
+
if (found.arl && !result.arl)
|
|
147
|
+
result.arl = found.arl;
|
|
148
|
+
if (found.spDc && !result.spDc)
|
|
149
|
+
result.spDc = found.spDc;
|
|
150
|
+
if (result.arl && result.spDc)
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
if (!result.arl || !result.spDc) {
|
|
154
|
+
const safari = readSafari();
|
|
155
|
+
if (safari.arl && !result.arl)
|
|
156
|
+
result.arl = safari.arl;
|
|
157
|
+
if (safari.spDc && !result.spDc)
|
|
158
|
+
result.spDc = safari.spDc;
|
|
159
|
+
}
|
|
160
|
+
return result;
|
|
161
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { confirm, intro, isCancel, outro, password, select, text } from '@clack/prompts';
|
|
3
|
+
import { loadConfig, saveCredentials, saveRecentUrl, tryAutoFillCredentials } from './config.js';
|
|
4
|
+
import { DeezerClient, resolveDeezerPlaylistId } from './deezer.js';
|
|
5
|
+
import { Converter } from './converter.js';
|
|
6
|
+
import { anonymousToken, authenticatedToken, parsePlaylistId, playlistName } from './spotify.js';
|
|
7
|
+
import { reverseConvert, reverseWriteToExisting } from './reverse.js';
|
|
8
|
+
import { listPlaylists } from './spotify.js';
|
|
9
|
+
import { HELP_TEXT, parseArgs } from './args.js';
|
|
10
|
+
function args() { return process.argv.slice(2); }
|
|
11
|
+
// Build a validated Deezer client. If no ARL is set, prompt for it and persist.
|
|
12
|
+
// Returns null when the user cancels or the ARL is rejected.
|
|
13
|
+
async function ensureDeezer(cfg) {
|
|
14
|
+
if (!cfg.deezerArl) {
|
|
15
|
+
const filled = await tryAutoFillCredentials(cfg);
|
|
16
|
+
if (!filled.includes('deezerArl')) {
|
|
17
|
+
const arl = await password({ message: `Deezer ARL (${ARL_HINT})` });
|
|
18
|
+
if (isCancel(arl) || !arl)
|
|
19
|
+
return null;
|
|
20
|
+
cfg.deezerArl = String(arl);
|
|
21
|
+
await saveCredentials({ deezerArl: cfg.deezerArl });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
const deezer = new DeezerClient(cfg.deezerArl);
|
|
25
|
+
try {
|
|
26
|
+
await deezer.getMe();
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
console.log(`⚠️ ARL Deezer tidak valid (${error instanceof Error ? error.message : 'error'}). Isi ulang lewat Pengaturan.`);
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
return deezer;
|
|
33
|
+
}
|
|
34
|
+
// Pick a Spotify source: recent URLs, paste new, or (with sp_dc) pick from the account.
|
|
35
|
+
// Returns the ref plus an authenticated read token when picked from the account
|
|
36
|
+
// (covers private playlists); token is null on the anonymous paste/recent paths.
|
|
37
|
+
async function chooseSource(cfg) {
|
|
38
|
+
const options = [
|
|
39
|
+
...cfg.recentUrls.map((u) => ({ value: u, label: u })),
|
|
40
|
+
{ value: '__new__', label: 'Tempel URL/ID baru' },
|
|
41
|
+
];
|
|
42
|
+
if (cfg.spotifyDc)
|
|
43
|
+
options.push({ value: '__lib__', label: 'Pilih dari akun Spotify saya' });
|
|
44
|
+
const pick = await select({ message: 'Playlist asal (Spotify)', options });
|
|
45
|
+
if (isCancel(pick))
|
|
46
|
+
return null;
|
|
47
|
+
if (pick === '__new__') {
|
|
48
|
+
const ref = await text({ message: 'Tempel URL/ID playlist (q untuk kembali)', initialValue: '' });
|
|
49
|
+
if (isCancel(ref) || !ref || String(ref).toLowerCase() === 'q')
|
|
50
|
+
return null;
|
|
51
|
+
return { ref: String(ref), token: null };
|
|
52
|
+
}
|
|
53
|
+
if (pick === '__lib__') {
|
|
54
|
+
const token = await ensureSpotify(cfg);
|
|
55
|
+
if (!token)
|
|
56
|
+
return null;
|
|
57
|
+
const uri = await pickSpotifyPlaylist(token, 'asal');
|
|
58
|
+
return uri ? { ref: uri, token } : null;
|
|
59
|
+
}
|
|
60
|
+
return { ref: String(pick), token: null };
|
|
61
|
+
}
|
|
62
|
+
// Pick an existing Deezer playlist (as target or source). Returns { id, title } or null.
|
|
63
|
+
async function chooseTarget(deezer, role = 'target') {
|
|
64
|
+
const method = await select({
|
|
65
|
+
message: `Pilih playlist ${role === 'source' ? 'asal' : 'target'} (Deezer)`,
|
|
66
|
+
options: [
|
|
67
|
+
{ value: 'list', label: 'Daftar dari akun Deezer' },
|
|
68
|
+
{ value: 'paste', label: 'Tempel URL/ID Deezer' },
|
|
69
|
+
],
|
|
70
|
+
});
|
|
71
|
+
if (isCancel(method))
|
|
72
|
+
return null;
|
|
73
|
+
if (method === 'list') {
|
|
74
|
+
let playlists;
|
|
75
|
+
try {
|
|
76
|
+
playlists = await deezer.listPlaylists();
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
console.log(`⚠️ Gagal baca daftar playlist (${error instanceof Error ? error.message : 'error'}).`);
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
if (!playlists.length) {
|
|
83
|
+
console.log('Tidak ada playlist Deezer ditemukan di akun ini.');
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
const pick = await select({ message: 'Pilih playlist', options: playlists.map((p) => ({ value: p.id, label: p.title })) });
|
|
87
|
+
if (isCancel(pick))
|
|
88
|
+
return null;
|
|
89
|
+
return playlists.find((p) => p.id === pick) ?? null;
|
|
90
|
+
}
|
|
91
|
+
const ref = await text({ message: 'Tempel URL/ID playlist Deezer', initialValue: '' });
|
|
92
|
+
if (isCancel(ref) || !ref)
|
|
93
|
+
return null;
|
|
94
|
+
const id = await resolveDeezerPlaylistId(String(ref));
|
|
95
|
+
return { id, title: id };
|
|
96
|
+
}
|
|
97
|
+
// Pick how to write the target: a new playlist or an existing one.
|
|
98
|
+
async function chooseTargetKind() {
|
|
99
|
+
const kind = await select({
|
|
100
|
+
message: 'Target tujuan',
|
|
101
|
+
options: [
|
|
102
|
+
{ value: 'new', label: 'Playlist baru' },
|
|
103
|
+
{ value: 'existing', label: 'Playlist yang sudah ada' },
|
|
104
|
+
],
|
|
105
|
+
});
|
|
106
|
+
if (isCancel(kind))
|
|
107
|
+
return null;
|
|
108
|
+
return kind;
|
|
109
|
+
}
|
|
110
|
+
// Core convert: read source, match, then write to a new playlist or an existing target.
|
|
111
|
+
async function convert(cfg, options) {
|
|
112
|
+
const source = await chooseSource(cfg);
|
|
113
|
+
if (!source)
|
|
114
|
+
return;
|
|
115
|
+
const deezer = await ensureDeezer(cfg);
|
|
116
|
+
if (!deezer)
|
|
117
|
+
return;
|
|
118
|
+
const id = parsePlaylistId(source.ref);
|
|
119
|
+
const name = await playlistName(id);
|
|
120
|
+
const converter = new Converter(deezer, source.token ?? await anonymousToken(), options.output);
|
|
121
|
+
const result = await converter.matchPlaylist({ name, uri: id }, options.dryRun);
|
|
122
|
+
if (!options.dryRun && result.matchedIds.length) {
|
|
123
|
+
const kind = await chooseTargetKind();
|
|
124
|
+
if (kind === 'existing') {
|
|
125
|
+
const target = await chooseTarget(deezer);
|
|
126
|
+
if (target)
|
|
127
|
+
await converter.writeToExisting(name, target.id, result.matchedIds);
|
|
128
|
+
}
|
|
129
|
+
else if (kind === 'new') {
|
|
130
|
+
await maybeWrite(converter, name, result);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
await converter.writeReport();
|
|
134
|
+
await saveRecentUrl(source.ref);
|
|
135
|
+
}
|
|
136
|
+
async function maybeWrite(converter, name, result) {
|
|
137
|
+
if (!result.matchedIds.length)
|
|
138
|
+
return;
|
|
139
|
+
if (result.truncated) {
|
|
140
|
+
const proceed = await confirm({ message: `Playlist terpotong di 100 lagu. Lanjut dengan ${result.matchedIds.length} lagu?`, initialValue: false });
|
|
141
|
+
if (isCancel(proceed) || !proceed)
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const ok = await confirm({ message: `Buat '[plx] ${name}' (${result.matchedIds.length} lagu)?`, initialValue: false });
|
|
145
|
+
if (isCancel(ok) || !ok)
|
|
146
|
+
return;
|
|
147
|
+
await converter.writePlaylist(name, result.matchedIds);
|
|
148
|
+
}
|
|
149
|
+
// Build a validated Spotify session (sp_dc cookie → authenticated token). Returns token or null.
|
|
150
|
+
async function ensureSpotify(cfg) {
|
|
151
|
+
if (!cfg.spotifyDc) {
|
|
152
|
+
const filled = await tryAutoFillCredentials(cfg);
|
|
153
|
+
if (!filled.includes('spotifyDc')) {
|
|
154
|
+
const dc = await password({ message: `Spotify sp_dc (${SPDC_HINT})` });
|
|
155
|
+
if (isCancel(dc) || !dc)
|
|
156
|
+
return null;
|
|
157
|
+
cfg.spotifyDc = String(dc);
|
|
158
|
+
await saveCredentials({ spotifyDc: cfg.spotifyDc });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
try {
|
|
162
|
+
return await authenticatedToken(cfg.spotifyDc);
|
|
163
|
+
}
|
|
164
|
+
catch (error) {
|
|
165
|
+
console.log(`⚠️ sp_dc Spotify tidak valid (${error instanceof Error ? error.message : 'error'}). Isi ulang lewat Pengaturan.`);
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
// Pick a Spotify playlist by URI from the user's account (role 'asal' | 'target').
|
|
170
|
+
async function pickSpotifyPlaylist(token, role) {
|
|
171
|
+
let playlists;
|
|
172
|
+
try {
|
|
173
|
+
playlists = await listPlaylists(token);
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
console.log(`⚠️ Gagal baca daftar playlist Spotify (${error instanceof Error ? error.message : 'error'}).`);
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
if (!playlists.length) {
|
|
180
|
+
console.log('Tidak ada playlist Spotify (yang bisa diubah) ditemukan.');
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
const pick = await select({ message: `Pilih playlist ${role} (Spotify)`, options: playlists.map((p) => ({ value: p.uri, label: p.name })) });
|
|
184
|
+
if (isCancel(pick))
|
|
185
|
+
return null;
|
|
186
|
+
return String(pick);
|
|
187
|
+
}
|
|
188
|
+
// Pick an existing Spotify playlist (writable) as reverse target. Returns uri or null.
|
|
189
|
+
async function chooseSpotifyTarget(token) {
|
|
190
|
+
return pickSpotifyPlaylist(token, 'target');
|
|
191
|
+
}
|
|
192
|
+
// Reverse flow: Deezer playlist (source) → new or existing Spotify playlist (target).
|
|
193
|
+
async function reverseConvertFlow(cfg, options) {
|
|
194
|
+
const deezer = await ensureDeezer(cfg);
|
|
195
|
+
if (!deezer)
|
|
196
|
+
return;
|
|
197
|
+
const source = await chooseTarget(deezer, 'source');
|
|
198
|
+
if (!source)
|
|
199
|
+
return;
|
|
200
|
+
const token = await ensureSpotify(cfg);
|
|
201
|
+
if (!token)
|
|
202
|
+
return;
|
|
203
|
+
const kind = await chooseTargetKind();
|
|
204
|
+
if (kind === 'existing') {
|
|
205
|
+
const target = await chooseSpotifyTarget(token);
|
|
206
|
+
if (target)
|
|
207
|
+
await reverseWriteToExisting(deezer, token, source.id, source.title, target, options.output);
|
|
208
|
+
}
|
|
209
|
+
else if (kind === 'new') {
|
|
210
|
+
await reverseConvert(deezer, token, source.id, source.title, options.output);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
function warnLogin(sites) {
|
|
214
|
+
console.log(`⚠️ Pastikan sudah login di ${sites} di browser, lalu ambil kredensialnya.`);
|
|
215
|
+
}
|
|
216
|
+
const ARL_HINT = 'login deezer.com → F12 → Application → Cookies → arl → salin value';
|
|
217
|
+
const SPDC_HINT = 'login open.spotify.com → F12 → Application → Cookies → sp_dc → salin value';
|
|
218
|
+
async function runInteractive(options) {
|
|
219
|
+
intro('plx — Spotify ⇄ Deezer');
|
|
220
|
+
const cfg = await loadConfig(false); // no requirement at menu entry
|
|
221
|
+
for (;;) {
|
|
222
|
+
const choice = await select({
|
|
223
|
+
message: 'Pilih aksi',
|
|
224
|
+
options: [
|
|
225
|
+
{ value: 'forward', label: 'Spotify → Deezer' },
|
|
226
|
+
{ value: 'reverse', label: 'Deezer → Spotify' },
|
|
227
|
+
{ value: 'arl', label: `Deezer ARL: ${cfg.deezerArl ? '✓ tersimpan' : '(belum diisi)'}` },
|
|
228
|
+
{ value: 'spdc', label: `Spotify sp_dc: ${cfg.spotifyDc ? '✓ tersimpan' : '(belum diisi)'}` },
|
|
229
|
+
{ value: 'autofetch', label: 'Ambil kredensial otomatis (dari browser)' },
|
|
230
|
+
{ value: 'output', label: `Laporan: ${options.output}` },
|
|
231
|
+
{ value: 'quit', label: 'Keluar' },
|
|
232
|
+
],
|
|
233
|
+
});
|
|
234
|
+
if (isCancel(choice) || choice === 'quit')
|
|
235
|
+
break;
|
|
236
|
+
if (choice === 'forward') {
|
|
237
|
+
await convert(cfg, options);
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
if (choice === 'reverse') {
|
|
241
|
+
await reverseConvertFlow(cfg, options);
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
if (choice === 'arl') {
|
|
245
|
+
warnLogin('Deezer (deezer.com)');
|
|
246
|
+
const filled = await tryAutoFillCredentials(cfg);
|
|
247
|
+
if (!filled.includes('deezerArl')) {
|
|
248
|
+
const arl = await password({ message: `Deezer ARL (${ARL_HINT})` });
|
|
249
|
+
if (isCancel(arl) || !arl)
|
|
250
|
+
continue;
|
|
251
|
+
cfg.deezerArl = String(arl);
|
|
252
|
+
await saveCredentials({ deezerArl: cfg.deezerArl });
|
|
253
|
+
}
|
|
254
|
+
console.log('✓ Deezer ARL tersimpan.');
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
if (choice === 'spdc') {
|
|
258
|
+
warnLogin('Spotify (open.spotify.com)');
|
|
259
|
+
const filled = await tryAutoFillCredentials(cfg);
|
|
260
|
+
if (!filled.includes('spotifyDc')) {
|
|
261
|
+
const dc = await password({ message: `Spotify sp_dc (${SPDC_HINT})` });
|
|
262
|
+
if (isCancel(dc) || !dc)
|
|
263
|
+
continue;
|
|
264
|
+
cfg.spotifyDc = String(dc);
|
|
265
|
+
await saveCredentials({ spotifyDc: cfg.spotifyDc });
|
|
266
|
+
}
|
|
267
|
+
console.log('✓ Spotify sp_dc tersimpan.');
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
if (choice === 'autofetch') {
|
|
271
|
+
warnLogin('Deezer & Spotify');
|
|
272
|
+
const filled = await tryAutoFillCredentials(cfg, true);
|
|
273
|
+
console.log(filled.length
|
|
274
|
+
? `✓ Terambil dari browser: ${filled.join(', ')}.`
|
|
275
|
+
: '✗ Tidak ada kredensial yang bisa diambil (browser belum login / keychain ditolak).');
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
if (choice === 'output') {
|
|
279
|
+
const output = await text({ message: 'Path file laporan', initialValue: options.output });
|
|
280
|
+
if (isCancel(output) || !output)
|
|
281
|
+
continue;
|
|
282
|
+
options.output = String(output);
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
outro(`Selesai. Laporan: ${options.output}`);
|
|
287
|
+
}
|
|
288
|
+
async function main() {
|
|
289
|
+
const options = parseArgs(args());
|
|
290
|
+
if (options.help) {
|
|
291
|
+
console.log(HELP_TEXT);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
if (!options.urls.length) {
|
|
295
|
+
await runInteractive(options);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
const cfg = await loadConfig(true);
|
|
299
|
+
const deezer = new DeezerClient(cfg.deezerArl);
|
|
300
|
+
await deezer.getMe();
|
|
301
|
+
const converter = new Converter(deezer, await anonymousToken(), options.output);
|
|
302
|
+
for (const raw of options.urls) {
|
|
303
|
+
const id = parsePlaylistId(raw);
|
|
304
|
+
const name = await playlistName(id);
|
|
305
|
+
const result = await converter.matchPlaylist({ name, uri: id }, options.dryRun);
|
|
306
|
+
if (!options.dryRun && result.matchedIds.length)
|
|
307
|
+
await converter.writePlaylist(name, result.matchedIds);
|
|
308
|
+
}
|
|
309
|
+
await converter.writeReport();
|
|
310
|
+
outro(`Report saved to ${options.output}`);
|
|
311
|
+
}
|
|
312
|
+
main().catch((error) => { console.error(`Error: ${error instanceof Error ? error.message : error}`); process.exitCode = 1; });
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { homedir, platform } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import 'dotenv/config';
|
|
5
|
+
import { fetchBrowserCredentials } from './browser.js';
|
|
6
|
+
export function configDir() {
|
|
7
|
+
if (platform() === 'win32')
|
|
8
|
+
return join(process.env.APPDATA ?? join(homedir(), 'AppData', 'Roaming'), 'plx');
|
|
9
|
+
return join(process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config'), 'plx');
|
|
10
|
+
}
|
|
11
|
+
const file = () => join(configDir(), 'credentials.json');
|
|
12
|
+
export async function loadConfig(requireDeezer = true) {
|
|
13
|
+
let stored = {};
|
|
14
|
+
try {
|
|
15
|
+
stored = JSON.parse(await readFile(file(), 'utf8'));
|
|
16
|
+
}
|
|
17
|
+
catch { /* first run */ }
|
|
18
|
+
const cfg = { deezerArl: process.env.DEEZER_ARL ?? stored.deezerArl ?? '', spotifyDc: process.env.SPOTIFY_DC ?? stored.spotifyDc ?? '', recentUrls: stored.recentUrls ?? [] };
|
|
19
|
+
if (requireDeezer && !cfg.deezerArl)
|
|
20
|
+
throw new Error('Konfigurasi kurang: DEEZER_ARL');
|
|
21
|
+
return cfg;
|
|
22
|
+
}
|
|
23
|
+
export async function saveRecentUrl(url) {
|
|
24
|
+
const stored = await loadStored();
|
|
25
|
+
const list = Array.isArray(stored.recentUrls) ? stored.recentUrls : [];
|
|
26
|
+
const next = [url, ...list.filter((u) => u !== url)].slice(0, 10);
|
|
27
|
+
await saveCredentials({ recentUrls: next });
|
|
28
|
+
}
|
|
29
|
+
export async function saveCredentials(values) {
|
|
30
|
+
await mkdir(configDir(), { recursive: true, mode: 0o700 });
|
|
31
|
+
const current = await loadStored();
|
|
32
|
+
await writeFile(file(), JSON.stringify({ ...current, ...values }, null, 2), { mode: 0o600 });
|
|
33
|
+
try {
|
|
34
|
+
await chmod(file(), 0o600);
|
|
35
|
+
}
|
|
36
|
+
catch { /* Windows */ }
|
|
37
|
+
}
|
|
38
|
+
async function loadStored() { try {
|
|
39
|
+
return JSON.parse(await readFile(file(), 'utf8'));
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return {};
|
|
43
|
+
} }
|
|
44
|
+
// Semi-auto: fill credentials from a logged-in browser (pops the macOS Keychain
|
|
45
|
+
// "Allow" dialog on first use), persist what's found, and return the field names
|
|
46
|
+
// filled. By default only fills *missing* fields (env vars and stored credentials
|
|
47
|
+
// win); pass overwrite=true to refetch and replace whatever the browser has. Never
|
|
48
|
+
// throws — a denied keychain / TCC block just fills nothing, and the caller falls
|
|
49
|
+
// back to the manual paste prompt.
|
|
50
|
+
export async function tryAutoFillCredentials(cfg, overwrite = false) {
|
|
51
|
+
const filled = [];
|
|
52
|
+
const want = overwrite || !cfg.deezerArl || !cfg.spotifyDc;
|
|
53
|
+
if (!want)
|
|
54
|
+
return filled;
|
|
55
|
+
const found = fetchBrowserCredentials();
|
|
56
|
+
if ((overwrite || !cfg.deezerArl) && found.arl) {
|
|
57
|
+
cfg.deezerArl = found.arl;
|
|
58
|
+
filled.push('deezerArl');
|
|
59
|
+
}
|
|
60
|
+
if ((overwrite || !cfg.spotifyDc) && found.spDc) {
|
|
61
|
+
cfg.spotifyDc = found.spDc;
|
|
62
|
+
filled.push('spotifyDc');
|
|
63
|
+
}
|
|
64
|
+
if (filled.length)
|
|
65
|
+
await saveCredentials({ deezerArl: cfg.deezerArl, spotifyDc: cfg.spotifyDc });
|
|
66
|
+
return filled;
|
|
67
|
+
}
|