@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
@@ -0,0 +1,253 @@
1
+ // WebSocket server for web UI
2
+ import { createServer as createHttpServer, } from 'node:http';
3
+ import { WebSocketServer, WebSocket } from 'ws';
4
+ import { getWebStreamingService } from "./web-streaming.service.js";
5
+ import { getStaticFileService } from "./static-file.service.js";
6
+ import { logger } from "../logger/logger.service.js";
7
+ class WebSocketServerClass {
8
+ httpServer = null;
9
+ wsServer = null;
10
+ config;
11
+ streamingService = getWebStreamingService();
12
+ staticFileService = getStaticFileService();
13
+ onCommand;
14
+ onImportRequest;
15
+ constructor() {
16
+ this.config = {
17
+ enabled: false,
18
+ host: 'localhost',
19
+ port: 8080,
20
+ enableCors: true,
21
+ allowedOrigins: ['*'],
22
+ auth: { enabled: false },
23
+ };
24
+ }
25
+ /**
26
+ * Start the WebSocket server
27
+ */
28
+ async start(options) {
29
+ this.config = options.config;
30
+ this.onCommand = options.onCommand;
31
+ this.onImportRequest = options.onImportRequest;
32
+ logger.info('WebSocketServer', 'Starting server', {
33
+ host: this.config.host,
34
+ port: this.config.port,
35
+ auth: this.config.auth.enabled,
36
+ });
37
+ // Create HTTP server
38
+ this.httpServer = createHttpServer((req, res) => {
39
+ this.handleHttpRequest(req, res);
40
+ });
41
+ // Create WebSocket server
42
+ this.wsServer = new WebSocketServer({
43
+ server: this.httpServer,
44
+ path: '/ws',
45
+ });
46
+ // Handle WebSocket connections
47
+ this.wsServer.on('connection', (ws, req) => {
48
+ this.handleWebSocketConnection(ws, req);
49
+ });
50
+ // Handle HTTP server errors
51
+ this.httpServer.on('error', error => {
52
+ logger.error('WebSocketServer', 'HTTP server error', {
53
+ error: error instanceof Error ? error.message : String(error),
54
+ });
55
+ });
56
+ // Handle WebSocket server errors
57
+ this.wsServer.on('error', error => {
58
+ logger.error('WebSocketServer', 'WebSocket server error', {
59
+ error: error instanceof Error ? error.message : String(error),
60
+ });
61
+ });
62
+ // Start listening
63
+ return new Promise((resolve, reject) => {
64
+ this.httpServer.listen({
65
+ host: this.config.host,
66
+ port: this.config.port,
67
+ }, () => {
68
+ logger.info('WebSocketServer', 'Server started', {
69
+ url: `http://${this.config.host}:${this.config.port}`,
70
+ });
71
+ resolve();
72
+ });
73
+ this.httpServer.on('error', reject);
74
+ });
75
+ }
76
+ /**
77
+ * Handle HTTP requests (for static file serving)
78
+ */
79
+ handleHttpRequest(req, res) {
80
+ // Set CORS headers if enabled
81
+ if (this.config.enableCors) {
82
+ const origin = req.headers.origin;
83
+ if (origin &&
84
+ (this.config.allowedOrigins.includes('*') ||
85
+ this.config.allowedOrigins.includes(origin))) {
86
+ res.setHeader('Access-Control-Allow-Origin', origin);
87
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
88
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
89
+ }
90
+ }
91
+ // Handle preflight requests
92
+ if (req.method === 'OPTIONS') {
93
+ res.writeHead(204);
94
+ res.end();
95
+ return;
96
+ }
97
+ // Serve static files
98
+ const url = req.url ?? '/';
99
+ this.staticFileService.serve(url, req, res);
100
+ }
101
+ /**
102
+ * Handle WebSocket connection
103
+ */
104
+ handleWebSocketConnection(ws, req) {
105
+ const clientId = this.generateClientId();
106
+ logger.info('WebSocketServer', 'New connection', {
107
+ clientId,
108
+ ip: req.socket.remoteAddress,
109
+ });
110
+ // Handle authentication if enabled
111
+ if (this.config.auth.enabled) {
112
+ const token = this.extractAuthToken(req);
113
+ if (!token || token !== this.config.auth.token) {
114
+ logger.warn('WebSocketServer', 'Authentication failed', { clientId });
115
+ ws.close(1008, 'Authentication failed');
116
+ return;
117
+ }
118
+ }
119
+ // Add client to streaming service
120
+ this.streamingService.addClient(clientId, ws, true);
121
+ // Send welcome message
122
+ this.sendToClient(ws, {
123
+ type: 'auth',
124
+ success: true,
125
+ message: 'Connected to YouTube Music CLI',
126
+ });
127
+ // Handle incoming messages
128
+ ws.on('message', (data) => {
129
+ try {
130
+ const message = JSON.parse(data.toString());
131
+ this.streamingService.handleClientMessage(clientId, message);
132
+ this.handleClientCommand(clientId, message);
133
+ }
134
+ catch (error) {
135
+ logger.error('WebSocketServer', 'Failed to parse message', {
136
+ clientId,
137
+ error: error instanceof Error ? error.message : String(error),
138
+ });
139
+ }
140
+ });
141
+ // Handle close
142
+ ws.on('close', () => {
143
+ this.streamingService.removeClient(clientId);
144
+ });
145
+ // Handle errors
146
+ ws.on('error', error => {
147
+ logger.error('WebSocketServer', 'WebSocket error', {
148
+ error: error instanceof Error ? error.message : String(error),
149
+ });
150
+ });
151
+ }
152
+ /**
153
+ * Handle commands from clients
154
+ */
155
+ handleClientCommand(_clientId, message) {
156
+ switch (message.type) {
157
+ case 'command':
158
+ if (this.onCommand) {
159
+ this.onCommand(message.action);
160
+ }
161
+ break;
162
+ case 'import-request':
163
+ if (this.onImportRequest) {
164
+ this.onImportRequest(message.source, message.url, message.name);
165
+ }
166
+ break;
167
+ case 'auth-request':
168
+ // Already handled in connection phase
169
+ break;
170
+ }
171
+ }
172
+ /**
173
+ * Send message to a specific WebSocket client
174
+ */
175
+ sendToClient(ws, message) {
176
+ if (ws.readyState === WebSocket.OPEN) {
177
+ try {
178
+ ws.send(JSON.stringify(message));
179
+ }
180
+ catch (error) {
181
+ logger.error('WebSocketServer', 'Failed to send message', {
182
+ error: error instanceof Error ? error.message : String(error),
183
+ });
184
+ }
185
+ }
186
+ }
187
+ /**
188
+ * Generate a unique client ID
189
+ */
190
+ generateClientId() {
191
+ return `client_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
192
+ }
193
+ /**
194
+ * Extract auth token from request
195
+ */
196
+ extractAuthToken(req) {
197
+ // Check Authorization header
198
+ const authHeader = req.headers.authorization;
199
+ if (authHeader?.startsWith('Bearer ')) {
200
+ return authHeader.substring(7);
201
+ }
202
+ // Check query parameter
203
+ const url = req.url ?? '';
204
+ const urlObj = new URL(url, `http://${req.headers.host}`);
205
+ return urlObj.searchParams.get('token');
206
+ }
207
+ /**
208
+ * Stop the server
209
+ */
210
+ async stop() {
211
+ logger.info('WebSocketServer', 'Stopping server');
212
+ // Disconnect all clients
213
+ this.streamingService.disconnectAll();
214
+ // Close WebSocket server
215
+ if (this.wsServer) {
216
+ this.wsServer.close();
217
+ this.wsServer = null;
218
+ }
219
+ // Close HTTP server
220
+ if (this.httpServer) {
221
+ return new Promise(resolve => {
222
+ this.httpServer.close(() => {
223
+ this.httpServer = null;
224
+ logger.info('WebSocketServer', 'Server stopped');
225
+ resolve();
226
+ });
227
+ });
228
+ }
229
+ }
230
+ /**
231
+ * Check if server is running
232
+ */
233
+ isRunning() {
234
+ return this.httpServer !== null;
235
+ }
236
+ /**
237
+ * Get server URL
238
+ */
239
+ getServerUrl() {
240
+ if (!this.isRunning()) {
241
+ throw new Error('Server is not running');
242
+ }
243
+ return `http://${this.config.host}:${this.config.port}`;
244
+ }
245
+ }
246
+ // Singleton instance
247
+ let webSocketServerInstance = null;
248
+ export function getWebSocketServer() {
249
+ if (!webSocketServerInstance) {
250
+ webSocketServerInstance = new WebSocketServerClass();
251
+ }
252
+ return webSocketServerInstance;
253
+ }
@@ -8,6 +8,8 @@ import { getNotificationService } from "../services/notification/notification.se
8
8
  import { getScrobblingService } from "../services/scrobbling/scrobbling.service.js";
