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/README.md +15 -9
- package/dist/args.js +24 -11
- package/dist/browser.js +64 -40
- package/dist/cli.js +244 -231
- package/dist/config.js +46 -37
- package/dist/conversion.js +143 -0
- package/dist/csv.js +1 -1
- package/dist/deezer.js +26 -5
- package/dist/matcher.js +27 -1
- package/dist/registry.js +67 -0
- package/dist/spotify.js +31 -4
- package/dist/ytmusic-parse.js +75 -0
- package/dist/ytmusic-provider.js +156 -0
- package/dist/ytmusic.js +130 -0
- package/package.json +3 -2
- package/dist/converter.js +0 -115
- package/dist/reverse.js +0 -87
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,
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
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
|
-
//
|
|
12
|
-
//
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
44
|
+
console.log(`✓ ${spec.label}: ${await spec.validate(cred)}.`);
|
|
45
|
+
return true;
|
|
27
46
|
}
|
|
28
47
|
catch (error) {
|
|
29
|
-
console.log(
|
|
30
|
-
return
|
|
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
|
-
//
|
|
35
|
-
//
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
//
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
170
|
-
async function pickSpotifyPlaylist(token, role) {
|
|
108
|
+
async function pickFromAccount(provider, spec, role) {
|
|
171
109
|
let playlists;
|
|
172
110
|
try {
|
|
173
|
-
playlists = await listPlaylists(
|
|
111
|
+
playlists = await provider.listPlaylists();
|
|
174
112
|
}
|
|
175
113
|
catch (error) {
|
|
176
|
-
console.log(`⚠️
|
|
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(
|
|
118
|
+
console.log(`No writable ${spec.label} playlists found.`);
|
|
181
119
|
return null;
|
|
182
120
|
}
|
|
183
|
-
const pick = await select({ message: `
|
|
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
|
|
124
|
+
return playlists.find((p) => p.id === pick) ?? null;
|
|
187
125
|
}
|
|
188
|
-
// Pick an existing
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
//
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
|
165
|
+
const source = await build(cfg, sourceSpec, false);
|
|
198
166
|
if (!source)
|
|
199
167
|
return;
|
|
200
|
-
const
|
|
201
|
-
if (!
|
|
168
|
+
const picked = await pickSourcePlaylist(cfg, sourceSpec, source);
|
|
169
|
+
if (!picked)
|
|
202
170
|
return;
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
210
|
-
|
|
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
|
|
214
|
-
|
|
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
|
-
|
|
217
|
-
|
|
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 —
|
|
220
|
-
const cfg = await loadConfig(
|
|
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: '
|
|
220
|
+
message: 'Pick an action',
|
|
224
221
|
options: [
|
|
225
|
-
{ value: '
|
|
226
|
-
{ value: '
|
|
227
|
-
{ value: '
|
|
228
|
-
{ value: '
|
|
229
|
-
{ value: '
|
|
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 === '
|
|
237
|
-
await
|
|
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 === '
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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('
|
|
242
|
+
warnLogin(PROVIDERS.map((p) => p.label).join(' & '));
|
|
272
243
|
const filled = await tryAutoFillCredentials(cfg, true);
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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: '
|
|
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(`
|
|
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
|
-
|
|
299
|
-
const
|
|
300
|
-
|
|
301
|
-
|
|
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
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
if (
|
|
307
|
-
|
|
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; });
|