plx-converter 0.1.4 → 0.1.6
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/dist/browser.js +15 -0
- package/dist/cli.js +58 -21
- package/dist/config.js +7 -5
- package/dist/deezer.js +10 -4
- package/dist/matcher.js +43 -0
- package/dist/registry.js +3 -0
- package/dist/spotify.js +3 -2
- package/dist/ytmusic-provider.js +7 -2
- package/package.json +1 -1
package/dist/browser.js
CHANGED
|
@@ -243,3 +243,18 @@ export function fetchBrowserCredentials() {
|
|
|
243
243
|
}
|
|
244
244
|
return credentials;
|
|
245
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
|
@@ -63,14 +63,16 @@ async function write(patch) {
|
|
|
63
63
|
// Semi-auto: fill credentials from a logged-in browser (pops the macOS Keychain "Allow" dialog on
|
|
64
64
|
// first use), persist what's found, and return the provider names filled. By default only fills
|
|
65
65
|
// providers with no credential yet (env vars and stored credentials win); pass overwrite=true to
|
|
66
|
-
// refetch and replace
|
|
67
|
-
//
|
|
68
|
-
|
|
69
|
-
|
|
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]))
|
|
70
72
|
return [];
|
|
71
73
|
const filled = [];
|
|
72
74
|
for (const [provider, value] of Object.entries(fetchBrowserCredentials())) {
|
|
73
|
-
if (!value || (!overwrite && cfg.credentials[provider]))
|
|
75
|
+
if (!value || !wanted.includes(provider) || (!overwrite && cfg.credentials[provider]))
|
|
74
76
|
continue;
|
|
75
77
|
cfg.credentials[provider] = value;
|
|
76
78
|
filled.push(provider);
|
package/dist/deezer.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { fetchJson, retry } from './http.js';
|
|
3
|
-
import { firstArtist, matchCandidates, searchQuery, stripFeat } from './matcher.js';
|
|
3
|
+
import { firstArtist, matchByDuration, matchCandidates, searchQuery, stripFeat } from './matcher.js';
|
|
4
4
|
const gqlSchema = z.object({ errors: z.array(z.object({ message: z.string() })).optional(), data: z.unknown() });
|
|
5
5
|
const API = 'https://api.deezer.com';
|
|
6
6
|
const GQL = 'https://pipe.deezer.com/api';
|
|
@@ -67,16 +67,22 @@ export class DeezerClient {
|
|
|
67
67
|
const toCandidates = (data) => data.data.map((x) => ({ id: String(x.id), title: x.title, artist: x.artist.name, duration: x.duration }));
|
|
68
68
|
const free = `${API}/search?${new URLSearchParams({ q: searchQuery(track.name, track.artist), limit: '5' })}`;
|
|
69
69
|
const data = await retry(async () => fetchJson(free));
|
|
70
|
-
const
|
|
70
|
+
const candidates = toCandidates(data);
|
|
71
|
+
const match = matchCandidates(track.name, track.artist, track.durationMs, candidates);
|
|
71
72
|
if (match)
|
|
72
73
|
return match;
|
|
73
74
|
// Free-text relevance ranks covers/remixes above the real K-pop track; fall back to field syntax.
|
|
74
75
|
const field = `${API}/search?${new URLSearchParams({ q: `track:"${stripFeat(track.name)}" artist:"${firstArtist(track.artist)}"`, limit: '5' })}`;
|
|
75
76
|
const data2 = await retry(async () => fetchJson(field));
|
|
76
|
-
const
|
|
77
|
+
const candidates2 = toCandidates(data2);
|
|
78
|
+
const match2 = matchCandidates(track.name, track.artist, track.durationMs, candidates2);
|
|
77
79
|
if (match2)
|
|
78
80
|
return match2;
|
|
79
|
-
|
|
81
|
+
// The catalog scan still compares titles, so it stays ahead of the title-blind tier.
|
|
82
|
+
const byAlbum = await this.searchByAlbum(track);
|
|
83
|
+
if (byAlbum)
|
|
84
|
+
return byAlbum;
|
|
85
|
+
return matchByDuration(track.name, track.artist, track.durationMs, [...candidates, ...candidates2]);
|
|
80
86
|
}
|
|
81
87
|
// Artist → albums → album tracks, for tracks the search index never surfaces (e.g. "Salty & Sweet").
|
|
82
88
|
async searchByAlbum(track) {
|
package/dist/matcher.js
CHANGED
|
@@ -22,6 +22,49 @@ export function matchCandidates(title, artist, durationMs, candidates) {
|
|
|
22
22
|
}
|
|
23
23
|
return null;
|
|
24
24
|
}
|
|
25
|
+
// A recording that is a *version* of the source rather than the source itself. Tested against the
|
|
26
|
+
// raw title, not the normalized one: normalize() strips parenthesised text, which is exactly where
|
|
27
|
+
// the marker lives ("estrangement(Instrumental)" and "estrangement" normalize to the same string).
|
|
28
|
+
const VERSION_MARKER = /instrumental|karaoke|off ?vocal|acoustic|remix|live|cover|伴奏/i;
|
|
29
|
+
function artistOverlaps(a, b) { return Boolean(a && b) && (a.includes(b) || b.includes(a)); }
|
|
30
|
+
// Last-resort tier: the title is not compared at all. A catalog often carries a track under a
|
|
31
|
+
// translated title — Deezer lists 红线 as "RED LINE", 愛你但說不出口 as "Hard to say" — so a search
|
|
32
|
+
// built from the source title returns the right recording under a name that can never string-match.
|
|
33
|
+
//
|
|
34
|
+
// With the title gone, duration is the only real guard, so it tightens to 2s and a candidate
|
|
35
|
+
// without one is rejected. Artist equality relaxes to containment (Spotify's "LBI利比" is Deezer's
|
|
36
|
+
// "LBI"), and the guard that carries the weight is uniqueness: exactly one surviving candidate, or
|
|
37
|
+
// no match. Version-marked candidates drop out first, unless the source is itself a version.
|
|
38
|
+
//
|
|
39
|
+
// Only ever call this on relevance-ranked search results. Run over a whole artist catalog it will
|
|
40
|
+
// happily pair a source track with whatever unrelated release happens to share its length.
|
|
41
|
+
export function matchByDuration(title, artist, durationMs, candidates) {
|
|
42
|
+
if (durationMs == null)
|
|
43
|
+
return null;
|
|
44
|
+
const na = artistKey(firstArtist(artist));
|
|
45
|
+
if (!na)
|
|
46
|
+
return null;
|
|
47
|
+
const sourceIsVersion = VERSION_MARKER.test(title);
|
|
48
|
+
const seen = new Set();
|
|
49
|
+
const hits = candidates.filter((c) => {
|
|
50
|
+
if (seen.has(c.id))
|
|
51
|
+
return false; // the same recording found by two queries is still one candidate
|
|
52
|
+
if (c.duration == null || !matchDurationMs(durationMs, c.duration, 2000))
|
|
53
|
+
return false;
|
|
54
|
+
if (!artistOverlaps(na, artistKey(c.artist)))
|
|
55
|
+
return false;
|
|
56
|
+
if (!sourceIsVersion && VERSION_MARKER.test(c.title))
|
|
57
|
+
return false;
|
|
58
|
+
seen.add(c.id);
|
|
59
|
+
return true;
|
|
60
|
+
});
|
|
61
|
+
// ponytail: two ids for the same recording (single + album release) read as ambiguous and are
|
|
62
|
+
// rejected. Collapse on normalized title if that turns out to cost real matches.
|
|
63
|
+
if (hits.length !== 1)
|
|
64
|
+
return null;
|
|
65
|
+
const [c] = hits;
|
|
66
|
+
return { id: c.id, title: c.title, artist: c.artist, method: 'duration-only' };
|
|
67
|
+
}
|
|
25
68
|
// Match rules for a music video, used only after the catalog has produced nothing.
|
|
26
69
|
//
|
|
27
70
|
// A music video's stated artist is usually the uploading channel, so the artist rule relaxes from
|
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),
|
package/dist/spotify.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createHmac } from 'node:crypto';
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import { browserHeaders, fetchJson, fetchText } from './http.js';
|
|
4
|
-
import { matchCandidates, searchQuery } from './matcher.js';
|
|
4
|
+
import { matchByDuration, matchCandidates, searchQuery } from './matcher.js';
|
|
5
5
|
const PATHFINDER_URL = 'https://api-partner.spotify.com/pathfinder/v1/query';
|
|
6
6
|
const FETCH_PLAYLIST_SHA = 'a65e12194ed5fc443a1cdebed5fabe33ca5b07b987185d63c72483867ad13cb4';
|
|
7
7
|
export const EMBED_TRACK_LIMIT = 100;
|
|
@@ -221,7 +221,8 @@ export class SpotifyProvider {
|
|
|
221
221
|
readPlaylist(ref) { return readPlaylist(parsePlaylistId(ref), this.token); }
|
|
222
222
|
async search(track) {
|
|
223
223
|
const candidates = await searchTrack(searchQuery(track.name, track.artist), this.token);
|
|
224
|
-
return matchCandidates(track.name, track.artist, track.durationMs, candidates)
|
|
224
|
+
return matchCandidates(track.name, track.artist, track.durationMs, candidates)
|
|
225
|
+
?? matchByDuration(track.name, track.artist, track.durationMs, candidates);
|
|
225
226
|
}
|
|
226
227
|
createPlaylist(title) { return createPlaylist(title, this.token); }
|
|
227
228
|
// Returns the count submitted, not one the service confirmed: addToPlaylist reports transport
|
package/dist/ytmusic-provider.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { matchCandidates, matchVideoCandidates, searchQuery } from './matcher.js';
|
|
1
|
+
import { matchByDuration, matchCandidates, matchVideoCandidates, searchQuery } from './matcher.js';
|
|
2
2
|
import { innertube, isMix, parsePlaylistRef } from './ytmusic.js';
|
|
3
3
|
import { continuationToken, parseItems, playlistTitle, toCandidate } from './ytmusic-parse.js';
|
|
4
4
|
// Search filter params, as the web client sends them: catalog songs, and music videos.
|
|
@@ -57,9 +57,14 @@ export class YtMusicProvider {
|
|
|
57
57
|
// strategy, the matcher owns the rules).
|
|
58
58
|
async search(track) {
|
|
59
59
|
const songs = await this.searchItems(track, SONGS);
|
|
60
|
-
const
|
|
60
|
+
const songCandidates = songs.filter((i) => i.isSong).map(toCandidate);
|
|
61
|
+
const song = matchCandidates(track.name, track.artist, track.durationMs, songCandidates);
|
|
61
62
|
if (song)
|
|
62
63
|
return song;
|
|
64
|
+
// A catalog song under a translated title still beats a video — see matchByDuration.
|
|
65
|
+
const translated = matchByDuration(track.name, track.artist, track.durationMs, songCandidates);
|
|
66
|
+
if (translated)
|
|
67
|
+
return translated;
|
|
63
68
|
const videos = await this.searchItems(track, VIDEOS);
|
|
64
69
|
return matchVideoCandidates(track.name, track.artist, track.durationMs, videos.map(toCandidate));
|
|
65
70
|
}
|