9
9
  import { getDiscordRpcService } from "../services/discord/discord-rpc.service.js";
10
10
  import { getMprisService } from "../services/mpris/mpris.service.js";
11
+ import { getWebServerManager } from "../services/web/web-server-manager.js";
12
+ import { getWebStreamingService } from "../services/web/web-streaming.service.js";
11
13
  const initialState = {
12
14
  currentTrack: null,
13
15
  isPlaying: false,
@@ -58,6 +60,7 @@ export function playerReducer(state, action) {
58
60
  ...state,
59
61
  queuePosition: randomIndex,
60
62
  currentTrack: state.queue[randomIndex] ?? null,
63
+ isPlaying: true,
61
64
  progress: 0,
62
65
  };
63
66
  }
@@ -69,6 +72,7 @@ export function playerReducer(state, action) {
69
72
  ...state,
70
73
  queuePosition: 0,
71
74
  currentTrack: state.queue[0] ?? null,
75
+ isPlaying: true,
72
76
  progress: 0,
73
77
  };
74
78
  }
@@ -78,6 +82,7 @@ export function playerReducer(state, action) {
78
82
  ...state,
79
83
  queuePosition: nextPosition,
80
84
  currentTrack: state.queue[nextPosition] ?? null,
85
+ isPlaying: true,
81
86
  progress: 0,
82
87
  };
