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/dist/cli.js CHANGED
@@ -1,290 +1,269 @@
1
1
  #!/usr/bin/env node
2
2
  import { confirm, intro, isCancel, outro, password, select, text } from '@clack/prompts';
3
- import { loadConfig, saveCredentials, saveRecentUrl, tryAutoFillCredentials } from './config.js';
4
- import { DeezerClient, resolveDeezerPlaylistId } from './deezer.js';
5
- import { Converter } from './converter.js';
6
- import { anonymousToken, authenticatedToken, parsePlaylistId, playlistName } from './spotify.js';
7
- import { reverseConvert, reverseWriteToExisting } from './reverse.js';
8
- import { listPlaylists } from './spotify.js';
3
+ import { credential, loadConfig, saveCredential, saveRecentUrl, tryAutoFillCredentials } from './config.js';
4
+ import { Conversion } from './conversion.js';
5
+ import { PROVIDERS, inferSource, providerFor, targetKeys } from './registry.js';
9
6
  import { HELP_TEXT, parseArgs } from './args.js';
10
7
  function args() { return process.argv.slice(2); }
11
- // Build a validated Deezer client. If no ARL is set, prompt for it and persist.
12
- // Returns null when the user cancels or the ARL is rejected.
13
- async function ensureDeezer(cfg) {
14
- if (!cfg.deezerArl) {
15
- const filled = await tryAutoFillCredentials(cfg);
16
- if (!filled.includes('deezerArl')) {
17
- const arl = await password({ message: `Deezer ARL (${ARL_HINT})` });
18
- if (isCancel(arl) || !arl)
19
- return null;
20
- cfg.deezerArl = String(arl);
21
- await saveCredentials({ deezerArl: cfg.deezerArl });
22
- }
8
+ // --- Credentials -----------------------------------------------------------
9
+ // Fill one provider's credential: browser auto-fill first, then a manual paste. Persists what it
10
+ // gets. Returns the credential, or null when the user cancels.
11
+ async function ensureCredential(cfg, spec) {
12
+ if (credential(cfg, spec.key))
13
+ return credential(cfg, spec.key);
14
+ const filled = await tryAutoFillCredentials(cfg);
15
+ if (filled.includes(spec.key))
16
+ return credential(cfg, spec.key);
17
+ const entered = await password({ message: `${spec.credentialLabel} (${spec.credentialHint})` });
18
+ if (isCancel(entered) || !entered)
19
+ return null;
20
+ await saveCredential(cfg, spec.key, String(entered));
21
+ return credential(cfg, spec.key);
22
+ }
23
+ // Settings entry for one provider's credential: unlike ensureCredential this always asks, so a
24
+ // saved-but-expired credential can be replaced. Proves it against the live service before saying so.
25
+ async function reenterCredential(cfg, spec) {
26
+ warnLogin(spec.loginSite);
27
+ const filled = await tryAutoFillCredentials(cfg);
28
+ if (!filled.includes(spec.key)) {
29
+ const entered = await password({ message: `${spec.credentialLabel} (${spec.credentialHint})` });
30
+ if (isCancel(entered) || !entered)
31
+ return;
32
+ await saveCredential(cfg, spec.key, String(entered));
33
+ }
34
+ await report(cfg, spec);
35
+ }
36
+ // Check one provider's stored credential against the live service and say what happened.
37
+ async function report(cfg, spec) {
38
+ const cred = credential(cfg, spec.key);
39
+ if (!cred) {
40
+ console.log(`✗ No ${spec.credentialLabel} saved.`);
41
+ return false;
23
42
  }
24
- const deezer = new DeezerClient(cfg.deezerArl);
25
43
  try {
26
- await deezer.getMe();
44
+ console.log(`✓ ${spec.label}: ${await spec.validate(cred)}.`);
45
+ return true;
27
46
  }
28
47
  catch (error) {
29
- console.log(`⚠️ ARL Deezer tidak valid (${error instanceof Error ? error.message : 'error'}). Isi ulang lewat Pengaturan.`);
30
- return null;
48
+ console.log(`✗ ${spec.label}: ${error instanceof Error ? error.message : 'could not be verified'}`);
49
+ return false;
31
50
  }
32
- return deezer;
33
51
  }
34
- // Pick a Spotify source: recent URLs, paste new, or (with sp_dc) pick from the account.
35
- // Returns the ref plus an authenticated read token when picked from the account
36
- // (covers private playlists); token is null on the anonymous paste/recent paths.
37
- async function chooseSource(cfg) {
38
- const options = [
39
- ...cfg.recentUrls.map((u) => ({ value: u, label: u })),
40
- { value: '__new__', label: 'Tempel URL/ID baru' },
41
- ];
42
- if (cfg.spotifyDc)
43
- options.push({ value: '__lib__', label: 'Pilih dari akun Spotify saya' });
44
- const pick = await select({ message: 'Playlist asal (Spotify)', options });
45
- if (isCancel(pick))
46
- return null;
47
- if (pick === '__new__') {
48
- const ref = await text({ message: 'Tempel URL/ID playlist (q untuk kembali)', initialValue: '' });
49
- if (isCancel(ref) || !ref || String(ref).toLowerCase() === 'q')
52
+ // Build a provider, asking for its credential only if it needs one. `needsWrite` marks the target
53
+ // side, which always needs one; a source that can read anonymously never asks.
54
+ async function build(cfg, spec, needsWrite) {
55
+ let cred = credential(cfg, spec.key);
56
+ if (!cred && (needsWrite || !spec.anonymousRead)) {
57
+ cred = (await ensureCredential(cfg, spec)) ?? '';
58
+ if (!cred)
50
59
  return null;
51
- return { ref: String(ref), token: null };
52
60
  }
53
- if (pick === '__lib__') {
54
- const token = await ensureSpotify(cfg);
55
- if (!token)
56
- return null;
57
- const uri = await pickSpotifyPlaylist(token, 'asal');
58
- return uri ? { ref: uri, token } : null;
61
+ try {
62
+ return await spec.create(cred);
59
63
  }
60
- return { ref: String(pick), token: null };
61
- }
62
- // Pick an existing Deezer playlist (as target or source). Returns { id, title } or null.
63
- async function chooseTarget(deezer, role = 'target') {
64
- const method = await select({
65
- message: `Pilih playlist ${role === 'source' ? 'asal' : 'target'} (Deezer)`,
66
- options: [
67
- { value: 'list', label: 'Daftar dari akun Deezer' },
68
- { value: 'paste', label: 'Tempel URL/ID Deezer' },
69
- ],
70
- });
71
- if (isCancel(method))
64
+ catch (error) {
65
+ console.log(`⚠️ ${spec.label} rejected the saved credential (${error instanceof Error ? error.message : 'error'}). Replace it from the Credentials menu.`);
72
66
  return null;
73
- if (method === 'list') {
74
- let playlists;
75
- try {
76
- playlists = await deezer.listPlaylists();
77
- }
78
- catch (error) {
79
- console.log(`⚠️ Gagal baca daftar playlist (${error instanceof Error ? error.message : 'error'}).`);
80
- return null;
81
- }
82
- if (!playlists.length) {
83
- console.log('Tidak ada playlist Deezer ditemukan di akun ini.');
84
- return null;
85
- }
86
- const pick = await select({ message: 'Pilih playlist', options: playlists.map((p) => ({ value: p.id, label: p.title })) });
87
- if (isCancel(pick))
88
- return null;
89
- return playlists.find((p) => p.id === pick) ?? null;
90
67
  }
91
- const ref = await text({ message: 'Tempel URL/ID playlist Deezer', initialValue: '' });
92
- if (isCancel(ref) || !ref)
93
- return null;
94
- const id = await resolveDeezerPlaylistId(String(ref));
95
- return { id, title: id };
96
68
  }
97
- // Pick how to write the target: a new playlist or an existing one.
98
- async function chooseTargetKind() {
99
- const kind = await select({
100
- message: 'Target tujuan',
101
- options: [
102
- { value: 'new', label: 'Playlist baru' },
103
- { value: 'existing', label: 'Playlist yang sudah ada' },
104
- ],
105
- });
106
- if (isCancel(kind))
107
- return null;
108
- return kind;
109
- }
110
- // Core convert: read source, match, then write to a new playlist or an existing target.
111
- async function convert(cfg, options) {
112
- const source = await chooseSource(cfg);
113
- if (!source)
114
- return;
115
- const deezer = await ensureDeezer(cfg);
116
- if (!deezer)
117
- return;
118
- const id = parsePlaylistId(source.ref);
119
- const name = await playlistName(id);
120
- const converter = new Converter(deezer, source.token ?? await anonymousToken(), options.output);
121
- const result = await converter.matchPlaylist({ name, uri: id }, options.dryRun);
122
- if (!options.dryRun && result.matchedIds.length) {
123
- const kind = await chooseTargetKind();
124
- if (kind === 'existing') {
125
- const target = await chooseTarget(deezer);
126
- if (target)
127
- await converter.writeToExisting(name, target.id, result.matchedIds);
128
- }
129
- else if (kind === 'new') {
130
- await maybeWrite(converter, name, result);
131
- }
132
- }
133
- await converter.writeReport();
134
- await saveRecentUrl(source.ref);
69
+ // --- Pickers ---------------------------------------------------------------
70
+ function warnLogin(sites) {
71
+ console.log(`⚠️ Make sure you're logged in at ${sites} in your browser, then grab the credentials.`);
135
72
  }
136
- async function maybeWrite(converter, name, result) {
137
- if (!result.matchedIds.length)
138
- return;
139
- if (result.truncated) {
140
- const proceed = await confirm({ message: `Playlist terpotong di 100 lagu. Lanjut dengan ${result.matchedIds.length} lagu?`, initialValue: false });
141
- if (isCancel(proceed) || !proceed)
142
- return;
143
- }
144
- const ok = await confirm({ message: `Buat '[plx] ${name}' (${result.matchedIds.length} lagu)?`, initialValue: false });
145
- if (isCancel(ok) || !ok)
146
- return;
147
- await converter.writePlaylist(name, result.matchedIds);
73
+ // Pick a provider by role. Adding a provider adds an option here, never a menu entry.
74
+ async function pickProvider(message, from, exclude) {
75
+ const choices = from.filter((p) => p !== exclude);
76
+ const pick = await select({ message, options: choices.map((p) => ({ value: p.key, label: p.label })) });
77
+ if (isCancel(pick))
78
+ return null;
79
+ return providerFor(String(pick)) ?? null;
148
80
  }
149
- // Build a validated Spotify session (sp_dc cookie → authenticated token). Returns token or null.
150
- async function ensureSpotify(cfg) {
151
- if (!cfg.spotifyDc) {
152
- const filled = await tryAutoFillCredentials(cfg);
153
- if (!filled.includes('spotifyDc')) {
154
- const dc = await password({ message: `Spotify sp_dc (${SPDC_HINT})` });
155
- if (isCancel(dc) || !dc)
156
- return null;
157
- cfg.spotifyDc = String(dc);
158
- await saveCredentials({ spotifyDc: cfg.spotifyDc });
159
- }
160
- }
161
- try {
162
- return await authenticatedToken(cfg.spotifyDc);
163
- }
164
- catch (error) {
165
- console.log(`⚠️ sp_dc Spotify tidak valid (${error instanceof Error ? error.message : 'error'}). Isi ulang lewat Pengaturan.`);
81
+ // Pick the playlist to read: a recent link, a pasted one, or one from the account. Same three
82
+ // options for every provider — the account option only appears when that provider can list.
83
+ async function pickSourcePlaylist(cfg, spec, provider) {
84
+ const recents = cfg.recentUrls.filter((u) => inferSource(u) === spec);
85
+ const options = [
86
+ ...recents.map((u) => ({ value: u, label: u })),
87
+ { value: '__new__', label: 'Paste playlist URL/ID' },
88
+ ];
89
+ if (credential(cfg, spec.key))
90
+ options.push({ value: '__lib__', label: `Pick from my ${spec.label} account` });
91
+ const pick = await select({ message: `Source playlist (${spec.label})`, options });
92
+ if (isCancel(pick))
166
93
  return null;
94
+ if (pick === '__lib__') {
95
+ const chosen = await pickFromAccount(provider, spec, 'source');
96
+ return chosen && { id: chosen.id, name: chosen.title };
167
97
  }
98
+ const ref = pick === '__new__'
99
+ ? await text({ message: 'Paste playlist URL/ID (q to go back)', initialValue: '' })
100
+ : String(pick);
101
+ if (isCancel(ref) || !ref || String(ref).toLowerCase() === 'q')
102
+ return null;
103
+ const id = await spec.parseRef(String(ref));
104
+ // A provider that can resolve its own titles does it better than the registry's fallback.
105
+ const name = (await provider.playlistName?.(id).catch(() => '')) || await spec.playlistName(id);
106
+ return { id, name, ref: String(ref) };
168
107
  }
169
- // Pick a Spotify playlist by URI from the user's account (role 'asal' | 'target').
170
- async function pickSpotifyPlaylist(token, role) {
108
+ async function pickFromAccount(provider, spec, role) {
171
109
  let playlists;
172
110
  try {
173
- playlists = await listPlaylists(token);
111
+ playlists = await provider.listPlaylists();
174
112
  }
175
113
  catch (error) {
176
- console.log(`⚠️ Gagal baca daftar playlist Spotify (${error instanceof Error ? error.message : 'error'}).`);
114
+ console.log(`⚠️ Failed to read the ${spec.label} playlist list (${error instanceof Error ? error.message : 'error'}).`);
177
115
  return null;
178
116
  }
179
117
  if (!playlists.length) {
180
- console.log('Tidak ada playlist Spotify (yang bisa diubah) ditemukan.');
118
+ console.log(`No writable ${spec.label} playlists found.`);
181
119
  return null;
182
120
  }
183
- const pick = await select({ message: `Pilih playlist ${role} (Spotify)`, options: playlists.map((p) => ({ value: p.uri, label: p.name })) });
121
+ const pick = await select({ message: `Pick ${role} playlist (${spec.label})`, options: playlists.map((p) => ({ value: p.id, label: p.title })) });
184
122
  if (isCancel(pick))
185
123
  return null;
186
- return String(pick);
124
+ return playlists.find((p) => p.id === pick) ?? null;
187
125
  }
188
- // Pick an existing Spotify playlist (writable) as reverse target. Returns uri or null.
189
- async function chooseSpotifyTarget(token) {
190
- return pickSpotifyPlaylist(token, 'target');
126
+ // Pick the target playlist: an existing one (from the account or pasted), or null for a new one.
127
+ // Returns undefined when the user cancels, to keep it apart from "a new playlist".
128
+ async function pickTargetPlaylist(spec, provider) {
129
+ const kind = await select({
130
+ message: 'Target destination',
131
+ options: [
132
+ { value: 'new', label: 'New playlist' },
133
+ { value: 'existing', label: 'Existing playlist' },
134
+ ],
135
+ });
136
+ if (isCancel(kind))
137
+ return undefined;
138
+ if (kind === 'new')
139
+ return null;
140
+ const how = await select({
141
+ message: `Pick target playlist (${spec.label})`,
142
+ options: [
143
+ { value: 'list', label: `List from my ${spec.label} account` },
144
+ { value: 'paste', label: 'Paste playlist URL/ID' },
145
+ ],
146
+ });
147
+ if (isCancel(how))
148
+ return undefined;
149
+ if (how === 'list')
150
+ return (await pickFromAccount(provider, spec, 'target'))?.id;
151
+ const ref = await text({ message: 'Paste playlist URL/ID', initialValue: '' });
152
+ if (isCancel(ref) || !ref)
153
+ return undefined;
154
+ return spec.parseRef(String(ref));
191
155
  }
192
- // Reverse flow: Deezer playlist (source) → new or existing Spotify playlist (target).
193
- async function reverseConvertFlow(cfg, options) {
194
- const deezer = await ensureDeezer(cfg);
195
- if (!deezer)
156
+ // --- Conversion ------------------------------------------------------------
157
+ // One flow for every direction: pick source provider, pick target provider, then convert.
158
+ async function runConversion(cfg, options) {
159
+ const sourceSpec = await pickProvider('Source provider', PROVIDERS);
160
+ if (!sourceSpec)
161
+ return;
162
+ const targetSpec = await pickProvider('Target provider', PROVIDERS, sourceSpec);
163
+ if (!targetSpec)
196
164
  return;
197
- const source = await chooseTarget(deezer, 'source');
165
+ const source = await build(cfg, sourceSpec, false);
198
166
  if (!source)
199
167
  return;
200
- const token = await ensureSpotify(cfg);
201
- if (!token)
168
+ const picked = await pickSourcePlaylist(cfg, sourceSpec, source);
169
+ if (!picked)
202
170
  return;
203
- const kind = await chooseTargetKind();
204
- if (kind === 'existing') {
205
- const target = await chooseSpotifyTarget(token);
206
- if (target)
207
- await reverseWriteToExisting(deezer, token, source.id, source.title, target, options.output);
171
+ // A dry run writes nothing, so it never asks for the target's credential or its destination.
172
+ const target = await build(cfg, targetSpec, !options.dryRun);
173
+ if (!target)
174
+ return;
175
+ let targetPlaylist = null;
176
+ if (!options.dryRun) {
177
+ const chosen = await pickTargetPlaylist(targetSpec, target);
178
+ if (chosen === undefined)
179
+ return;
180
+ targetPlaylist = chosen;
208
181
  }
209
- else if (kind === 'new') {
210
- await reverseConvert(deezer, token, source.id, source.title, options.output);
182
+ const conversion = new Conversion(source, target, options.output);
183
+ try {
184
+ const result = await conversion.matchPlaylist({ name: picked.name, uri: picked.id }, options.dryRun);
185
+ if (!options.dryRun && result.matchedIds.length) {
186
+ if (targetPlaylist)
187
+ await conversion.writeToExisting(picked.name, targetPlaylist, result.matchedIds);
188
+ else
189
+ await confirmAndCreate(conversion, picked.name, result);
190
+ }
191
+ }
192
+ finally {
193
+ await conversion.writeReport(); // a failed write must not cost the whole report
211
194
  }
195
+ if (picked.ref)
196
+ await saveRecentUrl(picked.ref);
212
197
  }
213
- function warnLogin(sites) {
214
- console.log(`⚠️ Pastikan sudah login di ${sites} di browser, lalu ambil kredensialnya.`);
198
+ async function confirmAndCreate(conversion, name, result) {
199
+ if (result.truncated) {
200
+ const proceed = await confirm({ message: `Playlist truncated at ${result.total} tracks. Continue with ${result.matchedIds.length} tracks?`, initialValue: false });
201
+ if (isCancel(proceed) || !proceed)
202
+ return;
203
+ }
204
+ const ok = await confirm({ message: `Create '[plx] ${name}' (${result.matchedIds.length} tracks)?`, initialValue: false });
205
+ if (isCancel(ok) || !ok)
206
+ return;
207
+ await conversion.writePlaylist(name, result.matchedIds);
215
208
  }
216
- const ARL_HINT = 'login deezer.com → F12 → Application → Cookies → arl → salin value';
217
- const SPDC_HINT = 'login open.spotify.com → F12 → Application → Cookies → sp_dc → salin value';
209
+ // --- Menu ------------------------------------------------------------------
210
+ // Fixed entry count: the two provider-shaped entries (Convert, Credentials) branch by provider
211
+ // rather than listing one option each, so adding a provider never lengthens this menu.
218
212
  async function runInteractive(options) {
219
- intro('plx — Spotify ⇄ Deezer');
220
- const cfg = await loadConfig(false); // no requirement at menu entry
213
+ intro('plx — playlist converter');
214
+ const cfg = await loadConfig();
215
+ if (cfg.legacyCredentials)
216
+ console.log('⚠️ Your saved credentials are in an older format and were not carried over. Re-enter them from Credentials (or use Auto-fetch) — it only takes one browser dialog.');
221
217
  for (;;) {
218
+ const saved = PROVIDERS.filter((p) => credential(cfg, p.key)).length;
222
219
  const choice = await select({
223
- message: 'Pilih aksi',
220
+ message: 'Pick an action',
224
221
  options: [
225
- { value: 'forward', label: 'Spotify → Deezer' },
226
- { value: 'reverse', label: 'Deezer → Spotify' },
227
- { value: 'arl', label: `Deezer ARL: ${cfg.deezerArl ? '✓ tersimpan' : '(belum diisi)'}` },
228
- { value: 'spdc', label: `Spotify sp_dc: ${cfg.spotifyDc ? '✓ tersimpan' : '(belum diisi)'}` },
229
- { value: 'autofetch', label: 'Ambil kredensial otomatis (dari browser)' },
230
- { value: 'output', label: `Laporan: ${options.output}` },
231
- { value: 'quit', label: 'Keluar' },
222
+ { value: 'convert', label: 'Convert a playlist' },
223
+ { value: 'credentials', label: `Credentials: ${saved}/${PROVIDERS.length} saved` },
224
+ { value: 'autofetch', label: 'Auto-fetch credentials (from browser)' },
225
+ { value: 'output', label: `Report: ${options.output}` },
226
+ { value: 'quit', label: 'Quit' },
232
227
  ],
233
228
  });
234
229
  if (isCancel(choice) || choice === 'quit')
235
230
  break;
236
- if (choice === 'forward') {
237
- await convert(cfg, options);
238
- continue;
239
- }
240
- if (choice === 'reverse') {
241
- await reverseConvertFlow(cfg, options);
242
- continue;
243
- }
244
- if (choice === 'arl') {
245
- warnLogin('Deezer (deezer.com)');
246
- const filled = await tryAutoFillCredentials(cfg);
247
- if (!filled.includes('deezerArl')) {
248
- const arl = await password({ message: `Deezer ARL (${ARL_HINT})` });
249
- if (isCancel(arl) || !arl)
250
- continue;
251
- cfg.deezerArl = String(arl);
252
- await saveCredentials({ deezerArl: cfg.deezerArl });
253
- }
254
- console.log('✓ Deezer ARL tersimpan.');
231
+ if (choice === 'convert') {
232
+ await runConversion(cfg, options);
255
233
  continue;
256
234
  }
257
- if (choice === 'spdc') {
258
- warnLogin('Spotify (open.spotify.com)');
259
- const filled = await tryAutoFillCredentials(cfg);
260
- if (!filled.includes('spotifyDc')) {
261
- const dc = await password({ message: `Spotify sp_dc (${SPDC_HINT})` });
262
- if (isCancel(dc) || !dc)
263
- continue;
264
- cfg.spotifyDc = String(dc);
265
- await saveCredentials({ spotifyDc: cfg.spotifyDc });
266
- }
267
- console.log('✓ Spotify sp_dc tersimpan.');
235
+ if (choice === 'credentials') {
236
+ const spec = await pickProvider('Which provider?', PROVIDERS);
237
+ if (spec)
238
+ await reenterCredential(cfg, spec);
268
239
  continue;
269
240
  }
270
241
  if (choice === 'autofetch') {
271
- warnLogin('Deezer & Spotify');
242
+ warnLogin(PROVIDERS.map((p) => p.label).join(' & '));
272
243
  const filled = await tryAutoFillCredentials(cfg, true);
273
- console.log(filled.length
274
- ? `✓ Terambil dari browser: ${filled.join(', ')}.`
275
- : '✗ Tidak ada kredensial yang bisa diambil (browser belum login / keychain ditolak).');
244
+ if (!filled.length) {
245
+ console.log('✗ No credentials could be fetched (browser not logged in / keychain denied).');
246
+ continue;
247
+ }
248
+ console.log(`Fetched from browser: ${filled.join(', ')}. Checking…`);
249
+ for (const key of filled) {
250
+ const spec = providerFor(key);
251
+ if (spec)
252
+ await report(cfg, spec);
253
+ }
276
254
  continue;
277
255
  }
278
256
  if (choice === 'output') {
279
- const output = await text({ message: 'Path file laporan', initialValue: options.output });
257
+ const output = await text({ message: 'Report file path', initialValue: options.output });
280
258
  if (isCancel(output) || !output)
281
259
  continue;
282
260
  options.output = String(output);
283
261
  continue;
284
262
  }
285
263
  }
286
- outro(`Selesai. Laporan: ${options.output}`);
264
+ outro(`Done. Report: ${options.output}`);
287
265
  }
266
+ // --- Entry -----------------------------------------------------------------
288
267
  async function main() {
289
268
  const options = parseArgs(args());
290
269
  if (options.help) {
@@ -295,18 +274,52 @@ async function main() {
295
274
  await runInteractive(options);
296
275
  return;
297
276
  }
298
- const cfg = await loadConfig(true);
299
- const deezer = new DeezerClient(cfg.deezerArl);
300
- await deezer.getMe();
301
- const converter = new Converter(deezer, await anonymousToken(), options.output);
277
+ // A target is never guessed — not naming one is an error, dry run or not.
278
+ const targetSpec = options.target ? providerFor(options.target) : undefined;
279
+ if (!targetSpec) {
280
+ throw new Error(options.target
281
+ ? `Unknown target provider '${options.target}'. Valid targets: ${targetKeys().join(', ')}.`
282
+ : `No target provider. Name one with --to <${targetKeys().join('|')}>.`);
283
+ }
284
+ const cfg = await loadConfig();
285
+ if (cfg.legacyCredentials)
286
+ console.log('⚠️ Saved credentials are in an older format and were not carried over. Run `plx` and re-enter them (one browser dialog).');
302
287
  for (const raw of options.urls) {
303
- const id = parsePlaylistId(raw);
304
- const name = await playlistName(id);
305
- const result = await converter.matchPlaylist({ name, uri: id }, options.dryRun);
306
- if (!options.dryRun && result.matchedIds.length)
307
- await converter.writePlaylist(name, result.matchedIds);
288
+ const sourceSpec = inferSource(raw);
289
+ if (!sourceSpec)
290
+ throw new Error(`Cannot tell which provider '${raw}' belongs to. Use a link from one of: ${PROVIDERS.map((p) => p.label).join(', ')}.`);
291
+ if (sourceSpec === targetSpec)
292
+ throw new Error(`Source and target are both ${sourceSpec.label}.`);
293
+ const source = await sourceSpec.create(readCredential(cfg, sourceSpec));
294
+ // A dry run only searches the target, which may need no credential at all; a write always does.
295
+ const target = await targetSpec.create(options.dryRun ? credential(cfg, targetSpec.key) : writeCredential(cfg, targetSpec));
296
+ const conversion = new Conversion(source, target, options.output);
297
+ try {
298
+ const id = await sourceSpec.parseRef(raw);
299
+ // A provider that can resolve its own titles does it better than the registry's fallback.
300
+ const name = (await source.playlistName?.(id).catch(() => '')) || await sourceSpec.playlistName(id);
301
+ const result = await conversion.matchPlaylist({ name, uri: id }, options.dryRun);
302
+ if (!options.dryRun && result.matchedIds.length)
303
+ await conversion.writePlaylist(name, result.matchedIds);
304
+ }
305
+ finally {
306
+ await conversion.writeReport(); // a failed write must not cost the whole report
307
+ }
308
308
  }
309
- await converter.writeReport();
310
309
  outro(`Report saved to ${options.output}`);
311
310
  }
311
+ // Reading: a provider that permits anonymous reads needs nothing.
312
+ function readCredential(cfg, spec) {
313
+ const cred = credential(cfg, spec.key);
314
+ if (!cred && !spec.anonymousRead)
315
+ throw new Error(`No ${spec.label} credential, needed to read a ${spec.label} playlist. Run \`plx\` and save it from the Credentials menu.`);
316
+ return cred;
317
+ }
318
+ // Writing: always needs one, whatever the provider allows for reads.
319
+ function writeCredential(cfg, spec) {
320
+ const cred = credential(cfg, spec.key);
321
+ if (!cred)
322
+ throw new Error(`No ${spec.label} credential, needed to write into ${spec.label}. Run \`plx\` and save it from the Credentials menu, or use --dry-run to match without writing.`);
323
+ return cred;
324
+ }
312
325
  main().catch((error) => { console.error(`Error: ${error instanceof Error ? error.message : error}`); process.exitCode = 1; });