plx-converter 0.1.1 → 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.
- package/README.md +15 -9
- package/dist/args.js +24 -11
- package/dist/browser.js +64 -40
- package/dist/cli.js +244 -231
- package/dist/config.js +46 -37
- package/dist/conversion.js +143 -0
- package/dist/csv.js +1 -1
- package/dist/deezer.js +26 -5
- package/dist/matcher.js +27 -1
- package/dist/registry.js +67 -0
- package/dist/spotify.js +31 -4
- package/dist/ytmusic-parse.js +75 -0
- package/dist/ytmusic-provider.js +156 -0
- package/dist/ytmusic.js +130 -0
- package/package.json +3 -2
- package/dist/converter.js +0 -115
- package/dist/reverse.js +0 -87
package/README.md
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
# plx
|
|
2
2
|
|
|
3
|
-
Spotify
|
|
3
|
+
Playlist converter CLI for Spotify, Deezer, and YouTube Music.
|
|
4
4
|
|
|
5
|
-
Converts
|
|
5
|
+
Converts a playlist from any of the three services into any other — six directions — using your own logged-in browser session. No developer account, no OAuth, no API keys.
|
|
6
6
|
|
|
7
|
-
> ⚠️ **Personal, non-commercial use.** Uses unofficial web endpoints that can change or break without notice. Not affiliated with Spotify or
|
|
7
|
+
> ⚠️ **Personal, non-commercial use.** Uses unofficial web endpoints that can change or break without notice. Not affiliated with Spotify, Deezer, or YouTube.
|
|
8
8
|
|
|
9
9
|
## Install
|
|
10
10
|
|
|
11
11
|
```bash
|
|
12
|
-
npm install -g plx
|
|
12
|
+
npm install -g plx-converter
|
|
13
13
|
```
|
|
14
14
|
|
|
15
15
|
Requires Node.js ≥ 22.
|
|
@@ -17,14 +17,20 @@ Requires Node.js ≥ 22.
|
|
|
17
17
|
## Usage
|
|
18
18
|
|
|
19
19
|
```bash
|
|
20
|
-
plx
|
|
21
|
-
plx --url "https://open.spotify.com/playlist/ID"
|
|
22
|
-
plx --url "
|
|
20
|
+
plx # interactive menu
|
|
21
|
+
plx --url "https://open.spotify.com/playlist/ID" --to deezer
|
|
22
|
+
plx --url "https://www.deezer.com/playlist/ID" --to spotify
|
|
23
|
+
plx --url "https://music.youtube.com/playlist?list=ID" --to deezer
|
|
24
|
+
plx --url "<LINK>" --to deezer --dry-run # preview matches without writing
|
|
23
25
|
```
|
|
24
26
|
|
|
27
|
+
You name the **target**; the source is inferred from the link, since the link already says which
|
|
28
|
+
service it belongs to. plx never guesses a destination — omitting `--to` is an error.
|
|
29
|
+
|
|
25
30
|
```text
|
|
26
31
|
Options:
|
|
27
|
-
-u, --url <
|
|
32
|
+
-u, --url <LINK> playlist URL/URI/ID (repeatable)
|
|
33
|
+
-t, --to <PROVIDER> target provider (required)
|
|
28
34
|
-o, --output <file> CSV report path (default: conversion_report.csv)
|
|
29
35
|
-d, --dry-run match only, don't write
|
|
30
36
|
-h, --help show help
|
|
@@ -35,7 +41,7 @@ Options:
|
|
|
35
41
|
| Cookie | Env var | Needed for |
|
|
36
42
|
|---|---|---|
|
|
37
43
|
| Deezer `arl` | `DEEZER_ARL` | writing to Deezer |
|
|
38
|
-
| Spotify `sp_dc` | `SPOTIFY_DC` | writing to Spotify
|
|
44
|
+
| Spotify `sp_dc` | `SPOTIFY_DC` | writing to a Spotify target |
|
|
39
45
|
|
|
40
46
|
Set in `.env`, or paste when prompted. Reading a Spotify playlist needs **no credentials**. Full setup: [`docs/CONFIG.md`](docs/CONFIG.md).
|
|
41
47
|
|
package/dist/args.js
CHANGED
|
@@ -1,22 +1,28 @@
|
|
|
1
|
-
export const HELP_TEXT = `plx —
|
|
1
|
+
export const HELP_TEXT = `plx — playlist converter
|
|
2
2
|
|
|
3
3
|
Usage:
|
|
4
|
-
plx
|
|
5
|
-
plx --url <
|
|
4
|
+
plx interactive menu
|
|
5
|
+
plx --url <LINK> --to <PROVIDER> convert a playlist into PROVIDER
|
|
6
|
+
|
|
7
|
+
The source provider is inferred from the link, so you only name the target.
|
|
6
8
|
|
|
7
9
|
Options:
|
|
8
|
-
-u, --url <
|
|
10
|
+
-u, --url <LINK> playlist URL, URI, or ID (repeatable)
|
|
11
|
+
-t, --to <PROVIDER> target provider: spotify | deezer | ytmusic (required)
|
|
9
12
|
-o, --output <file> CSV report path (default: conversion_report.csv)
|
|
10
|
-
-d, --dry-run match only; do not create
|
|
13
|
+
-d, --dry-run match only; do not create or modify any playlist
|
|
11
14
|
-h, --help show this help
|
|
12
15
|
|
|
13
16
|
Examples:
|
|
14
|
-
plx --url https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M --
|
|
17
|
+
plx --url https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M --to deezer
|
|
18
|
+
plx --url https://www.deezer.com/playlist/1234567890 --to ytmusic
|
|
19
|
+
plx --url https://music.youtube.com/playlist?list=PL... --to spotify
|
|
20
|
+
plx --url https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M --to deezer --dry-run
|
|
15
21
|
plx
|
|
16
22
|
`;
|
|
17
23
|
export function parseArgs(argv) {
|
|
18
|
-
const options = { urls: [], dryRun: false, output: 'conversion_report.csv', help: false };
|
|
19
|
-
const takeValue = (
|
|
24
|
+
const options = { urls: [], target: null, dryRun: false, output: 'conversion_report.csv', help: false };
|
|
25
|
+
const takeValue = (i, inline) => inline !== undefined ? { value: inline, next: i + 1 } : { value: argv[i + 1], next: i + 2 };
|
|
20
26
|
for (let i = 0; i < argv.length; i += 1) {
|
|
21
27
|
const arg = argv[i];
|
|
22
28
|
if (arg === '--help' || arg === '-h') {
|
|
@@ -27,18 +33,25 @@ export function parseArgs(argv) {
|
|
|
27
33
|
options.dryRun = true;
|
|
28
34
|
continue;
|
|
29
35
|
}
|
|
30
|
-
const flagMatch = arg.match(/^(--\w
|
|
36
|
+
const flagMatch = arg.match(/^(--\w[\w-]*|-\w)=(.+)$/);
|
|
31
37
|
const flag = flagMatch ? flagMatch[1] : arg;
|
|
32
38
|
const inline = flagMatch ? flagMatch[2] : undefined;
|
|
33
39
|
if (flag === '--url' || flag === '-u') {
|
|
34
|
-
const { value, next } = takeValue(
|
|
40
|
+
const { value, next } = takeValue(i, inline);
|
|
35
41
|
if (value)
|
|
36
42
|
options.urls.push(value);
|
|
37
43
|
i = next - 1;
|
|
38
44
|
continue;
|
|
39
45
|
}
|
|
46
|
+
if (flag === '--to' || flag === '-t') {
|
|
47
|
+
const { value, next } = takeValue(i, inline);
|
|
48
|
+
if (value)
|
|
49
|
+
options.target = value;
|
|
50
|
+
i = next - 1;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
40
53
|
if (flag === '--output' || flag === '-o') {
|
|
41
|
-
const { value, next } = takeValue(
|
|
54
|
+
const { value, next } = takeValue(i, inline);
|
|
42
55
|
if (value)
|
|
43
56
|
options.output = value;
|
|
44
57
|
i = next - 1;
|
package/dist/browser.js
CHANGED
|
@@ -4,6 +4,7 @@ import { existsSync, readFileSync } from 'node:fs';
|
|
|
4
4
|
import { homedir, platform } from 'node:os';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
import { DatabaseSync } from 'node:sqlite';
|
|
7
|
+
import { YTMUSIC_COOKIES, cookieHeader } from './ytmusic.js';
|
|
7
8
|
const CHROMIUM_BROWSERS = [
|
|
8
9
|
{ dir: 'BraveSoftware/Brave-Browser', keychain: 'Brave Safe Storage' },
|
|
9
10
|
{ dir: 'Google/Chrome', keychain: 'Chrome Safe Storage' },
|
|
@@ -45,7 +46,39 @@ function keychainPassword(service) {
|
|
|
45
46
|
return null; // keychain denied / no such item — skip this browser
|
|
46
47
|
}
|
|
47
48
|
}
|
|
48
|
-
|
|
49
|
+
// Which cookie names matter, and which hosts they may come from **in priority order**.
|
|
50
|
+
//
|
|
51
|
+
// This ordering is not cosmetic. Google sets `SID`, `SAPISID` and friends on `.google.com` *and*
|
|
52
|
+
// `.youtube.com` with **different values**, so taking whichever the cookie store happened to return
|
|
53
|
+
// first yields a bundle mixing two sessions — which the service reads as logged out. `.youtube.com`
|
|
54
|
+
// wins because that is the host the requests actually go to; `.google.com` is only a fallback for a
|
|
55
|
+
// cookie YouTube did not set itself.
|
|
56
|
+
const WANTED = [
|
|
57
|
+
{ name: 'arl', hosts: ['deezer.com'] },
|
|
58
|
+
{ name: 'sp_dc', hosts: ['spotify.com'] },
|
|
59
|
+
...YTMUSIC_COOKIES.map((name) => ({ name, hosts: ['youtube.com', 'google.com'] })),
|
|
60
|
+
];
|
|
61
|
+
const COOKIE_NAMES = WANTED.map((w) => w.name);
|
|
62
|
+
// How preferred this host is for this cookie name: 0 is best, -1 means "not a host we want it from".
|
|
63
|
+
export function hostRank(name, host) {
|
|
64
|
+
const want = WANTED.find((w) => w.name === name);
|
|
65
|
+
if (!want)
|
|
66
|
+
return -1;
|
|
67
|
+
return want.hosts.findIndex((h) => host === h || host.endsWith(`.${h}`));
|
|
68
|
+
}
|
|
69
|
+
// Keep the best-ranked host for each cookie name, whatever order the store yielded rows in.
|
|
70
|
+
function collect(rows) {
|
|
71
|
+
const best = new Map();
|
|
72
|
+
for (const row of rows) {
|
|
73
|
+
const rank = hostRank(row.name, row.host);
|
|
74
|
+
if (rank < 0)
|
|
75
|
+
continue;
|
|
76
|
+
const current = best.get(row.name);
|
|
77
|
+
if (!current || rank < current.rank)
|
|
78
|
+
best.set(row.name, { rank, value: row.value });
|
|
79
|
+
}
|
|
80
|
+
return Object.fromEntries([...best].map(([name, { value }]) => [name, value]));
|
|
81
|
+
}
|
|
49
82
|
function readChromium(browser) {
|
|
50
83
|
const dbPath = join(homedir(), 'Library', 'Application Support', browser.dir, 'Default', 'Cookies');
|
|
51
84
|
if (!existsSync(dbPath))
|
|
@@ -58,18 +91,12 @@ function readChromium(browser) {
|
|
|
58
91
|
try {
|
|
59
92
|
const meta = db.prepare("SELECT value FROM meta WHERE key = 'version'").get();
|
|
60
93
|
const dbVersion = meta?.value ? Number(meta.value) : 0;
|
|
61
|
-
const
|
|
62
|
-
const rows = db.prepare(
|
|
63
|
-
|
|
94
|
+
const query = `SELECT name, host_key, encrypted_value FROM cookies WHERE name IN (${COOKIE_NAMES.map(() => '?').join(',')})`;
|
|
95
|
+
const rows = db.prepare(query).all(...COOKIE_NAMES);
|
|
96
|
+
return collect(rows.flatMap((row) => {
|
|
64
97
|
const value = decryptCookie(Buffer.from(row.encrypted_value), key, dbVersion >= 24);
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
out.arl = value;
|
|
68
|
-
else if (row.name === 'sp_dc')
|
|
69
|
-
out.spDc = value;
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
return out;
|
|
98
|
+
return value ? [{ name: row.name, host: row.host_key, value }] : [];
|
|
99
|
+
}));
|
|
73
100
|
}
|
|
74
101
|
catch {
|
|
75
102
|
return {}; // locked DB / changed schema — skip this browser
|
|
@@ -98,7 +125,7 @@ export function parseSafariCookies(buf) {
|
|
|
98
125
|
const pageSizes = [];
|
|
99
126
|
for (let i = 0; i < totalPages; i++)
|
|
100
127
|
pageSizes.push(buf.readUInt32BE(8 + i * 4));
|
|
101
|
-
const
|
|
128
|
+
const found = [];
|
|
102
129
|
let base = 8 + totalPages * 4;
|
|
103
130
|
for (let p = 0; p < totalPages; p++) {
|
|
104
131
|
const page = buf.subarray(base, base + pageSizes[p]);
|
|
@@ -111,15 +138,10 @@ export function parseSafariCookies(buf) {
|
|
|
111
138
|
const host = readCStr(page, cookieOffset + page.readUInt32LE(cookieOffset + 16));
|
|
112
139
|
const name = readCStr(page, cookieOffset + page.readUInt32LE(cookieOffset + 20));
|
|
113
140
|
const value = readCStr(page, cookieOffset + page.readUInt32LE(cookieOffset + 28));
|
|
114
|
-
|
|
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;
|
|
141
|
+
found.push({ name, host, value });
|
|
120
142
|
}
|
|
121
143
|
}
|
|
122
|
-
return
|
|
144
|
+
return collect(found);
|
|
123
145
|
}
|
|
124
146
|
function readSafari() {
|
|
125
147
|
for (const path of SAFARI_PATHS) {
|
|
@@ -134,28 +156,30 @@ function readSafari() {
|
|
|
134
156
|
}
|
|
135
157
|
return {};
|
|
136
158
|
}
|
|
137
|
-
// Auto-retrieve credentials from the first logged-in browser that has them
|
|
138
|
-
//
|
|
139
|
-
//
|
|
159
|
+
// Auto-retrieve credentials from the first logged-in browser that has them, keyed by provider —
|
|
160
|
+
// the parsers above speak in cookie names, and this is where that becomes one opaque string per
|
|
161
|
+
// provider for the config layer to store. Never throws: a missing browser, locked DB, denied
|
|
162
|
+
// keychain, or TCC block just yields {}. Manual paste stays the fallback.
|
|
140
163
|
export function fetchBrowserCredentials() {
|
|
141
164
|
if (platform() !== 'darwin')
|
|
142
165
|
return {};
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
if (
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
166
|
+
const credentials = {};
|
|
167
|
+
// Every backend is read rather than stopping at the first: a user may be logged into Deezer in one
|
|
168
|
+
// browser and YouTube Music in another.
|
|
169
|
+
for (const found of [...CHROMIUM_BROWSERS.map(readChromium), readSafari()]) {
|
|
170
|
+
if (!credentials.deezer && found.arl)
|
|
171
|
+
credentials.deezer = found.arl;
|
|
172
|
+
if (!credentials.spotify && found.sp_dc)
|
|
173
|
+
credentials.spotify = found.sp_dc;
|
|
174
|
+
// A cookie bundle is only a session as a *set* from one profile, so it is built per browser and
|
|
175
|
+
// never merged across them — half of one login plus half of another authenticates as neither.
|
|
176
|
+
// Both backends funnel through the one joiner, so "which cookies, joined how" is decided in a
|
|
177
|
+
// single place, and that place is testable without a browser.
|
|
178
|
+
if (!credentials.ytmusic) {
|
|
179
|
+
const header = cookieHeader(found);
|
|
180
|
+
if (header)
|
|
181
|
+
credentials.ytmusic = header;
|
|
182
|
+
}
|
|
159
183
|
}
|
|
160
|
-
return
|
|
184
|
+
return credentials;
|
|
161
185
|
}
|