83
88
  }
@@ -548,6 +553,28 @@ export function PlayerProvider({ children }) {
548
553
  // Only register handlers once, update via ref
549
554
  // eslint-disable-next-line react-hooks/exhaustive-deps
550
555
  }, []);
556
+ // Web streaming: Broadcast state changes to connected clients
557
+ useEffect(() => {
558
+ // Initialize web streaming service and set up command handler
559
+ const streamingService = getWebStreamingService();
560
+ // Set up handler for incoming commands from web clients
561
+ const unsubscribe = streamingService.onMessage(message => {
562
+ if (message.type === 'command') {
563
+ dispatch(message.action);
564
+ }
565
+ });
566
+ return () => {
567
+ unsubscribe();
568
+ };
569
+ }, [dispatch]);
570
+ // Broadcast state changes to web clients
571
+ useEffect(() => {
572
+ const webServerManager = getWebServerManager();
573
+ if (webServerManager.isServerRunning()) {
574
+ const streamingService = getWebStreamingService();
575
+ streamingService.onStateChange(state);
576
+ }
577
+ }, [state]);
551
578
  const actions = useMemo(() => ({
552
579
  play: (track) => {
553
580
  logger.info('PlayerProvider', 'play() action dispatched', {
@@ -11,4 +11,12 @@ export interface Flags {
11
11
  showSuggestions?: boolean;
12
12
  headless?: boolean;
13
13
  action?: 'pause' | 'resume' | 'next' | 'previous';
14
+ importSource?: 'spotify' | 'youtube';
15
+ importUrl?: string;
16
+ importName?: string;
17
+ web?: boolean;
18
+ webHost?: string;
19
+ webPort?: number;
20
+ webOnly?: boolean;
21
+ webAuth?: string;
14
22
  }
@@ -1,5 +1,6 @@
1
1
  import type { Playlist } from './youtube-music.types.ts';
2
2
  import type { Theme } from './theme.types.ts';
3
+ import type { WebServerConfig } from './web.types.ts';
3
4
  export type RepeatMode = 'off' | 'all' | 'one';
4
5
  export type DownloadFormat = 'mp3' | 'm4a';
5
6
  export interface KeybindingConfig {
@@ -34,4 +35,5 @@ export interface Config {
34
35
  downloadsEnabled?: boolean;
35
36
  downloadDirectory?: string;
36
37
  downloadFormat?: DownloadFormat;
38
+ webServer?: WebServerConfig;
37
39
  }
@@ -0,0 +1,72 @@
1
+ import type { Track } from './youtube-music.types.ts';
2
+ /** Supported import sources */
3
+ export type ImportSource = 'spotify' | 'youtube';
4
+ /** Import operation status */
5
+ export type ImportStatus = 'idle' | 'fetching' | 'matching' | 'creating' | 'completed' | 'failed' | 'cancelled';
6
+ /** Match confidence level */
7
+ export type MatchConfidence = 'high' | 'medium' | 'low' | 'none';
8
+ /** Import progress information */
9
+ export interface ImportProgress {
10
+ status: ImportStatus;
11
+ current: number;
12
+ total: number;
13
+ currentTrack?: string;
14
+ message: string;
15
+ }
16
+ /** Track match result */
17
+ export interface TrackMatch {
18
+ originalTrack: SpotifyTrack | YouTubeTrack;
19
+ matchedTrack: Track | null;
20
+ confidence: MatchConfidence;
21
+ error?: string;
22
+ }
23
+ /** Import result summary */
24
+ export interface ImportResult {
25
+ playlistId: string;
26
+ playlistName: string;
27
+ source: ImportSource;
28
+ total: number;
29
+ matched: number;
30
+ failed: number;
31
+ matches: TrackMatch[];
32
+ errors: string[];
33
+ duration: number;
34
+ }
35
+ /** Spotify track data */
36
+ export interface SpotifyTrack {
37
+ id: string;
38
+ name: string;
39
+ artists: string[];
40
+ album?: string;
41
+ duration: number;
42
+ trackNumber?: number;
43
+ }
44
+ /** Spotify playlist data */
45
+ export interface SpotifyPlaylist {
46
+ id: string;
47
+ name: string;
48
+ description?: string;
49
+ tracks: SpotifyTrack[];
50
+ isPublic: boolean;
51
+ owner?: string;
52
+ url?: string;
53
+ }
54
+ /** YouTube track data (for import) */
55
+ export interface YouTubeTrack {
56
+ id: string;
57
+ title: string;
58
+ name: string;
59
+ artists: string[];
60
+ album?: string;
61
+ duration: number;
62
+ thumbnail?: string;
63
+ }
64
+ /** YouTube playlist data (for import) */
65
+ export interface YouTubePlaylist {
66
+ id: string;
67
+ name: string;
68
+ description?: string;
69
+ tracks: YouTubeTrack[];
70
+ channelTitle?: string;
71
+ url?: string;
72
+ }
@@ -0,0 +1,2 @@
1
+ // Playlist import type definitions
2
+ export {};
@@ -0,0 +1,89 @@
1
+ import type { PlayerAction, PlayerState } from './player.types.ts';
2
+ import type { ImportProgress, ImportResult } from './import.types.ts';
3
+ /** WebSocket server message types */
4
+ export type ServerMessage = StateUpdateMessage | EventMessage | ErrorMessage | AuthMessage | ImportProgressMessage | ImportResultMessage;
5
+ /** Player state update message */
6
+ export interface StateUpdateMessage {
7
+ type: 'state-update';
8
+ state: Partial<PlayerState>;
9
+ }
10
+ /** Event message for one-time events */
11
+ export interface EventMessage {
12
+ type: 'event';
13
+ event: 'connected' | 'disconnected' | 'client-connected' | 'client-disconnected';
14
+ data?: unknown;
15
+ }
16
+ /** Error message from server */
17
+ export interface ErrorMessage {
18
+ type: 'error';
19
+ error: string;
20
+ code?: string;
21
+ }
22
+ /** Authentication message */
23
+ export interface AuthMessage {
24
+ type: 'auth';
25
+ success: boolean;
26
+ message?: string;
27
+ }
28
+ /** Import progress message */
29
+ export interface ImportProgressMessage {
30
+ type: 'import-progress';
31
+ data: ImportProgress;
32
+ }
33
+ /** Import result message */
34
+ export interface ImportResultMessage {
35
+ type: 'import-result';
36
+ data: ImportResult;
37
+ }
38
+ /** WebSocket client message types */
39
+ export type ClientMessage = CommandMessage | AuthRequestMessage | ImportRequestMessage;
40
+ /** Command message from client */
41
+ export interface CommandMessage {
42
+ type: 'command';
43
+ action: PlayerAction;
44
+ }
45
+ /** Authentication request from client */
46
+ export interface AuthRequestMessage {
47
+ type: 'auth-request';
48
+ token: string;
49
+ }
50
+ /** Import request from client */
51
+ export interface ImportRequestMessage {
52
+ type: 'import-request';
53
+ source: 'spotify' | 'youtube';
54
+ url: string;
55
+ name?: string;
56
+ }
57
+ /** WebSocket client information */
58
+ export interface WebSocketClient {
59
+ id: string;
60
+ authenticated: boolean;
61
+ connectedAt: number;
62
+ lastHeartbeat: number;
63
+ }
64
+ /** Web server configuration */
65
+ export interface WebServerConfig {
66
+ enabled: boolean;
67
+ host: string;
68
+ port: number;
69
+ enableCors: boolean;
70
+ allowedOrigins: string[];
71
+ auth: {
72
+ enabled: boolean;
73
+ token?: string;
74
+ };
75
+ }
76
+ /** Web server options for CLI flags */
77
+ export interface WebServerOptions {
78
+ enabled: boolean;
79
+ host?: string;
80
+ port?: number;
81
+ webOnly?: boolean;
82
+ auth?: string;
83
+ }
84
+ /** Server statistics */
85
+ export interface ServerStats {
86
+ uptime: number;
87
+ clients: number;
88
+ totalConnections: number;
89
+ }
@@ -0,0 +1,2 @@
1
+ // Web UI and WebSocket type definitions
2
+ export {};
@@ -18,6 +18,7 @@ export declare const VIEW: {
18
18
  readonly KEYBINDINGS: "keybindings";
19
19
  readonly TRENDING: "trending";
20
20
  readonly EXPLORE: "explore";
21
+ readonly IMPORT: "import";
21
22
  };
22
23
  export declare const SEARCH_TYPE: {
23
24
  readonly ALL: "all";
@@ -23,6 +23,7 @@ export const VIEW = {
23
23
  KEYBINDINGS: 'keybindings',
24
24
  TRENDING: 'trending',
25
25
  EXPLORE: 'explore',
26
+ IMPORT: 'import',
26
27
  };
27
28
  // Search types
28
29
  export const SEARCH_TYPE = {
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@involvex/youtube-music-cli",
3
- "version": "0.0.17",
3
+ "version": "0.0.19",
4
4
  "description": "- A Commandline music player for youtube-music",
5
5
  "repository": {
6
6
  "type": "git",
@@ -51,7 +51,10 @@
51
51
  "typecheck": "tsc --noEmit",
52
52
  "changelog": "conventional-changelog -p angular -i CHANGELOG.md -s -r 0",
53
53
  "clean": "rimraf dist",
54
- "release": "powershell -File scripts/release.ps1"
54
+ "release": "powershell -File scripts/release.ps1",
55
+ "build:web": "cd web && bun run build",
56
+ "dev:web": "cd web && bun run dev",
57
+ "build:all": "bun run build && bun run build:web"
55
58
  },
56
59
  "prettier": "@vdemedes/prettier-config",
57
60
  "ava": {
@@ -73,7 +76,8 @@
73
76
  "play-sound": "^1.1.6",
74
77
  "react": "^19.2.4",
75
78
  "youtube-ext": "^1.1.25",
76
- "youtubei.js": "^16.0.1"
79
+ "youtubei.js": "^16.0.1",
80
+ "ws": "^8.18.0"
77
81
  },
78
82
  "devDependencies": {
79
83
  "@eslint/js": "^10.0.1",
@@ -81,6 +85,7 @@
81
85
  "@types/node": "^25.2.3",
82
86
  "@types/node-notifier": "^8.0.5",
83
87
  "@types/react": "^19.2.14",
88
+ "@types/ws": "^8.5.13",
84
89
  "@vdemedes/prettier-config": "^2.0.1",
85
90
  "ava": "^6.4.1",
86
91
  "chalk": "^5.6.2",