plx-converter 0.1.5 → 0.1.7

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 CHANGED
@@ -6,12 +6,12 @@ import { join } from 'node:path';
6
6
  import { DatabaseSync } from 'node:sqlite';
7
7
  import { YTMUSIC_COOKIES, cookieHeader } from './ytmusic.js';
8
8
  const CHROMIUM_BROWSERS = [
9
- { dir: 'BraveSoftware/Brave-Browser', keychain: 'Brave Safe Storage' },
10
- { dir: 'Google/Chrome', keychain: 'Chrome Safe Storage' },
11
- { dir: 'Microsoft Edge', keychain: 'Microsoft Edge Safe Storage' },
12
- { dir: 'Chromium', keychain: 'Chromium Safe Storage' },
13
- { dir: 'Vivaldi', keychain: 'Vivaldi Safe Storage' },
14
- { dir: 'com.operasoftware.Opera', keychain: 'Opera Safe Storage' },
9
+ { key: 'brave', label: 'Brave', dir: 'BraveSoftware/Brave-Browser', keychain: 'Brave Safe Storage' },
10
+ { key: 'chrome', label: 'Chrome', dir: 'Google/Chrome', keychain: 'Chrome Safe Storage' },
11
+ { key: 'edge', label: 'Edge', dir: 'Microsoft Edge', keychain: 'Microsoft Edge Safe Storage' },
12
+ { key: 'chromium', label: 'Chromium', dir: 'Chromium', keychain: 'Chromium Safe Storage' },
13
+ { key: 'vivaldi', label: 'Vivaldi', dir: 'Vivaldi', keychain: 'Vivaldi Safe Storage' },
14
+ { key: 'opera', label: 'Opera', dir: 'com.operasoftware.Opera', keychain: 'Opera Safe Storage' },
15
15
  ];
16
16
  // Chromium v10 blob: 3-byte "v10" prefix + AES-128-CBC (IV = 16 × 0x20, PKCS7).
17
17
  // Key = PBKDF2-HMAC-SHA1(keychain pw string, "saltysalt", 1003, 16 bytes).
@@ -79,20 +79,27 @@ function collect(rows) {
79
79
  }
80
80
  return Object.fromEntries([...best].map(([name, { value }]) => [name, value]));
81
81
  }
