plx-converter 0.1.3 → 0.1.5
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 +17 -6
- package/dist/browser.js +80 -5
- package/dist/cli.js +58 -21
- package/dist/config.js +13 -6
- package/dist/registry.js +3 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -66,13 +66,24 @@ dozen-odd cookies, so copy the entire `cookie:` value: on [music.youtube.com](ht
|
|
|
66
66
|
DevTools → **Network** → any request → **Request Headers** → `cookie:`. Google sessions also expire
|
|
67
67
|
faster than the other two, so expect to redo this occasionally.
|
|
68
68
|
|
|
69
|
-
### Auto-fetch from your browser
|
|
69
|
+
### Auto-fetch from your browser
|
|
70
70
|
|
|
71
|
-
Rather than copying cookies by hand, plx can read them straight from a logged-in browser
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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.
|
|
76
87
|
|
|
77
88
|
Set credentials in `.env`, export them in your shell, or paste when prompted. Full setup:
|
|
78
89
|
[`docs/CONFIG.md`](docs/CONFIG.md).
|
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
|
-
|
|
165
|
-
|
|
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 [...
|
|
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)
|
|
@@ -183,3 +243,18 @@ export function fetchBrowserCredentials() {
|
|
|
183
243
|
}
|
|
184
244
|
return credentials;
|
|
185
245
|
}
|
|
246
|
+
// Why auto-fetch came back empty, said in the terms of the platform the user is actually on. macOS
|
|
247
|
+
// is the only place a denied Keychain prompt can be the answer; everywhere else Firefox is the only
|
|
248
|
+
// backend, so the useful thing to report is which half of that is missing. This says *why* only —
|
|
249
|
+
// the caller owns pointing at the manual paste, so that instruction lives in one place. Both inputs
|
|
250
|
+
// are injected so the branches are testable without a browser or a second OS.
|
|
251
|
+
export function autoFetchHint(os = platform(), firefoxFound = firefoxProfiles().length > 0) {
|
|
252
|
+
if (os === 'darwin')
|
|
253
|
+
return 'No browser was logged in, or the macOS Keychain prompt was denied.';
|
|
254
|
+
if (firefoxFound)
|
|
255
|
+
return 'A Firefox profile was found but held none of the cookies — log in at the provider in Firefox and retry.';
|
|
256
|
+
const why = os === 'win32'
|
|
257
|
+
? 'Chrome/Edge cookies are sealed by App-Bound Encryption that only a SYSTEM process can unwrap'
|
|
258
|
+
: 'the Chromium keyring backends are not implemented here';
|
|
259
|
+
return `Auto-fetch supports Firefox only on this OS and no Firefox profile was found — ${why}.`;
|
|
260
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -1,36 +1,67 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { confirm, intro, isCancel, outro, password, select, text } from '@clack/prompts';
|
|
3
|
+
import { autoFetchHint } from './browser.js';
|
|
3
4
|
import { credential, loadConfig, saveCredential, saveRecentUrl, tryAutoFillCredentials } from './config.js';
|
|
4
5
|
import { Conversion } from './conversion.js';
|
|
5
6
|
import { PROVIDERS, inferSource, providerFor, targetKeys } from './registry.js';
|
|
6
7
|
import { HELP_TEXT, parseArgs } from './args.js';
|
|
7
8
|
function args() { return process.argv.slice(2); }
|
|
8
9
|
// --- Credentials -----------------------------------------------------------
|
|
9
|
-
//
|
|
10
|
-
//
|
|
10
|
+
// Why the browser read came back with nothing for this provider, and what to do instead. Every
|
|
11
|
+
// auto-fetch failure says this, so the user never sees a bare "could not be fetched".
|
|
12
|
+
function explainAutoFetchFailure(spec) {
|
|
13
|
+
const what = spec ? `${spec.label} cookies` : 'No credentials';
|
|
14
|
+
console.log(`✗ ${what} could not be read from your browser. ${autoFetchHint()}`);
|
|
15
|
+
}
|
|
16
|
+
// One manual paste, with where to find the value and what a correct one looks like.
|
|
17
|
+
async function promptCredential(cfg, spec) {
|
|
18
|
+
console.log(`ℹ️ Where to find it: ${spec.credentialHint}`);
|
|
19
|
+
console.log(` Looks like: ${spec.credentialExample}`);
|
|
20
|
+
const entered = await password({ message: `Paste ${spec.credentialLabel}` });
|
|
21
|
+
if (isCancel(entered) || !entered)
|
|
22
|
+
return false;
|
|
23
|
+
await saveCredential(cfg, spec.key, String(entered).trim()); // pasted values carry stray whitespace
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
// Fill one provider's credential mid-conversion: browser auto-fill first, then a manual paste.
|
|
27
|
+
// Persists what it gets. Returns the credential, or null when the user cancels.
|
|
11
28
|
async function ensureCredential(cfg, spec) {
|
|
12
29
|
if (credential(cfg, spec.key))
|
|
13
30
|
return credential(cfg, spec.key);
|
|
14
31
|
const filled = await tryAutoFillCredentials(cfg);
|
|
15
32
|
if (filled.includes(spec.key))
|
|
16
33
|
return credential(cfg, spec.key);
|
|
17
|
-
|
|
18
|
-
if (
|
|
34
|
+
explainAutoFetchFailure(spec);
|
|
35
|
+
if (!(await promptCredential(cfg, spec)))
|
|
19
36
|
return null;
|
|
20
|
-
await saveCredential(cfg, spec.key, String(entered));
|
|
21
37
|
return credential(cfg, spec.key);
|
|
22
38
|
}
|
|
23
|
-
// Settings entry for one provider's credential
|
|
24
|
-
//
|
|
39
|
+
// Settings entry for one provider's credential. The two ways in are offered as a choice rather than
|
|
40
|
+
// auto-first-then-manual, so someone who knows the browser read will fail (or who wants to replace a
|
|
41
|
+
// saved-but-expired value with a cookie of their own) can go straight to the paste. Auto-fetch here
|
|
42
|
+
// overwrites, scoped to this provider, so an expired credential is actually replaced.
|
|
43
|
+
// Proves whatever lands against the live service before saying it worked.
|
|
25
44
|
async function reenterCredential(cfg, spec) {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
45
|
+
const how = await select({
|
|
46
|
+
message: `${spec.credentialLabel}`,
|
|
47
|
+
options: [
|
|
48
|
+
{ value: 'auto', label: 'Auto-fetch from browser' },
|
|
49
|
+
{ value: 'manual', label: 'Paste cookies manually' },
|
|
50
|
+
],
|
|
51
|
+
});
|
|
52
|
+
if (isCancel(how))
|
|
53
|
+
return;
|
|
54
|
+
if (how === 'auto') {
|
|
55
|
+
warnLogin(spec.loginSite);
|
|
56
|
+
if ((await tryAutoFillCredentials(cfg, true, spec.key)).includes(spec.key)) {
|
|
57
|
+
await report(cfg, spec);
|
|
31
58
|
return;
|
|
32
|
-
|
|
59
|
+
}
|
|
60
|
+
explainAutoFetchFailure(spec);
|
|
61
|
+
console.log(' Paste it manually instead:');
|
|
33
62
|
}
|
|
63
|
+
if (!(await promptCredential(cfg, spec)))
|
|
64
|
+
return;
|
|
34
65
|
await report(cfg, spec);
|
|
35
66
|
}
|
|
36
67
|
// Check one provider's stored credential against the live service and say what happened.
|
|
@@ -241,16 +272,22 @@ async function runInteractive(options) {
|
|
|
241
272
|
if (choice === 'autofetch') {
|
|
242
273
|
warnLogin(PROVIDERS.map((p) => p.label).join(' & '));
|
|
243
274
|
const filled = await tryAutoFillCredentials(cfg, true);
|
|
244
|
-
if (
|
|
245
|
-
console.log(
|
|
246
|
-
|
|
275
|
+
if (filled.length) {
|
|
276
|
+
console.log(`Fetched from browser: ${filled.join(', ')}. Checking…`);
|
|
277
|
+
for (const key of filled) {
|
|
278
|
+
const spec = providerFor(key);
|
|
279
|
+
if (spec)
|
|
280
|
+
await report(cfg, spec);
|
|
281
|
+
}
|
|
247
282
|
}
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
const spec = providerFor(key);
|
|
251
|
-
if (spec)
|
|
252
|
-
await report(cfg, spec);
|
|
283
|
+
else {
|
|
284
|
+
explainAutoFetchFailure();
|
|
253
285
|
}
|
|
286
|
+
// Whatever auto-fetch managed, the manual path is what closes the gap — name it and the menu
|
|
287
|
+
// entry that leads to it, rather than leaving the user at a dead end.
|
|
288
|
+
const missing = PROVIDERS.filter((p) => !credential(cfg, p.key));
|
|
289
|
+
if (missing.length)
|
|
290
|
+
console.log(`→ Still missing: ${missing.map((p) => p.label).join(', ')}. Add them from "Credentials: ${saved}/${PROVIDERS.length} saved" → pick the provider → "Paste cookies manually".`);
|
|
254
291
|
continue;
|
|
255
292
|
}
|
|
256
293
|
if (choice === 'output') {
|
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(
|
|
21
|
+
return join(homedir(), '.config', 'plx');
|
|
17
22
|
}
|
|
18
23
|
const file = () => join(configDir(), 'credentials.json');
|
|
19
24
|
async function loadStored() { try {
|
|
@@ -58,14 +63,16 @@ async function write(patch) {
|
|
|
58
63
|
// Semi-auto: fill credentials from a logged-in browser (pops the macOS Keychain "Allow" dialog on
|
|
59
64
|
// first use), persist what's found, and return the provider names filled. By default only fills
|
|
60
65
|
// providers with no credential yet (env vars and stored credentials win); pass overwrite=true to
|
|
61
|
-
// refetch and replace
|
|
62
|
-
//
|
|
63
|
-
|
|
64
|
-
|
|
66
|
+
// refetch and replace, and `only` to confine that to one provider — the Credentials menu refetches
|
|
67
|
+
// the provider being edited without quietly replacing the other two. Never throws — a denied
|
|
68
|
+
// keychain / TCC block just fills nothing, and the caller falls back to the manual paste prompt.
|
|
69
|
+
export async function tryAutoFillCredentials(cfg, overwrite = false, only) {
|
|
70
|
+
const wanted = only ? [only] : Object.keys(ENV_VAR);
|
|
71
|
+
if (!overwrite && wanted.every((provider) => cfg.credentials[provider]))
|
|
65
72
|
return [];
|
|
66
73
|
const filled = [];
|
|
67
74
|
for (const [provider, value] of Object.entries(fetchBrowserCredentials())) {
|
|
68
|
-
if (!value || (!overwrite && cfg.credentials[provider]))
|
|
75
|
+
if (!value || !wanted.includes(provider) || (!overwrite && cfg.credentials[provider]))
|
|
69
76
|
continue;
|
|
70
77
|
cfg.credentials[provider] = value;
|
|
71
78
|
filled.push(provider);
|
package/dist/registry.js
CHANGED
|
@@ -8,6 +8,7 @@ const spotify = {
|
|
|
8
8
|
matches: ['open.spotify.com', 'spotify:playlist:'],
|
|
9
9
|
credentialLabel: 'Spotify sp_dc',
|
|
10
10
|
credentialHint: 'login open.spotify.com → F12 → Application → Cookies → sp_dc → copy value',
|
|
11
|
+
credentialExample: 'AQBv7pC3...9Kk (the value only, ~150 chars, no "sp_dc=" prefix)',
|
|
11
12
|
loginSite: 'Spotify (open.spotify.com)',
|
|
12
13
|
anonymousRead: true,
|
|
13
14
|
parseRef: async (ref) => parsePlaylistId(ref),
|
|
@@ -23,6 +24,7 @@ const deezer = {
|
|
|
23
24
|
matches: ['deezer.com', 'link.deezer.com'],
|
|
24
25
|
credentialLabel: 'Deezer ARL',
|
|
25
26
|
credentialHint: 'login deezer.com → F12 → Application → Cookies → arl → copy value',
|
|
27
|
+
credentialExample: '3f9a1c...e7b2 (the value only, 192 hex chars, no "arl=" prefix)',
|
|
26
28
|
loginSite: 'Deezer (deezer.com)',
|
|
27
29
|
// Deezer's search is public, but reading a *playlist* goes through the authenticated GraphQL API.
|
|
28
30
|
anonymousRead: false,
|
|
@@ -45,6 +47,7 @@ const ytmusic = {
|
|
|
45
47
|
matches: ['music.youtube.com', 'youtube.com'],
|
|
46
48
|
credentialLabel: 'YouTube Music cookies',
|
|
47
49
|
credentialHint: 'login music.youtube.com → F12 → Network → any request → Request Headers → copy the whole "cookie:" value',
|
|
50
|
+
credentialExample: 'SAPISID=xxx; __Secure-3PAPISID=xxx; SID=xxx; __Secure-3PSID=xxx; __Secure-3PSIDTS=xxx; HSID=xxx; SSID=xxx; APISID=xxx (one line, every cookie, SAPISID is required)',
|
|
48
51
|
loginSite: 'YouTube Music (music.youtube.com)',
|
|
49
52
|
anonymousRead: false,
|
|
50
53
|
parseRef: async (ref) => parsePlaylistRef(ref),
|