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.
package/README.md CHANGED
@@ -1,15 +1,15 @@
1
1
  # plx
2
2
 
3
- Spotify ⇄ Deezer playlist converter CLI.
3
+ Playlist converter CLI for Spotify, Deezer, and YouTube Music.
4
4
 
5
- Converts playlists in both directions — reads Spotify **without an account, Premium, or OAuth**; writes via your session cookies.
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 Deezer.
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 # 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
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 <URL|ID> playlist URL/URI/ID (repeatable)
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 (reverse flow) |
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 — Spotify → Deezer playlist converter
1
+ export const HELP_TEXT = `plx — playlist converter
2
2
 
3
3
  Usage:
4
- plx interactive menu
5
- plx --url <URL|ID> [--url ...] convert specific playlist(s) — no Spotify account or OAuth needed
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 <URL|ID> Spotify playlist URL, URI, or ID (repeatable)
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 Deezer playlists
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 --dry-run
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 = (flag, i, inline) => inline !== undefined ? { value: inline, next: i + 1 } : { value: argv[i + 1], next: i + 2 };
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+|\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('--url', i, inline);
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('--output', i, inline);
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
- 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
+ // 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 out = {};
62
- const rows = db.prepare(COOKIE_QUERY).all();
63
- for (const row of rows) {
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
- 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;
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 out = {};
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
- 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;
141
+ found.push({ name, host, value });
120
142
  }
121
143
  }
122
- return out;
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
- // 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.
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 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;
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 result;
184
+ return credentials;
161
185
  }