@involvex/youtube-music-cli 0.0.17 → 0.0.19

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.
Files changed (37) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/dist/source/cli.js +123 -8
  3. package/dist/source/components/common/ShortcutsBar.js +2 -2
  4. package/dist/source/components/import/ImportLayout.d.ts +1 -0
  5. package/dist/source/components/import/ImportLayout.js +119 -0
  6. package/dist/source/components/import/ImportProgress.d.ts +6 -0
  7. package/dist/source/components/import/ImportProgress.js +73 -0
  8. package/dist/source/components/layouts/MainLayout.js +7 -0
  9. package/dist/source/components/settings/Settings.js +6 -2
  10. package/dist/source/services/config/config.service.js +10 -0
  11. package/dist/source/services/import/import.service.d.ts +44 -0
  12. package/dist/source/services/import/import.service.js +272 -0
  13. package/dist/source/services/import/spotify.service.d.ts +40 -0
  14. package/dist/source/services/import/spotify.service.js +171 -0
  15. package/dist/source/services/import/track-matcher.service.d.ts +60 -0
  16. package/dist/source/services/import/track-matcher.service.js +271 -0
  17. package/dist/source/services/import/youtube-import.service.d.ts +17 -0
  18. package/dist/source/services/import/youtube-import.service.js +84 -0
  19. package/dist/source/services/web/static-file.service.d.ts +31 -0
  20. package/dist/source/services/web/static-file.service.js +174 -0
  21. package/dist/source/services/web/web-server-manager.d.ts +66 -0
  22. package/dist/source/services/web/web-server-manager.js +219 -0
  23. package/dist/source/services/web/web-streaming.service.d.ts +88 -0
  24. package/dist/source/services/web/web-streaming.service.js +290 -0
  25. package/dist/source/services/web/websocket.server.d.ts +58 -0
  26. package/dist/source/services/web/websocket.server.js +253 -0
  27. package/dist/source/stores/player.store.js +27 -0
  28. package/dist/source/types/cli.types.d.ts +8 -0
  29. package/dist/source/types/config.types.d.ts +2 -0
  30. package/dist/source/types/import.types.d.ts +72 -0
  31. package/dist/source/types/import.types.js +2 -0
  32. package/dist/source/types/web.types.d.ts +89 -0
  33. package/dist/source/types/web.types.js +2 -0
  34. package/dist/source/utils/constants.d.ts +1 -0
  35. package/dist/source/utils/constants.js +1 -0
  36. package/dist/youtube-music-cli.exe +0 -0
  37. package/package.json +8 -3
