plx-converter 0.1.2 → 0.1.4

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
@@ -6,6 +6,8 @@ Converts a playlist from any of the three services into any other — six direct
6
6
 
7
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
+ <img src="https://raw.githubusercontent.com/samlehoy/plx/master/screenshots/cli-interactive-menu.png" width="640" alt="plx interactive menu: Convert a playlist, Credentials 3/3 saved, Auto-fetch credentials from browser, Report, Quit" />
10
+
9
11
  ## Install
10
12
 
11
13
  ```bash
@@ -16,12 +18,18 @@ Requires Node.js ≥ 22.
16
18
 
17
19
  ## Usage
18
20
 
21
+ Run `plx` with no arguments for the interactive menu, which walks you through source, target, and credentials:
22
+
23
+ <img src="https://raw.githubusercontent.com/samlehoy/plx/master/screenshots/cli-provider-selection.png" width="640" alt="Interactive prompt picking a source provider between Spotify, Deezer, and YouTube Music" />
24
+
25
+ Or name everything up front:
26
+
19
27
  ```bash
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
28
+ plx # interactive menu
29
+ plx --url "https://open.spotify.com/playlist/ID" --to deezer
30
+ plx --url "https://www.deezer.com/playlist/ID" --to ytmusic
31
+ plx --url "https://music.youtube.com/playlist?list=ID" --to spotify
32
+ plx --url "<LINK>" --to deezer --dry-run # preview matches without writing
25
33
  ```
26
34
 
27
35
  You name the **target**; the source is inferred from the link, since the link already says which
@@ -30,20 +38,61 @@ service it belongs to. plx never guesses a destination — omitting `--to` is an
30
38
  ```text
31
39
  Options:
32
40
  -u, --url <LINK> playlist URL/URI/ID (repeatable)
33
- -t, --to <PROVIDER> target provider (required)
41
+ -t, --to <PROVIDER> target provider: spotify | deezer | ytmusic (required)
34
42
  -o, --output <file> CSV report path (default: conversion_report.csv)
35
43
  -d, --dry-run match only, don't write
36
44
  -h, --help show help
