@involvex/youtube-music-cli 0.0.18 → 0.0.20
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/CHANGELOG.md +8 -0
- package/dist/source/cli.js +123 -8
- package/dist/source/components/import/ImportLayout.d.ts +1 -0
- package/dist/source/components/import/ImportLayout.js +119 -0
- package/dist/source/components/import/ImportProgress.d.ts +6 -0
- package/dist/source/components/import/ImportProgress.js +73 -0
- package/dist/source/components/layouts/MainLayout.js +15 -2
- package/dist/source/components/settings/Settings.js +6 -2
- package/dist/source/services/config/config.service.js +10 -0
- package/dist/source/services/import/import.service.d.ts +44 -0
- package/dist/source/services/import/import.service.js +272 -0
- package/dist/source/services/import/spotify.service.d.ts +40 -0
- package/dist/source/services/import/spotify.service.js +171 -0
- package/dist/source/services/import/track-matcher.service.d.ts +60 -0
- package/dist/source/services/import/track-matcher.service.js +271 -0
- package/dist/source/services/import/youtube-import.service.d.ts +17 -0
- package/dist/source/services/import/youtube-import.service.js +84 -0
- package/dist/source/services/web/static-file.service.d.ts +31 -0
- package/dist/source/services/web/static-file.service.js +163 -0
- package/dist/source/services/web/web-server-manager.d.ts +88 -0
- package/dist/source/services/web/web-server-manager.js +502 -0
- package/dist/source/services/web/web-streaming.service.d.ts +88 -0
- package/dist/source/services/web/web-streaming.service.js +290 -0
- package/dist/source/services/web/websocket.server.d.ts +63 -0
- package/dist/source/services/web/websocket.server.js +267 -0
- package/dist/source/stores/player.store.js +24 -0
- package/dist/source/types/cli.types.d.ts +8 -0
- package/dist/source/types/config.types.d.ts +2 -0
- package/dist/source/types/import.types.d.ts +72 -0
- package/dist/source/types/import.types.js +2 -0
- package/dist/source/types/web.types.d.ts +127 -0
- package/dist/source/types/web.types.js +2 -0
- package/dist/source/utils/constants.d.ts +1 -0
- package/dist/source/utils/constants.js +1 -0
- package/dist/youtube-music-cli.exe +0 -0
- package/package.json +8 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
## [0.0.20](https://github.com/involvex/youtube-music-cli/compare/v0.0.19...v0.0.20) (2026-02-20)
|
|
2
|
+
|
|
3
|
+
### Bug Fixes
|
|
4
|
+
|
|
5
|
+
- keyboard shortcut conflicts and web UI TypeScript errors ([af01f16](https://github.com/involvex/youtube-music-cli/commit/af01f16a151416c15891e44bb1eee3696a1ddc0e))
|
|
6
|
+
|
|
7
|
+
## [0.0.19](https://github.com/involvex/youtube-music-cli/compare/v0.0.18...v0.0.19) (2026-02-20)
|
|
8
|
+
|
|
1
9
|
## [0.0.18](https://github.com/involvex/youtube-music-cli/compare/v0.0.17...v0.0.18) (2026-02-18)
|
|
2
10
|
|
|
3
11
|
### Bug Fixes
|
package/dist/source/cli.js
CHANGED
|
@@ -6,6 +6,9 @@ import meow from 'meow';
|
|
|
6
6
|
import { getPluginInstallerService } from "./services/plugin/plugin-installer.service.js";
|
|
7
7
|
import { getPluginUpdaterService } from "./services/plugin/plugin-updater.service.js";
|
|
8
8
|
import { getPluginRegistryService } from "./services/plugin/plugin-registry.service.js";
|
|
9
|
+
import { getImportService } from "./services/import/import.service.js";
|
|
10
|
+
import { getWebServerManager } from "./services/web/web-server-manager.js";
|
|
11
|
+
import { getWebStreamingService } from "./services/web/web-streaming.service.js";
|
|
9
12
|
const cli = meow(`
|
|
10
13
|
Usage
|
|
11
14
|
$ youtube-music-cli
|
|
@@ -26,13 +29,23 @@ const cli = meow(`
|
|
|
26
29
|
$ youtube-music-cli plugins enable <name>
|
|
27
30
|
$ youtube-music-cli plugins disable <name>
|
|
28
31
|
|
|
32
|
+
Import Commands
|
|
33
|
+
$ youtube-music-cli import spotify <url-or-id>
|
|
34
|
+
$ youtube-music-cli import youtube <url-or-id>
|
|
35
|
+
|
|
29
36
|
Options
|
|
30
|
-
--theme, -t
|
|
31
|
-
--volume, -v
|
|
32
|
-
--shuffle, -s
|
|
33
|
-
--repeat, -r
|
|
34
|
-
--headless
|
|
35
|
-
--
|
|
37
|
+
--theme, -t Theme to use (dark, light, midnight, matrix)
|
|
38
|
+
--volume, -v Initial volume (0-100)
|
|
39
|
+
--shuffle, -s Enable shuffle mode
|
|
40
|
+
--repeat, -r Repeat mode (off, all, one)
|
|
41
|
+
--headless Run without TUI (just play)
|
|
42
|
+
--web Enable web UI server
|
|
43
|
+
--web-host Web server host (default: localhost)
|
|
44
|
+
--web-port Web server port (default: 8080)
|
|
45
|
+
--web-only Run web server without CLI UI
|
|
46
|
+
--web-auth Authentication token for web server
|
|
47
|
+
--name Custom name for imported playlist
|
|
48
|
+
--help, -h Show this help
|
|
36
49
|
|
|
37
50
|
Examples
|
|
38
51
|
$ youtube-music-cli
|
|
@@ -40,6 +53,8 @@ const cli = meow(`
|
|
|
40
53
|
$ youtube-music-cli search "Rick Astley"
|
|
41
54
|
$ youtube-music-cli play dQw4w9WgXcQ --headless
|
|
42
55
|
$ youtube-music-cli plugins install adblock
|
|
56
|
+
$ youtube-music-cli import spotify "https://open.spotify.com/playlist/..."
|
|
57
|
+
$ youtube-music-cli --web --web-port 3000
|
|
43
58
|
`, {
|
|
44
59
|
importMeta: import.meta,
|
|
45
60
|
flags: {
|
|
@@ -64,6 +79,26 @@ const cli = meow(`
|
|
|
64
79
|
type: 'boolean',
|
|
65
80
|
default: false,
|
|
66
81
|
},
|
|
82
|
+
web: {
|
|
83
|
+
type: 'boolean',
|
|
84
|
+
default: false,
|
|
85
|
+
},
|
|
86
|
+
webHost: {
|
|
87
|
+
type: 'string',
|
|
88
|
+
},
|
|
89
|
+
webPort: {
|
|
90
|
+
type: 'number',
|
|
91
|
+
},
|
|
92
|
+
webOnly: {
|
|
93
|
+
type: 'boolean',
|
|
94
|
+
default: false,
|
|
95
|
+
},
|
|
96
|
+
webAuth: {
|
|
97
|
+
type: 'string',
|
|
98
|
+
},
|
|
99
|
+
name: {
|
|
100
|
+
type: 'string',
|
|
101
|
+
},
|
|
67
102
|
help: {
|
|
68
103
|
type: 'boolean',
|
|
69
104
|
shortFlag: 'h',
|
|
@@ -236,6 +271,86 @@ else {
|
|
|
236
271
|
else if (command === 'back') {
|
|
237
272
|
cli.flags.action = 'previous';
|
|
238
273
|
}
|
|
239
|
-
|
|
240
|
-
|
|
274
|
+
else if (command === 'import') {
|
|
275
|
+
// Handle import commands
|
|
276
|
+
void (async () => {
|
|
277
|
+
const source = args[0];
|
|
278
|
+
const url = args[1];
|
|
279
|
+
if (!source || !url) {
|
|
280
|
+
console.error('Usage: youtube-music-cli import <spotify|youtube> <url-or-id>');
|
|
281
|
+
process.exit(1);
|
|
282
|
+
}
|
|
283
|
+
if (source !== 'spotify' && source !== 'youtube') {
|
|
284
|
+
console.error('Invalid source. Use "spotify" or "youtube".');
|
|
285
|
+
process.exit(1);
|
|
286
|
+
}
|
|
287
|
+
const importService = getImportService();
|
|
288
|
+
const customName = cli.flags.name;
|
|
289
|
+
try {
|
|
290
|
+
console.log(`Importing ${source} playlist...`);
|
|
291
|
+
const result = await importService.importPlaylist(source, url, customName);
|
|
292
|
+
console.log(`\n✓ Import completed!`);
|
|
293
|
+
console.log(` Playlist: ${result.playlistName}`);
|
|
294
|
+
console.log(` Matched: ${result.matched}/${result.total} tracks`);
|
|
295
|
+
if (result.errors.length > 0) {
|
|
296
|
+
console.log(`\nErrors:`);
|
|
297
|
+
for (const error of result.errors.slice(0, 10)) {
|
|
298
|
+
console.log(` - ${error}`);
|
|
299
|
+
}
|
|
300
|
+
if (result.errors.length > 10) {
|
|
301
|
+
console.log(` ... and ${result.errors.length - 10} more`);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
process.exit(0);
|
|
305
|
+
}
|
|
306
|
+
catch (error) {
|
|
307
|
+
console.error(`✗ Import failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
308
|
+
process.exit(1);
|
|
309
|
+
}
|
|
310
|
+
})();
|
|
311
|
+
}
|
|
312
|
+
else if (cli.flags.web || cli.flags.webOnly) {
|
|
313
|
+
// Handle web server flags
|
|
314
|
+
void (async () => {
|
|
315
|
+
const webManager = getWebServerManager();
|
|
316
|
+
try {
|
|
317
|
+
await webManager.start({
|
|
318
|
+
enabled: true,
|
|
319
|
+
host: cli.flags.webHost ?? 'localhost',
|
|
320
|
+
port: cli.flags.webPort ?? 8080,
|
|
321
|
+
webOnly: cli.flags.webOnly,
|
|
322
|
+
auth: cli.flags.webAuth,
|
|
323
|
+
});
|
|
324
|
+
const serverUrl = webManager.getServerUrl();
|
|
325
|
+
console.log(`Web UI server running at: ${serverUrl}`);
|
|
326
|
+
// Set up import progress streaming
|
|
327
|
+
const streamingService = getWebStreamingService();
|
|
328
|
+
const importService = getImportService();
|
|
329
|
+
importService.onProgress(progress => {
|
|
330
|
+
streamingService.onImportProgress(progress);
|
|
331
|
+
});
|
|
332
|
+
// If web-only mode, just keep the server running
|
|
333
|
+
if (cli.flags.webOnly) {
|
|
334
|
+
console.log('Running in web-only mode. Press Ctrl+C to exit.');
|
|
335
|
+
// Keep process alive
|
|
336
|
+
process.on('SIGINT', () => {
|
|
337
|
+
console.log('\nShutting down web server...');
|
|
338
|
+
void webManager.stop().then(() => process.exit(0));
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
else {
|
|
342
|
+
// Also render the CLI UI
|
|
343
|
+
render(_jsx(App, { flags: cli.flags }));
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
catch (error) {
|
|
347
|
+
console.error(`Failed to start web server: ${error instanceof Error ? error.message : String(error)}`);
|
|
348
|
+
process.exit(1);
|
|
349
|
+
}
|
|
350
|
+
})();
|
|
351
|
+
}
|
|
352
|
+
else {
|
|
353
|
+
// Render the app
|
|
354
|
+
render(_jsx(App, { flags: cli.flags }));
|
|
355
|
+
}
|
|
241
356
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export default function ImportLayout(): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// Import layout component for playlist import
|
|
3
|
+
import { useState, useCallback, useEffect } from 'react';
|
|
4
|
+
import { Box, Text } from 'ink';
|
|
5
|
+
import TextInput from 'ink-text-input';
|
|
6
|
+
import { useTheme } from "../../hooks/useTheme.js";
|
|
7
|
+
import { useNavigation } from "../../hooks/useNavigation.js";
|
|
8
|
+
import { useKeyBinding } from "../../hooks/useKeyboard.js";
|
|
9
|
+
import { KEYBINDINGS } from "../../utils/constants.js";
|
|
10
|
+
import { getImportService } from "../../services/import/import.service.js";
|
|
11
|
+
import ImportProgressComponent from "./ImportProgress.js";
|
|
12
|
+
const SOURCES = [
|
|
13
|
+
{ key: 'spotify', label: 'Spotify' },
|
|
14
|
+
{ key: 'youtube', label: 'YouTube' },
|
|
15
|
+
];
|
|
16
|
+
export default function ImportLayout() {
|
|
17
|
+
const { theme } = useTheme();
|
|
18
|
+
const { dispatch } = useNavigation();
|
|
19
|
+
const importService = getImportService();
|
|
20
|
+
const [step, setStep] = useState('source');
|
|
21
|
+
const [selectedSource, setSelectedSource] = useState(0);
|
|
22
|
+
const [url, setUrl] = useState('');
|
|
23
|
+
const [customName, setCustomName] = useState('');
|
|
24
|
+
const [progress, setProgress] = useState(null);
|
|
25
|
+
const [result, setResult] = useState(null);
|
|
26
|
+
const [error, setError] = useState(null);
|
|
27
|
+
const goBack = useCallback(() => {
|
|
28
|
+
if (step === 'source') {
|
|
29
|
+
dispatch({ category: 'GO_BACK' });
|
|
30
|
+
}
|
|
31
|
+
else if (step === 'url') {
|
|
32
|
+
setStep('source');
|
|
33
|
+
}
|
|
34
|
+
else if (step === 'name') {
|
|
35
|
+
setStep('url');
|
|
36
|
+
}
|
|
37
|
+
else if (step === 'result') {
|
|
38
|
+
setStep('source');
|
|
39
|
+
setResult(null);
|
|
40
|
+
}
|
|
41
|
+
}, [step, dispatch]);
|
|
42
|
+
const selectSource = useCallback(() => {
|
|
43
|
+
setStep('url');
|
|
44
|
+
}, []);
|
|
45
|
+
const submitUrl = useCallback(() => {
|
|
46
|
+
if (url.trim()) {
|
|
47
|
+
setStep('name');
|
|
48
|
+
}
|
|
49
|
+
}, [url]);
|
|
50
|
+
const startImport = useCallback(async () => {
|
|
51
|
+
setStep('importing');
|
|
52
|
+
setError(null);
|
|
53
|
+
try {
|
|
54
|
+
const unsubscribe = importService.onProgress(prog => {
|
|
55
|
+
setProgress(prog);
|
|
56
|
+
});
|
|
57
|
+
const source = SOURCES[selectedSource].key;
|
|
58
|
+
const importResult = await importService.importPlaylist(source, url, customName || undefined);
|
|
59
|
+
unsubscribe();
|
|
60
|
+
setResult({
|
|
61
|
+
playlistName: importResult.playlistName,
|
|
62
|
+
matched: importResult.matched,
|
|
63
|
+
total: importResult.total,
|
|
64
|
+
errors: importResult.errors,
|
|
65
|
+
});
|
|
66
|
+
setStep('result');
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
70
|
+
setStep('source');
|
|
71
|
+
}
|
|
72
|
+
}, [selectedSource, url, customName, importService]);
|
|
73
|
+
const submitName = useCallback(() => {
|
|
74
|
+
startImport();
|
|
75
|
+
}, [startImport]);
|
|
76
|
+
// Keyboard bindings
|
|
77
|
+
useKeyBinding(KEYBINDINGS.UP, () => {
|
|
78
|
+
if (step === 'source') {
|
|
79
|
+
setSelectedSource(prev => Math.max(0, prev - 1));
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
useKeyBinding(KEYBINDINGS.DOWN, () => {
|
|
83
|
+
if (step === 'source') {
|
|
84
|
+
setSelectedSource(prev => Math.min(SOURCES.length - 1, prev + 1));
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
useKeyBinding(KEYBINDINGS.SELECT, () => {
|
|
88
|
+
if (step === 'source')
|
|
89
|
+
selectSource();
|
|
90
|
+
else if (step === 'url')
|
|
91
|
+
submitUrl();
|
|
92
|
+
else if (step === 'name')
|
|
93
|
+
submitName();
|
|
94
|
+
else if (step === 'result')
|
|
95
|
+
goBack();
|
|
96
|
+
});
|
|
97
|
+
useKeyBinding(KEYBINDINGS.BACK, goBack);
|
|
98
|
+
// Escape key for skip name
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
if (step === 'name') {
|
|
101
|
+
const handleEscape = () => {
|
|
102
|
+
startImport();
|
|
103
|
+
};
|
|
104
|
+
const stdin = process.stdin;
|
|
105
|
+
stdin.on('keypress', handleEscape);
|
|
106
|
+
return () => {
|
|
107
|
+
stdin.off('keypress', handleEscape);
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
return undefined;
|
|
111
|
+
}, [step, startImport]);
|
|
112
|
+
return (_jsxs(Box, { flexDirection: "column", gap: 1, paddingX: 1, children: [_jsx(Box, { borderStyle: "double", borderColor: theme.colors.secondary, paddingX: 1, marginBottom: 1, children: _jsx(Text, { bold: true, color: theme.colors.primary, children: "Import Playlist" }) }), error && (_jsx(Box, { paddingX: 1, children: _jsxs(Text, { color: theme.colors.error, children: ["Error: ", error] }) })), step === 'source' && (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsx(Text, { color: theme.colors.dim, children: "Select playlist source:" }), SOURCES.map((source, index) => (_jsx(Box, { paddingX: 1, children: _jsxs(Text, { backgroundColor: index === selectedSource ? theme.colors.primary : undefined, color: index === selectedSource
|
|
113
|
+
? theme.colors.background
|
|
114
|
+
: theme.colors.text, bold: index === selectedSource, children: [index === selectedSource ? '► ' : ' ', source.label] }) }, source.key)))] })), step === 'url' && (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsxs(Text, { color: theme.colors.dim, children: ["Enter ", SOURCES[selectedSource].label, " playlist URL or ID:"] }), _jsx(Box, { paddingX: 1, children: _jsx(TextInput, { value: url, onChange: setUrl, onSubmit: submitUrl, placeholder: "Paste URL or ID here...", focus: true }) }), _jsxs(Text, { color: theme.colors.dim, children: ["Examples:", ' ', SOURCES[selectedSource].key === 'spotify'
|
|
115
|
+
? 'https://open.spotify.com/playlist/...'
|
|
116
|
+
: 'https://www.youtube.com/playlist?list=...'] })] })), step === 'name' && (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsx(Text, { color: theme.colors.dim, children: "Custom playlist name (optional, Esc to skip):" }), _jsx(Box, { paddingX: 1, children: _jsx(TextInput, { value: customName, onChange: setCustomName, onSubmit: submitName, placeholder: "Leave empty to use original name", focus: true }) }), _jsx(Text, { color: theme.colors.dim, children: "Press Enter to import or Esc to skip" })] })), step === 'importing' && progress && (_jsx(ImportProgressComponent, { progress: progress })), step === 'result' && result && (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsx(Box, { paddingX: 1, children: _jsx(Text, { color: theme.colors.success, bold: true, children: "\u2713 Import completed!" }) }), _jsx(Box, { paddingX: 1, children: _jsxs(Text, { children: ["Playlist: ", result.playlistName] }) }), _jsx(Box, { paddingX: 1, children: _jsxs(Text, { children: ["Matched:", ' ', _jsx(Text, { color: theme.colors.primary, children: result.matched }), "/", result.total, " tracks"] }) }), result.errors.length > 0 && (_jsxs(Box, { flexDirection: "column", gap: 1, marginTop: 1, children: [_jsxs(Text, { color: theme.colors.dim, bold: true, children: ["Errors (", result.errors.length, "):"] }), result.errors.slice(0, 5).map((err, i) => (_jsx(Box, { paddingX: 2, children: _jsxs(Text, { color: theme.colors.error, children: ["\u2022 ", err] }) }, i))), result.errors.length > 5 && (_jsx(Box, { paddingX: 2, children: _jsxs(Text, { color: theme.colors.dim, children: ["... and ", result.errors.length - 5, " more"] }) }))] })), _jsx(Box, { marginTop: 1, paddingX: 1, children: _jsx(Text, { color: theme.colors.dim, children: "Press Enter to continue" }) })] })), step !== 'importing' && step !== 'result' && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.colors.dim, children: step === 'source'
|
|
117
|
+
? '↑↓ to select, Enter to continue, Esc/q to go back'
|
|
118
|
+
: 'Enter to continue, Esc to go back' }) }))] }));
|
|
119
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// Import progress display component
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
import { Box, Text } from 'ink';
|
|
5
|
+
import { useTheme } from "../../hooks/useTheme.js";
|
|
6
|
+
const PROGRESS_BLOCKS = 20;
|
|
7
|
+
export default function ImportProgress({ progress }) {
|
|
8
|
+
const { theme } = useTheme();
|
|
9
|
+
const [animatedBlocks, setAnimatedBlocks] = useState(0);
|
|
10
|
+
// Animate progress blocks
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
if (progress.total > 0) {
|
|
13
|
+
const targetBlocks = Math.floor((progress.current / progress.total) * PROGRESS_BLOCKS);
|
|
14
|
+
const delay = Math.max(50, 500 / PROGRESS_BLOCKS); // Max 500ms total animation
|
|
15
|
+
const interval = setInterval(() => {
|
|
16
|
+
setAnimatedBlocks(prev => {
|
|
17
|
+
if (prev >= targetBlocks) {
|
|
18
|
+
clearInterval(interval);
|
|
19
|
+
return prev;
|
|
20
|
+
}
|
|
21
|
+
return prev + 1;
|
|
22
|
+
});
|
|
23
|
+
}, delay);
|
|
24
|
+
return () => clearInterval(interval);
|
|
25
|
+
}
|
|
26
|
+
return undefined;
|
|
27
|
+
}, [progress]);
|
|
28
|
+
// Calculate completed blocks
|
|
29
|
+
const completedBlocks = Math.min(animatedBlocks, PROGRESS_BLOCKS);
|
|
30
|
+
const progressPercent = progress.total > 0
|
|
31
|
+
? Math.round((progress.current / progress.total) * 100)
|
|
32
|
+
: 0;
|
|
33
|
+
// Get status color
|
|
34
|
+
const getStatusColor = () => {
|
|
35
|
+
switch (progress.status) {
|
|
36
|
+
case 'fetching':
|
|
37
|
+
return theme.colors.accent;
|
|
38
|
+
case 'matching':
|
|
39
|
+
return theme.colors.primary;
|
|
40
|
+
case 'creating':
|
|
41
|
+
return theme.colors.success;
|
|
42
|
+
case 'completed':
|
|
43
|
+
return theme.colors.success;
|
|
44
|
+
case 'failed':
|
|
45
|
+
case 'cancelled':
|
|
46
|
+
return theme.colors.error;
|
|
47
|
+
default:
|
|
48
|
+
return theme.colors.dim;
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
// Get status label
|
|
52
|
+
const getStatusLabel = () => {
|
|
53
|
+
switch (progress.status) {
|
|
54
|
+
case 'fetching':
|
|
55
|
+
return 'Fetching playlist...';
|
|
56
|
+
case 'matching':
|
|
57
|
+
return 'Matching tracks...';
|
|
58
|
+
case 'creating':
|
|
59
|
+
return 'Creating playlist...';
|
|
60
|
+
case 'completed':
|
|
61
|
+
return 'Completed!';
|
|
62
|
+
case 'failed':
|
|
63
|
+
return 'Failed';
|
|
64
|
+
case 'cancelled':
|
|
65
|
+
return 'Cancelled';
|
|
66
|
+
default:
|
|
67
|
+
return 'Starting...';
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsx(Box, { paddingX: 1, children: _jsx(Text, { bold: true, color: getStatusColor(), children: getStatusLabel() }) }), progress.total > 0 && (_jsx(Box, { paddingX: 1, children: _jsxs(Box, { children: [Array.from({ length: completedBlocks }).map((_, i) => (_jsx(Text, { backgroundColor: theme.colors.primary, children: ' ' }, i))), Array.from({ length: PROGRESS_BLOCKS - completedBlocks }).map((_, i) => (_jsx(Text, { backgroundColor: theme.colors.dim, dimColor: true, children: ' ' }, i + completedBlocks)))] }) })), _jsx(Box, { paddingX: 1, children: _jsxs(Text, { color: theme.colors.dim, children: [progress.total > 0 ? `${progressPercent}%` : '...', " -", ' ', progress.current, "/", progress.total || '?', ' ', progress.total === 1 ? 'track' : 'tracks'] }) }), progress.currentTrack && progress.status === 'matching' && (_jsx(Box, { paddingX: 1, children: _jsx(Text, { color: theme.colors.text, dimColor: true, children: progress.currentTrack.length > 50
|
|
71
|
+
? `...${progress.currentTrack.slice(-47)}`
|
|
72
|
+
: progress.currentTrack }) })), progress.message && (_jsx(Box, { paddingX: 1, children: _jsx(Text, { color: theme.colors.dim, children: progress.message }) }))] }));
|
|
73
|
+
}
|
|
@@ -20,6 +20,7 @@ import SearchHistory from "../search/SearchHistory.js";
|
|
|
20
20
|
import KeybindingsLayout from "../config/KeybindingsLayout.js";
|
|
21
21
|
import TrendingLayout from "./TrendingLayout.js";
|
|
22
22
|
import ExploreLayout from "./ExploreLayout.js";
|
|
23
|
+
import ImportLayout from "../import/ImportLayout.js";
|
|
23
24
|
import { KEYBINDINGS, VIEW } from "../../utils/constants.js";
|
|
24
25
|
import { Box } from 'ink';
|
|
25
26
|
import { useTerminalSize } from "../../hooks/useTerminalSize.js";
|
|
@@ -63,8 +64,17 @@ function MainLayout() {
|
|
|
63
64
|
dispatch({ category: 'NAVIGATE', view: VIEW.TRENDING });
|
|
64
65
|
}, [dispatch]);
|
|
65
66
|
const goToExplore = useCallback(() => {
|
|
66
|
-
|
|
67
|
-
|
|
67
|
+
// Don't navigate to explore if we're in plugins view (e key is used for enable/disable there)
|
|
68
|
+
if (navState.currentView !== VIEW.PLUGINS) {
|
|
69
|
+
dispatch({ category: 'NAVIGATE', view: VIEW.EXPLORE });
|
|
70
|
+
}
|
|
71
|
+
}, [dispatch, navState.currentView]);
|
|
72
|
+
const goToImport = useCallback(() => {
|
|
73
|
+
// Don't navigate to import if we're in plugins view (i key is used for plugin install there)
|
|
74
|
+
if (navState.currentView !== VIEW.PLUGINS) {
|
|
75
|
+
dispatch({ category: 'NAVIGATE', view: VIEW.IMPORT });
|
|
76
|
+
}
|
|
77
|
+
}, [dispatch, navState.currentView]);
|
|
68
78
|
const togglePlayerMode = useCallback(() => {
|
|
69
79
|
dispatch({ category: 'TOGGLE_PLAYER_MODE' });
|
|
70
80
|
}, [dispatch]);
|
|
@@ -80,6 +90,7 @@ function MainLayout() {
|
|
|
80
90
|
useKeyBinding(['l'], goToLyrics);
|
|
81
91
|
useKeyBinding(['T'], goToTrending);
|
|
82
92
|
useKeyBinding(['e'], goToExplore);
|
|
93
|
+
useKeyBinding(['i'], goToImport);
|
|
83
94
|
// Memoize the view component to prevent unnecessary remounts
|
|
84
95
|
// Only recreate when currentView actually changes
|
|
85
96
|
const currentView = useMemo(() => {
|
|
@@ -114,6 +125,8 @@ function MainLayout() {
|
|
|
114
125
|
return _jsx(TrendingLayout, {}, "trending");
|
|
115
126
|
case 'explore':
|
|
116
127
|
return _jsx(ExploreLayout, {}, "explore");
|
|
128
|
+
case 'import':
|
|
129
|
+
return _jsx(ImportLayout, {}, "import");
|
|
117
130
|
case 'help':
|
|
118
131
|
return _jsx(Help, {}, "help");
|
|
119
132
|
default:
|
|
@@ -22,6 +22,7 @@ const SETTINGS_ITEMS = [
|
|
|
22
22
|
'Download Folder',
|
|
23
23
|
'Download Format',
|
|
24
24
|
'Sleep Timer',
|
|
25
|
+
'Import Playlists',
|
|
25
26
|
'Custom Keybindings',
|
|
26
27
|
'Manage Plugins',
|
|
27
28
|
];
|
|
@@ -116,9 +117,12 @@ export default function Settings() {
|
|
|
116
117
|
cycleSleepTimer();
|
|
117
118
|
}
|
|
118
119
|
else if (selectedIndex === 8) {
|
|
119
|
-
dispatch({ category: 'NAVIGATE', view: VIEW.
|
|
120
|
+
dispatch({ category: 'NAVIGATE', view: VIEW.IMPORT });
|
|
120
121
|
}
|
|
121
122
|
else if (selectedIndex === 9) {
|
|
123
|
+
dispatch({ category: 'NAVIGATE', view: VIEW.KEYBINDINGS });
|
|
124
|
+
}
|
|
125
|
+
else if (selectedIndex === 10) {
|
|
122
126
|
dispatch({ category: 'NAVIGATE', view: VIEW.PLUGINS });
|
|
123
127
|
}
|
|
124
128
|
};
|
|
@@ -142,5 +146,5 @@ export default function Settings() {
|
|
|
142
146
|
? theme.colors.background
|
|
143
147
|
: isActive
|
|
144
148
|
? theme.colors.accent
|
|
145
|
-
: theme.colors.text, bold: selectedIndex === 7, children: sleepTimerLabel }) }), _jsx(Box, { paddingX: 1, children: _jsx(Text, { backgroundColor: selectedIndex === 8 ? theme.colors.primary : undefined, color: selectedIndex === 8 ? theme.colors.background : theme.colors.text, bold: selectedIndex === 8, children: "
|
|
149
|
+
: theme.colors.text, bold: selectedIndex === 7, children: sleepTimerLabel }) }), _jsx(Box, { paddingX: 1, children: _jsx(Text, { backgroundColor: selectedIndex === 8 ? theme.colors.primary : undefined, color: selectedIndex === 8 ? theme.colors.background : theme.colors.text, bold: selectedIndex === 8, children: "Import Playlists \u2192" }) }), _jsx(Box, { paddingX: 1, children: _jsx(Text, { backgroundColor: selectedIndex === 9 ? theme.colors.primary : undefined, color: selectedIndex === 9 ? theme.colors.background : theme.colors.text, bold: selectedIndex === 9, children: "Custom Keybindings \u2192" }) }), _jsx(Box, { paddingX: 1, children: _jsx(Text, { backgroundColor: selectedIndex === 10 ? theme.colors.primary : undefined, color: selectedIndex === 10 ? theme.colors.background : theme.colors.text, bold: selectedIndex === 10, children: "Manage Plugins" }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.colors.dim, children: "Arrows to navigate, Enter to select, Esc/q to go back" }) })] }));
|
|
146
150
|
}
|
|
@@ -31,6 +31,16 @@ class ConfigService {
|
|
|
31
31
|
downloadsEnabled: false,
|
|
32
32
|
downloadDirectory: path.join(CONFIG_DIR, 'downloads'),
|
|
33
33
|
downloadFormat: 'mp3',
|
|
34
|
+
webServer: {
|
|
35
|
+
enabled: false,
|
|
36
|
+
host: 'localhost',
|
|
37
|
+
port: 8080,
|
|
38
|
+
enableCors: true,
|
|
39
|
+
allowedOrigins: ['*'],
|
|
40
|
+
auth: {
|
|
41
|
+
enabled: false,
|
|
42
|
+
},
|
|
43
|
+
},
|
|
34
44
|
};
|
|
35
45
|
}
|
|
36
46
|
load() {
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { ImportSource, ImportProgress, ImportResult } from '../../types/import.types.ts';
|
|
2
|
+
type ProgressCallback = (progress: ImportProgress) => void;
|
|
3
|
+
declare class ImportService {
|
|
4
|
+
private progressCallbacks;
|
|
5
|
+
private currentImport;
|
|
6
|
+
/**
|
|
7
|
+
* Subscribe to import progress updates
|
|
8
|
+
*/
|
|
9
|
+
onProgress(callback: ProgressCallback): () => void;
|
|
10
|
+
/**
|
|
11
|
+
* Emit progress to all subscribers
|
|
12
|
+
*/
|
|
13
|
+
private emitProgress;
|
|
14
|
+
/**
|
|
15
|
+
* Create a unique playlist ID
|
|
16
|
+
*/
|
|
17
|
+
private generatePlaylistId;
|
|
18
|
+
/**
|
|
19
|
+
* Save imported playlist to config
|
|
20
|
+
*/
|
|
21
|
+
private savePlaylist;
|
|
22
|
+
/**
|
|
23
|
+
* Import a playlist from Spotify or YouTube
|
|
24
|
+
*/
|
|
25
|
+
importPlaylist(source: ImportSource, urlOrId: string, customName?: string, signal?: AbortSignal): Promise<ImportResult>;
|
|
26
|
+
/**
|
|
27
|
+
* Validate a playlist URL/ID before importing
|
|
28
|
+
*/
|
|
29
|
+
validatePlaylist(source: ImportSource, urlOrId: string): Promise<boolean>;
|
|
30
|
+
/**
|
|
31
|
+
* Get current import status
|
|
32
|
+
*/
|
|
33
|
+
getCurrentImport(): {
|
|
34
|
+
source: ImportSource;
|
|
35
|
+
url: string;
|
|
36
|
+
elapsed: number;
|
|
37
|
+
} | null;
|
|
38
|
+
/**
|
|
39
|
+
* Cancel the current import
|
|
40
|
+
*/
|
|
41
|
+
cancelImport(): void;
|
|
42
|
+
}
|
|
43
|
+
export declare function getImportService(): ImportService;
|
|
44
|
+
export {};
|