package/CHANGELOG.md CHANGED
@@ -1,3 +1,11 @@
1
+ ## [0.0.19](https://github.com/involvex/youtube-music-cli/compare/v0.0.18...v0.0.19) (2026-02-20)
2
+
3
+ ## [0.0.18](https://github.com/involvex/youtube-music-cli/compare/v0.0.17...v0.0.18) (2026-02-18)
4
+
5
+ ### Bug Fixes
6
+
7
+ - start playback on shuffle, reset, and next actions ([d57452b](https://github.com/involvex/youtube-music-cli/commit/d57452bc6cf49ba7cdc62910ebadf7a2dea66007))
8
+
1
9
  ## [0.0.17](https://github.com/involvex/youtube-music-cli/compare/v0.0.16...v0.0.17) (2026-02-18)
2
10
 
3
11
  ### Features
@@ -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 Theme to use (dark, light, midnight, matrix)
31
- --volume, -v Initial volume (0-100)
32
- --shuffle, -s Enable shuffle mode
33
- --repeat, -r Repeat mode (off, all, one)
34
- --headless Run without TUI (just play)
35
- --help, -h Show this help
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
- // Render the app
240
- render(_jsx(App, { flags: cli.flags }));
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
  }
@@ -27,7 +27,7 @@ export default function ShortcutsBar() {
27
27
  useKeyBinding(KEYBINDINGS.SHUFFLE, toggleShuffle);
28
28
  useKeyBinding(KEYBINDINGS.REPEAT, toggleRepeat);
29
29
  // Note: SETTINGS keybinding handled by MainLayout to avoid double-dispatch
30
- return (_jsxs(Box, { borderStyle: "single", borderColor: theme.colors.dim, paddingX: 1, justifyContent: "space-between", children: [_jsxs(Text, { color: theme.colors.dim, children: ["Shortcuts: ", _jsx(Text, { color: theme.colors.text, children: "Space" }), " Play/Pause |", ' ', _jsx(Text, { color: theme.colors.text, children: "\u2192" }), " Next |", ' ', _jsx(Text, { color: theme.colors.text, children: "\u2190" }), " Prev |", ' ', _jsx(Text, { color: theme.colors.text, children: "Shift+S" }), " \uD83D\uDD00 |", ' ', _jsx(Text, { color: theme.colors.text, children: "r" }), " \uD83D\uDD04 |", ' ', _jsx(Text, { color: theme.colors.text, children: "Shift+P" }), " Playlists |", ' ', _jsx(Text, { color: theme.colors.text, children: "Shift+D" }), " Download |", ' ', _jsx(Text, { color: theme.colors.text, children: "m" }), " Mix |", ' ', _jsx(Text, { color: theme.colors.text, children: "/" }), " Search |", ' ', _jsx(Text, { color: theme.colors.text, children: "?" }), " Help |", ' ', _jsx(Text, { color: theme.colors.text, children: "q" }), " Quit"] }), _jsxs(Text, { color: theme.colors.text, children: [_jsx(Text, { color: playerState.shuffle ? theme.colors.primary : theme.colors.dim, children: "\uD83D\uDD00" }), ' ', _jsx(Text, { color: playerState.repeat === 'off'
30
+ return (_jsxs(Box, { borderStyle: "single", borderColor: theme.colors.dim, paddingX: 1, justifyContent: "space-between", children: [_jsxs(Text, { color: theme.colors.dim, children: [_jsx(Text, { color: theme.colors.text, children: "\u2420" }), playerState.isPlaying ? '⏸️' : '▶️', " |", ' ', _jsx(Text, { color: theme.colors.text, children: "\u2190" }), "\u23EA\uFE0F |", ' ', _jsx(Text, { color: theme.colors.text, children: "\u2192" }), "\u23ED\uFE0F |", ' ', _jsx(Text, { color: theme.colors.text, children: "\u21E7S" }), "\uD83D\uDD00 |", ' ', _jsx(Text, { color: theme.colors.text, children: "R" }), "\uD83D\uDD04 |", ' ', _jsx(Text, { color: theme.colors.text, children: "\u21E7P" }), "\uD83D\uDCDA |", ' ', _jsx(Text, { color: theme.colors.text, children: "\u21E7D" }), "\u2B07 |", ' ', _jsx(Text, { color: theme.colors.text, children: "m" }), "\uD83C\uDFB6 |", ' ', _jsx(Text, { color: theme.colors.text, children: "/" }), "\uD83D\uDD0E |", ' ', _jsx(Text, { color: theme.colors.text, children: "?" }), "\u2753 |", ' ', _jsx(Text, { color: theme.colors.text, children: "Q" }), "\u23FB"] }), _jsxs(Text, { color: theme.colors.text, children: [_jsx(Text, { color: playerState.shuffle ? theme.colors.primary : theme.colors.dim, children: "\uD83D\uDD00" }), ' ', _jsx(Text, { color: playerState.repeat === 'off'
31
31
  ? theme.colors.dim
32
- : theme.colors.secondary, children: playerState.repeat === 'one' ? '🔂' : '🔄' }), ' ', _jsx(Text, { color: theme.colors.dim, children: "[=/" }), "-", _jsx(Text, { color: theme.colors.dim, children: "]" }), " Vol:", ' ', _jsxs(Text, { color: theme.colors.primary, children: [playerState.volume, "%"] })] })] }));
32
+ : theme.colors.secondary, children: playerState.repeat === 'one' ? '🔂' : '🔄' }), ' ', _jsx(Text, { color: theme.colors.dim, children: "\uD83D\uDD0A [=/" }), "-", _jsx(Text, { color: theme.colors.dim, children: "]" }), ' ', _jsxs(Text, { color: theme.colors.primary, children: [playerState.volume, "%"] })] })] }));
33
33
  }
@@ -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,6 @@
1
+ import type { ImportProgress } from '../../types/import.types.ts';
2
+ interface Props {
3
+ progress: ImportProgress;
4
+ }
5
+ export default function ImportProgress({ progress }: Props): import("react/jsx-runtime").JSX.Element;
6
+ export {};
@@ -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";
@@ -65,6 +66,9 @@ function MainLayout() {
65
66
  const goToExplore = useCallback(() => {
66
67
  dispatch({ category: 'NAVIGATE', view: VIEW.EXPLORE });
67
68
  }, [dispatch]);
69
+ const goToImport = useCallback(() => {
70
+ dispatch({ category: 'NAVIGATE', view: VIEW.IMPORT });
71
+ }, [dispatch]);
68
72
  const togglePlayerMode = useCallback(() => {
69
73
  dispatch({ category: 'TOGGLE_PLAYER_MODE' });
70
74
  }, [dispatch]);
@@ -80,6 +84,7 @@ function MainLayout() {
80
84
  useKeyBinding(['l'], goToLyrics);
81
85
  useKeyBinding(['T'], goToTrending);
82
86
  useKeyBinding(['e'], goToExplore);
87
+ useKeyBinding(['i'], goToImport);
83
88
  // Memoize the view component to prevent unnecessary remounts
84
89
  // Only recreate when currentView actually changes
85
90
  const currentView = useMemo(() => {
@@ -114,6 +119,8 @@ function MainLayout() {
114
119
  return _jsx(TrendingLayout, {}, "trending");
115
120
  case 'explore':
116
121
  return _jsx(ExploreLayout, {}, "explore");
122
+ case 'import':
123
+ return _jsx(ImportLayout, {}, "import");
117
124
  case 'help':
118
125
  return _jsx(Help, {}, "help");
119
126
  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.KEYBINDINGS });
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: "Custom Keybindings \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: "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" }) })] }));
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 {};