37
45
  ```
38
46
 
47
+ Every run writes a CSV report — one row per track, with the match method and target id, so you can
48
+ see exactly what moved and what didn't.
49
+
39
50
  ## Credentials
40
51
 
41
- | Cookie | Env var | Needed for |
42
- |---|---|---|
43
- | Deezer `arl` | `DEEZER_ARL` | writing to Deezer |
44
- | Spotify `sp_dc` | `SPOTIFY_DC` | writing to a Spotify target |
52
+ One browser session credential per provider. All are optional — you only need the ones your chosen
53
+ direction actually touches.
54
+
55
+ | Provider | Credential | Env var | Needed for |
56
+ |---|---|---|---|
57
+ | Spotify | `sp_dc` cookie | `SPOTIFY_DC` | writing to Spotify, or reading your *private* playlists |
58
+ | Deezer | `arl` cookie | `DEEZER_ARL` | reading **or** writing a Deezer playlist |
59
+ | YouTube Music | whole `cookie:` header | `YTMUSIC_COOKIE` | reading **or** writing a YouTube Music playlist |
60
+
61
+ Reading a **public Spotify** playlist needs no credentials at all — which is what makes a
62
+ credential-free `--dry-run` possible.
63
+
64
+ **YouTube Music takes a whole cookie header, not a single cookie.** Google splits a session across a
65
+ dozen-odd cookies, so copy the entire `cookie:` value: on [music.youtube.com](https://music.youtube.com),
66
+ DevTools → **Network** → any request → **Request Headers** → `cookie:`. Google sessions also expire
67
+ faster than the other two, so expect to redo this occasionally.
68
+
69
+ ### Auto-fetch from your browser
70
+
71
+ Rather than copying cookies by hand, plx can read them straight from a logged-in browser. Pick
72
+ **"Auto-fetch credentials (from browser)"** from the menu, or let it run automatically when a
73
+ credential is missing. Manual paste stays the fallback everywhere.
74
+
75
+ | Browser | macOS | Windows | Linux |
76
+ |---|---|---|---|
77
+ | Firefox | ✅ | ✅ | ✅ |
78
+ | Chromium family (Brave, Chrome, Edge, Chromium, Vivaldi, Opera) | ✅ | — | — |
79
+ | Safari | ✅ — needs Full Disk Access | — | — |
80
+
81
+ On macOS the Chromium and Safari backends ask you to accept the Keychain prompt once. Firefox needs
82
+ no prompt on any OS, since it stores cookie values unencrypted.
83
+
84
+ Chromium on Windows isn't a gap waiting to be filled: since Chrome 127 the cookie key is sealed
85
+ behind App-Bound Encryption that only a SYSTEM-level process can unwrap, and every published
86
+ workaround is the sort of thing malware does. On Windows, use Firefox or paste manually.
87
+
88
+ Set credentials in `.env`, export them in your shell, or paste when prompted. Full setup:
89
+ [`docs/CONFIG.md`](docs/CONFIG.md).
90
+
91
+ ## Website
92
+
93
+ [plx.sh](https://plx.sh) — overview, matching rules, and install steps.
45
94
 
46
- Set in `.env`, or paste when prompted. Reading a Spotify playlist needs **no credentials**. Full setup: [`docs/CONFIG.md`](docs/CONFIG.md).
95
+ <img src="https://raw.githubusercontent.com/samlehoy/plx/master/screenshots/website-landing.png" width="640" alt="plx landing page: Move your playlist. No dev account." />
47
96
 
48
97
  ## Development
49
98
 
package/dist/browser.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createDecipheriv, pbkdf2Sync } from 'node:crypto';
2
2
  import { execFileSync } from 'node:child_process';
3
- import { existsSync, readFileSync } from 'node:fs';
4
- import { homedir, platform } from 'node:os';
3
+ import { copyFileSync, existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync } from 'node:fs';
4
+ import { homedir, platform, tmpdir } from 'node:os';
5
5
  import { join } from 'node:path';
6
6
  import { DatabaseSync } from 'node:sqlite';
7
7
  import { YTMUSIC_COOKIES, cookieHeader } from './ytmusic.js';
@@ -156,17 +156,77 @@ function readSafari() {
156
156
  }
157
157
  return {};
158
158
  }
159
+ // Firefox — the one backend that is not macOS-only. `cookies.sqlite` stores values in **plaintext**:
160
+ // no keychain, no DPAPI, and none of the App-Bound Encryption that closed the Chromium family off on
161
+ // Windows (Chrome 127+ wraps the cookie key in SYSTEM-DPAPI, so only a SYSTEM process can unwrap it,
162
+ // and every published bypass is malware behaviour). `key4.db` is commonly assumed to encrypt these
163
+ // too — it does not; it protects saved *logins*.
164
+ const FIREFOX_ROOTS = {
165
+ win32: join(process.env.APPDATA ?? join(homedir(), 'AppData', 'Roaming'), 'Mozilla', 'Firefox'),
166
+ darwin: join(homedir(), 'Library', 'Application Support', 'Firefox'),
167
+ linux: join(homedir(), '.mozilla', 'firefox'),
168
+ };
169
+ // Every profile holding a cookie DB, most-recently-written first. `profiles.ini` names the default
170
+ // one, but reading it needs an INI parser to answer worse than an mtime sort already does: someone
171
+ // with several profiles is logged in on the one they actually use, which is the one just written to.
172
+ export function firefoxProfiles(root = FIREFOX_ROOTS[platform()] ?? FIREFOX_ROOTS.linux) {
173
+ const nested = join(root, 'Profiles');
174
+ const base = existsSync(nested) ? nested : root; // some Linux packagings drop the Profiles/ level
175
+ try {
176
+ return readdirSync(base, { withFileTypes: true })
177
+ .filter((entry) => entry.isDirectory())
178
+ .map((entry) => join(base, entry.name, 'cookies.sqlite'))
179
+ .filter(existsSync)
180
+ .sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs);
181
+ }
182
+ catch {
183
+ return []; // no Firefox installed, or an unreadable profile root
184
+ }
185
+ }
186
+ // Firefox keeps the DB in WAL mode and holds it open while running — on Windows that lock is
187
+ // enforced, so reading the file in place fails outright. Copy it to a temp dir and read the copy.
188
+ // The `-wal` sidecar comes along because a cookie set this session may not be checkpointed into the
189
+ // main file yet, and the copy is opened writable precisely so SQLite is allowed to replay it.
190
+ export function readFirefox(dbPath) {
191
+ let dir = null;
192
+ try {
193
+ dir = mkdtempSync(join(tmpdir(), 'plx-ff-'));
194
+ const copy = join(dir, 'cookies.sqlite');
195
+ copyFileSync(dbPath, copy);
196
+ if (existsSync(`${dbPath}-wal`))
197
+ copyFileSync(`${dbPath}-wal`, `${copy}-wal`);
198
+ const db = new DatabaseSync(copy);
199
+ try {
200
+ const query = `SELECT name, host, value FROM moz_cookies WHERE name IN (${COOKIE_NAMES.map(() => '?').join(',')})`;
201
+ // `host` is stored dot-prefixed for domain cookies (`.youtube.com`); hostRank's endsWith arm
202
+ // already accepts that, so it is passed through as-is.
203
+ return collect(db.prepare(query).all(...COOKIE_NAMES));
204
+ }
205
+ finally {
206
+ db.close();
207
+ }
208
+ }
209
+ catch {
210
+ return {}; // unreadable profile / changed schema — skip it
211
+ }
212
+ finally {
213
+ if (dir)
214
+ rmSync(dir, { recursive: true, force: true });
215
+ }
216
+ }
159
217
  // Auto-retrieve credentials from the first logged-in browser that has them, keyed by provider —
160
218
  // the parsers above speak in cookie names, and this is where that becomes one opaque string per
161
219
  // provider for the config layer to store. Never throws: a missing browser, locked DB, denied
162
220
  // keychain, or TCC block just yields {}. Manual paste stays the fallback.
163
221
  export function fetchBrowserCredentials() {
164
- if (platform() !== 'darwin')
165
- return {};
222
+ // The keychain-backed backends stay gated on macOS; Firefox runs everywhere and is all that
223
+ // Windows and Linux get. macOS keeps its original order, so nothing changes for anyone who
224
+ // already relied on this — Firefox only fills what the others left empty.
225
+ const keychainBacked = platform() === 'darwin' ? [...CHROMIUM_BROWSERS.map(readChromium), readSafari()] : [];
166
226
  const credentials = {};
167
227
  // Every backend is read rather than stopping at the first: a user may be logged into Deezer in one
168
228
  // browser and YouTube Music in another.
169
- for (const found of [...CHROMIUM_BROWSERS.map(readChromium), readSafari()]) {
229
+ for (const found of [...keychainBacked, ...firefoxProfiles().map(readFirefox)]) {
170
230
  if (!credentials.deezer && found.arl)
171
231
  credentials.deezer = found.arl;
172
232
  if (!credentials.spotify && found.sp_dc)
package/dist/config.js CHANGED
@@ -10,10 +10,15 @@ const ENV_VAR = { deezer: 'DEEZER_ARL', spotify: 'SPOTIFY_DC', ytmusic: 'YTMUSIC
10
10
  // migrated (see #1): they are ignored, dropped on the next write, and the user re-fetches or
11
11
  // re-pastes — which costs one browser dialog.
12
12
  const LEGACY_KEYS = ['deezerArl', 'spotifyDc'];
13
+ // XDG_CONFIG_HOME wins on every platform, Windows included: it is the explicit override, and a
14
+ // Windows-only branch that ignored it left the tests sharing one real %APPDATA% dir instead of a
15
+ // temp dir each, so state leaked between them.
13
16
  export function configDir() {
17
+ if (process.env.XDG_CONFIG_HOME)
18
+ return join(process.env.XDG_CONFIG_HOME, 'plx');
14
19
  if (platform() === 'win32')
15
20
  return join(process.env.APPDATA ?? join(homedir(), 'AppData', 'Roaming'), 'plx');
16
- return join(process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config'), 'plx');
21
+ return join(homedir(), '.config', 'plx');
17
22
  }
18
23
  const file = () => join(configDir(), 'credentials.json');
19
24
  async function loadStored() { try {
package/package.json CHANGED
@@ -1,8 +1,13 @@
1
1
  {
2
2
  "name": "plx-converter",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Convert playlists between Spotify, Deezer, and YouTube Music using your own browser session",
5
5
  "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/samlehoy/plx.git"
9
+ },
10
+ "homepage": "https://plx.sh",
6
11
  "type": "module",
7
12
  "bin": {
8
13
  "plx": "dist/cli.js"
@@ -18,6 +23,7 @@
18
23
  "scripts": {
19
24
  "dev": "tsx src/cli.ts",
20
25
  "build": "tsc",
26
+ "prepublishOnly": "npm run build",
21
27
  "typecheck": "tsc --noEmit",
22
28
  "test": "vitest run",
23
29
  "lint": "eslint .",
@@ -26,7 +32,6 @@
26
32
  "dependencies": {
27
33
  "@clack/prompts": "^0.10.1",
28
34
  "dotenv": "^16.4.7",
29
- "plx-converter": "^0.1.1",
30
35
  "zod": "^3.24.2"
31
36
  },
32
37
  "devDependencies": {