82
- function readChromium(browser) {
83
- const dbPath = join(homedir(), 'Library', 'Application Support', browser.dir, 'Default', 'Cookies');
82
+ export function readChromiumDb(dbPath, getPassword) {
84
83
  if (!existsSync(dbPath))
85
84
  return {};
86
- const pw = keychainPassword(browser.keychain);
87
- if (pw == null)
88
- return {};
89
- const key = pbkdf2Sync(pw, PBKDF2_SALT, PBKDF2_ITER, PBKDF2_KEYLEN, 'sha1');
90
- const db = new DatabaseSync(dbPath, { readOnly: true });
85
+ let db;
86
+ try {
87
+ db = new DatabaseSync(dbPath, { readOnly: true });
88
+ }
89
+ catch {
90
+ return {}; // TCC-denied or unreadable — skip before touching the Keychain
91
+ }
91
92
  try {
92
93
  const meta = db.prepare("SELECT value FROM meta WHERE key = 'version'").get();
93
94
  const dbVersion = meta?.value ? Number(meta.value) : 0;
94
95
  const query = `SELECT name, host_key, encrypted_value FROM cookies WHERE name IN (${COOKIE_NAMES.map(() => '?').join(',')})`;
95
96
  const rows = db.prepare(query).all(...COOKIE_NAMES);
97
+ if (!rows.length)
98
+ return {};
99
+ const pw = getPassword();
100
+ if (pw == null)
101
+ return {};
102
+ const key = pbkdf2Sync(pw, PBKDF2_SALT, PBKDF2_ITER, PBKDF2_KEYLEN, 'sha1');
96
103
  return collect(rows.flatMap((row) => {
97
104
  const value = decryptCookie(Buffer.from(row.encrypted_value), key, dbVersion >= 24);
98
105
  return value ? [{ name: row.name, host: row.host_key, value }] : [];
@@ -105,6 +112,31 @@ function readChromium(browser) {
105
112
  db.close();
106
113
  }
107
114
  }
115
+ function sortedProfileDbs(base, cookieFile, includeDir = () => true) {
116
+ try {
117
+ return readdirSync(base, { withFileTypes: true })
118
+ .filter((entry) => entry.isDirectory() && includeDir(entry.name))
119
+ .map((entry) => join(base, entry.name, cookieFile))
120
+ .filter(existsSync)
121
+ .sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs);
122
+ }
123
+ catch {
124
+ return [];
125
+ }
126
+ }
127
+ // Every user profile (`Default`, `Profile 1`, `Profile 6`, …) holding a `Cookies` DB inside a
128
+ // Chromium browser's Application Support dir, most-recently-written first.
129
+ export function chromiumProfiles(browserDir) {
130
+ return sortedProfileDbs(browserDir, 'Cookies', (name) => name === 'Default' || name.startsWith('Profile '));
131
+ }
132
+ function readChromium(browser, appSupportDir = join(homedir(), 'Library', 'Application Support')) {
133
+ const profiles = chromiumProfiles(join(appSupportDir, browser.dir));
134
+ if (!profiles.length)
135
+ return [];
136
+ let cachedPw;
137
+ const getPassword = () => (cachedPw === undefined ? (cachedPw = keychainPassword(browser.keychain)) : cachedPw);
138
+ return profiles.map((dbPath) => readChromiumDb(dbPath, getPassword));
139
+ }
108
140
  // Safari `Cookies.binarycookies` layout (mirrors browser_cookie3):
109
141
  // "cook" magic + big-endian page table, then little-endian cookie records with
110
142
  // null-terminated host/name/value strings at cookie-relative offsets.
@@ -172,16 +204,7 @@ const FIREFOX_ROOTS = {
172
204
  export function firefoxProfiles(root = FIREFOX_ROOTS[platform()] ?? FIREFOX_ROOTS.linux) {
173
205
  const nested = join(root, 'Profiles');
174
206
  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
- }
207
+ return sortedProfileDbs(base, 'cookies.sqlite');
185
208
  }
186
209
  // Firefox keeps the DB in WAL mode and holds it open while running — on Windows that lock is
187
210
  // enforced, so reading the file in place fails outright. Copy it to a temp dir and read the copy.
@@ -214,19 +237,43 @@ export function readFirefox(dbPath) {
214
237
  rmSync(dir, { recursive: true, force: true });
215
238
  }
216
239
  }
217
- // Auto-retrieve credentials from the first logged-in browser that has them, keyed by provider —
218
- // the parsers above speak in cookie names, and this is where that becomes one opaque string per
219
- // provider for the config layer to store. Never throws: a missing browser, locked DB, denied
220
- // keychain, or TCC block just yields {}. Manual paste stays the fallback.
221
- export function fetchBrowserCredentials() {
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()] : [];
240
+ // Browsers available to pick on this OS: on macOS, every Chromium browser with at least one profile
241
+ // cookie DB on disk plus Safari (whose container is TCC-gated until tried), plus Firefox when a
242
+ // profile exists. On Windows and Linux, Firefox is the only supported backend.
243
+ export function detectedBrowsers(os = platform(), appSupportDir = join(homedir(), 'Library', 'Application Support'), firefoxRoot) {
244
+ const options = [];
245
+ if (os === 'darwin') {
246
+ for (const browser of CHROMIUM_BROWSERS) {
247
+ if (chromiumProfiles(join(appSupportDir, browser.dir)).length > 0) {
248
+ options.push({ key: browser.key, label: browser.label });
249
+ }
250
+ }
251
+ options.push({ key: 'safari', label: 'Safari' });
252
+ }
253
+ if (firefoxProfiles(firefoxRoot).length > 0) {
254
+ options.push({ key: 'firefox', label: 'Firefox' });
255
+ }
256
+ return options;
257
+ }
258
+ function readBrowserProfiles(browser) {
259
+ if (browser === 'firefox')
260
+ return firefoxProfiles().map(readFirefox);
261
+ if (browser === 'safari')
262
+ return platform() === 'darwin' ? [readSafari()] : [];
263
+ if (browser) {
264
+ const chromium = CHROMIUM_BROWSERS.find((b) => b.key === browser);
265
+ return platform() === 'darwin' && chromium ? readChromium(chromium) : [];
266
+ }
267
+ const keychainBacked = platform() === 'darwin' ? [...CHROMIUM_BROWSERS.flatMap((b) => readChromium(b)), readSafari()] : [];
268
+ return [...keychainBacked, ...firefoxProfiles().map(readFirefox)];
269
+ }
270
+ // Auto-retrieve credentials from the selected browser (or the first logged-in browser when none is
271
+ // named), keyed by provider — the parsers above speak in cookie names, and this is where that
272
+ // becomes one opaque string per provider for the config layer to store. Never throws: a missing
273
+ // browser, locked DB, denied keychain, or TCC block just yields {}. Manual paste stays the fallback.
274
+ export function fetchBrowserCredentials(browser) {
226
275
  const credentials = {};
227
- // Every backend is read rather than stopping at the first: a user may be logged into Deezer in one
228
- // browser and YouTube Music in another.
229
- for (const found of [...keychainBacked, ...firefoxProfiles().map(readFirefox)]) {
276
+ for (const found of readBrowserProfiles(browser)) {
230
277
  if (!credentials.deezer && found.arl)
231
278
  credentials.deezer = found.arl;
232
279
  if (!credentials.spotify && found.sp_dc)
@@ -248,11 +295,20 @@ export function fetchBrowserCredentials() {
248
295
  // backend, so the useful thing to report is which half of that is missing. This says *why* only —
249
296
  // the caller owns pointing at the manual paste, so that instruction lives in one place. Both inputs
250
297
  // 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)
298
+ export function autoFetchHint(os = platform(), firefoxFound = firefoxProfiles().length > 0, browser) {
299
+ if (browser === 'safari') {
300
+ return 'Safari held none of the cookies, or your terminal lacks Full Disk Access (System Settings → Privacy & Security → Full Disk Access).';
301
+ }
302
+ if (browser === 'firefox' || (os !== 'darwin' && !browser && firefoxFound)) {
255
303
  return 'A Firefox profile was found but held none of the cookies — log in at the provider in Firefox and retry.';
304
+ }
305
+ if (browser) {
306
+ const label = CHROMIUM_BROWSERS.find((b) => b.key === browser)?.label ?? browser;
307
+ return `${label} held none of the cookies, the macOS Keychain prompt was denied, or macOS blocked your terminal from reading ${label}'s app data (System Settings → Privacy & Security → App Management / Full Disk Access).`;
308
+ }
309
+ if (os === 'darwin') {
310
+ return 'No browser was logged in, the macOS Keychain prompt was denied, or macOS blocked your terminal from reading browser data (System Settings → Privacy & Security → Full Disk Access).';
311
+ }
256
312
  const why = os === 'win32'
257
313
  ? 'Chrome/Edge cookies are sealed by App-Bound Encryption that only a SYSTEM process can unwrap'
258
314
  : 'the Chromium keyring backends are not implemented here';
package/dist/cli.js CHANGED
@@ -1,6 +1,6 @@
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
+ import { autoFetchHint, detectedBrowsers } from './browser.js';
4
4
  import { credential, loadConfig, saveCredential, saveRecentUrl, tryAutoFillCredentials } from './config.js';
5
5
  import { Conversion } from './conversion.js';
6
6
  import { PROVIDERS, inferSource, providerFor, targetKeys } from './registry.js';
@@ -9,9 +9,9 @@ function args() { return process.argv.slice(2); }
9
9
  // --- Credentials -----------------------------------------------------------
10
10
  // Why the browser read came back with nothing for this provider, and what to do instead. Every
11
11
  // auto-fetch failure says this, so the user never sees a bare "could not be fetched".
12
- function explainAutoFetchFailure(spec) {
12
+ function explainAutoFetchFailure(spec, browser) {
13
13
  const what = spec ? `${spec.label} cookies` : 'No credentials';
14
- console.log(`✗ ${what} could not be read from your browser. ${autoFetchHint()}`);
14
+ console.log(`✗ ${what} could not be read from your browser. ${autoFetchHint(undefined, undefined, browser)}`);
15
15
  }
16
16
  // One manual paste, with where to find the value and what a correct one looks like.
17
17
  async function promptCredential(cfg, spec) {
@@ -23,44 +23,52 @@ async function promptCredential(cfg, spec) {
23
23
  await saveCredential(cfg, spec.key, String(entered).trim()); // pasted values carry stray whitespace
24
24
  return true;
25
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.
28
- async function ensureCredential(cfg, spec) {
29
- if (credential(cfg, spec.key))
30
- return credential(cfg, spec.key);
31
- const filled = await tryAutoFillCredentials(cfg);
32
- if (filled.includes(spec.key))
33
- return credential(cfg, spec.key);
34
- explainAutoFetchFailure(spec);
35
- if (!(await promptCredential(cfg, spec)))
36
- return null;
37
- return credential(cfg, spec.key);
38
- }
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.
44
- async function reenterCredential(cfg, spec) {
26
+ // Pick which detected browser to read from, or 'manual' to paste directly. When no supported
27
+ // browser is detected on this OS, explains why and goes straight to the manual paste.
28
+ async function chooseCredentialSource(spec) {
29
+ const browsers = detectedBrowsers();
30
+ if (!browsers.length) {
31
+ explainAutoFetchFailure(spec);
32
+ return 'manual';
33
+ }
45
34
  const how = await select({
46
35
  message: `${spec.credentialLabel}`,
47
36
  options: [
48
- { value: 'auto', label: 'Auto-fetch from browser' },
37
+ ...browsers.map((b) => ({ value: b.key, label: `Auto-fetch from ${b.label}` })),
49
38
  { value: 'manual', label: 'Paste cookies manually' },
50
39
  ],
51
40
  });
52
- if (isCancel(how))
53
- return;
54
- if (how === 'auto') {
41
+ return isCancel(how) ? null : how;
42
+ }
43
+ // Choose a detected browser (or manual paste) and populate `spec`'s credential, falling back to
44
+ // manual paste when the chosen browser has nothing for `spec`.
45
+ async function acquireCredential(cfg, spec, overwrite, only) {
46
+ const source = await chooseCredentialSource(spec);
47
+ if (!source)
48
+ return false;
49
+ if (source !== 'manual') {
55
50
  warnLogin(spec.loginSite);
56
- if ((await tryAutoFillCredentials(cfg, true, spec.key)).includes(spec.key)) {
57
- await report(cfg, spec);
58
- return;
59
- }
60
- explainAutoFetchFailure(spec);
51
+ if ((await tryAutoFillCredentials(cfg, overwrite, only, source)).includes(spec.key))
52
+ return true;
53
+ explainAutoFetchFailure(spec, source);
61
54
  console.log(' Paste it manually instead:');
62
55
  }
63
- if (!(await promptCredential(cfg, spec)))
56
+ return promptCredential(cfg, spec);
57
+ }
58
+ // Fill one provider's credential mid-conversion: pick a detected browser (or manual paste), and
59
+ // fall back to manual paste if the chosen browser had no cookie for this provider.
60
+ async function ensureCredential(cfg, spec) {
61
+ if (credential(cfg, spec.key))
62
+ return credential(cfg, spec.key);
63
+ if (!(await acquireCredential(cfg, spec, false)))
64
+ return null;
65
+ return credential(cfg, spec.key);
66
+ }
67
+ // Settings entry for one provider's credential. Offers each detected browser alongside manual
68
+ // paste in a single menu, and overwrites scoped to this provider so an expired credential is
69
+ // replaced without touching the other two. Proves whatever lands against the live service.
70
+ async function reenterCredential(cfg, spec) {
71
+ if (!(await acquireCredential(cfg, spec, true, spec.key)))
64
72
  return;
65
73
  await report(cfg, spec);
66
74
  }
@@ -101,6 +109,18 @@ async function build(cfg, spec, needsWrite) {
101
109
  function warnLogin(sites) {
102
110
  console.log(`⚠️ Make sure you're logged in at ${sites} in your browser, then grab the credentials.`);
103
111
  }
112
+ async function pickBrowser() {
113
+ const browsers = detectedBrowsers();
114
+ if (!browsers.length) {
115
+ explainAutoFetchFailure();
116
+ return null;
117
+ }
118
+ const pick = await select({
119
+ message: 'From which browser?',
120
+ options: browsers.map((b) => ({ value: b.key, label: b.label })),
121
+ });
122
+ return isCancel(pick) ? null : pick;
123
+ }
104
124
  // Pick a provider by role. Adding a provider adds an option here, never a menu entry.
105
125
  async function pickProvider(message, from, exclude) {
106
126
  const choices = from.filter((p) => p !== exclude);
@@ -270,24 +290,28 @@ async function runInteractive(options) {
270
290
  continue;
271
291
  }
272
292
  if (choice === 'autofetch') {
273
- warnLogin(PROVIDERS.map((p) => p.label).join(' & '));
274
- const filled = await tryAutoFillCredentials(cfg, true);
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);
293
+ const browser = await pickBrowser();
294
+ if (browser) {
295
+ warnLogin(PROVIDERS.map((p) => p.label).join(' & '));
296
+ const filled = await tryAutoFillCredentials(cfg, true, undefined, browser);
297
+ if (filled.length) {
298
+ console.log(`Fetched from browser: ${filled.join(', ')}. Checking…`);
299
+ for (const key of filled) {
300
+ const spec = providerFor(key);
301
+ if (spec)
302
+ await report(cfg, spec);
303
+ }
304
+ }
305
+ else {
306
+ explainAutoFetchFailure(undefined, browser);
281
307
  }
282
- }
283
- else {
284
- explainAutoFetchFailure();
285
308
  }
286
309
  // Whatever auto-fetch managed, the manual path is what closes the gap — name it and the menu
287
310
  // entry that leads to it, rather than leaving the user at a dead end.
311
+ const nowSaved = PROVIDERS.filter((p) => credential(cfg, p.key)).length;
288
312
  const missing = PROVIDERS.filter((p) => !credential(cfg, p.key));
289
313
  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".`);
314
+ console.log(`→ Still missing: ${missing.map((p) => p.label).join(', ')}. Add them from "Credentials: ${nowSaved}/${PROVIDERS.length} saved" → pick the provider → "Paste cookies manually".`);
291
315
  continue;
292
316
  }
293
317
  if (choice === 'output') {
package/dist/config.js CHANGED
@@ -66,12 +66,12 @@ async function write(patch) {
66
66
  // refetch and replace, and `only` to confine that to one provider — the Credentials menu refetches
67
67
  // the provider being edited without quietly replacing the other two. Never throws — a denied
68
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) {
69
+ export async function tryAutoFillCredentials(cfg, overwrite = false, only, browser) {
70
70
  const wanted = only ? [only] : Object.keys(ENV_VAR);
71
71
  if (!overwrite && wanted.every((provider) => cfg.credentials[provider]))
72
72
  return [];
73
73
  const filled = [];
74
- for (const [provider, value] of Object.entries(fetchBrowserCredentials())) {
74
+ for (const [provider, value] of Object.entries(fetchBrowserCredentials(browser))) {
75
75
  if (!value || !wanted.includes(provider) || (!overwrite && cfg.credentials[provider]))
76
76
  continue;
77
77
  cfg.credentials[provider] = value;
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 match = matchCandidates(track.name, track.artist, track.durationMs, toCandidates(data));
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 match2 = matchCandidates(track.name, track.artist, track.durationMs, toCandidates(data2));
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
- return this.searchByAlbum(track);
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/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
@@ -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 song = matchCandidates(track.name, track.artist, track.durationMs, songs.filter((i) => i.isSong).map(toCandidate));
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plx-converter",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "Convert playlists between Spotify, Deezer, and YouTube Music using your own browser session",
5
5
  "license": "MIT",
6
6
  "repository": {