@involvex/youtube-music-cli 0.0.47 → 0.0.48

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 (111) hide show
  1. package/CHANGELOG.md +2 -0
  2. package/dist/cli.js.map +3 -3
  3. package/dist/youtube-music-cli +0 -0
  4. package/package.json +1 -1
  5. package/dist/eslint.config.js +0 -55
  6. package/dist/package.json +0 -120
  7. package/dist/scripts/build-cli.js +0 -46
  8. package/dist/source/app.js +0 -17
  9. package/dist/source/cli.js +0 -504
  10. package/dist/source/components/common/ErrorBoundary.js +0 -22
  11. package/dist/source/components/common/Help.js +0 -18
  12. package/dist/source/components/common/ShortcutsBar.js +0 -89
  13. package/dist/source/components/config/ConfigLayout.js +0 -84
  14. package/dist/source/components/config/KeybindingsLayout.js +0 -107
  15. package/dist/source/components/export/ExportLayout.js +0 -111
  16. package/dist/source/components/import/ImportLayout.js +0 -119
  17. package/dist/source/components/import/ImportProgress.js +0 -73
  18. package/dist/source/components/layouts/ExploreLayout.js +0 -72
  19. package/dist/source/components/layouts/HistoryLayout.js +0 -37
  20. package/dist/source/components/layouts/LyricsLayout.js +0 -89
  21. package/dist/source/components/layouts/MainLayout.js +0 -190
  22. package/dist/source/components/layouts/MiniPlayerLayout.js +0 -20
  23. package/dist/source/components/layouts/PlayerLayout.js +0 -9
  24. package/dist/source/components/layouts/PluginsLayout.js +0 -77
  25. package/dist/source/components/layouts/SearchLayout.js +0 -193
  26. package/dist/source/components/layouts/TrendingLayout.js +0 -59
  27. package/dist/source/components/player/NowPlaying.js +0 -45
  28. package/dist/source/components/player/PlayerControls.js +0 -83
  29. package/dist/source/components/player/ProgressBar.js +0 -19
  30. package/dist/source/components/player/QueueList.js +0 -36
  31. package/dist/source/components/player/Suggestions.js +0 -50
  32. package/dist/source/components/playlist/PlaylistList.js +0 -138
  33. package/dist/source/components/plugins/PluginInstallDialog.js +0 -41
  34. package/dist/source/components/plugins/PluginsAvailable.js +0 -55
  35. package/dist/source/components/plugins/PluginsList.js +0 -18
  36. package/dist/source/components/search/SearchBar.js +0 -55
  37. package/dist/source/components/search/SearchHistory.js +0 -35
  38. package/dist/source/components/search/SearchResults.js +0 -280
  39. package/dist/source/components/settings/Settings.js +0 -211
  40. package/dist/source/components/theme/ThemeSwitcher.js +0 -11
  41. package/dist/source/config/themes.config.js +0 -123
  42. package/dist/source/contexts/theme.context.js +0 -29
  43. package/dist/source/hooks/useKeyboard.js +0 -188
  44. package/dist/source/hooks/useKeyboardBlocker.js +0 -45
  45. package/dist/source/hooks/useNavigation.js +0 -5
  46. package/dist/source/hooks/usePlayer.js +0 -43
  47. package/dist/source/hooks/usePlaylist.js +0 -65
  48. package/dist/source/hooks/useSearch.js +0 -76
  49. package/dist/source/hooks/useSleepTimer.js +0 -48
  50. package/dist/source/hooks/useTerminalSize.js +0 -24
  51. package/dist/source/hooks/useTheme.js +0 -5
  52. package/dist/source/hooks/useYouTubeMusic.js +0 -112
  53. package/dist/source/main.js +0 -127
  54. package/dist/source/services/cache/cache.service.js +0 -67
  55. package/dist/source/services/completions/completions.service.js +0 -313
  56. package/dist/source/services/config/config.service.js +0 -191
  57. package/dist/source/services/discord/discord-rpc.service.js +0 -95
  58. package/dist/source/services/download/download.service.js +0 -350
  59. package/dist/source/services/export/export.service.js +0 -131
  60. package/dist/source/services/history/history.service.js +0 -83
  61. package/dist/source/services/import/import.service.js +0 -272
  62. package/dist/source/services/import/spotify.service.js +0 -171
  63. package/dist/source/services/import/track-matcher.service.js +0 -271
  64. package/dist/source/services/import/youtube-import.service.js +0 -84
  65. package/dist/source/services/logger/logger.service.js +0 -52
  66. package/dist/source/services/lyrics/lyrics.service.js +0 -93
  67. package/dist/source/services/mpris/mpris.service.js +0 -78
  68. package/dist/source/services/notification/notification.service.js +0 -57
  69. package/dist/source/services/player/dependency-check.service.js +0 -140
  70. package/dist/source/services/player/player.service.js +0 -478
  71. package/dist/source/services/player-state/player-state.service.js +0 -123
  72. package/dist/source/services/plugin/plugin-audio-api.js +0 -36
  73. package/dist/source/services/plugin/plugin-context.js +0 -256
  74. package/dist/source/services/plugin/plugin-hooks.service.js +0 -135
  75. package/dist/source/services/plugin/plugin-installer.service.js +0 -248
  76. package/dist/source/services/plugin/plugin-loader.service.js +0 -161
  77. package/dist/source/services/plugin/plugin-permissions.service.js +0 -194
  78. package/dist/source/services/plugin/plugin-registry.service.js +0 -215
  79. package/dist/source/services/plugin/plugin-ui-api.js +0 -46
  80. package/dist/source/services/plugin/plugin-updater.service.js +0 -206
  81. package/dist/source/services/scrobbling/scrobbling.service.js +0 -115
  82. package/dist/source/services/sleep-timer/sleep-timer.service.js +0 -45
  83. package/dist/source/services/version-check/version-check.service.js +0 -121
  84. package/dist/source/services/web/static-file.service.js +0 -185
  85. package/dist/source/services/web/web-server-manager.js +0 -507
  86. package/dist/source/services/web/web-streaming.service.js +0 -292
  87. package/dist/source/services/web/websocket.server.js +0 -267
  88. package/dist/source/services/youtube-music/api.js +0 -649
  89. package/dist/source/services/youtube-music/search.service.js +0 -38
  90. package/dist/source/stores/history.store.js +0 -64
  91. package/dist/source/stores/navigation.store.js +0 -90
  92. package/dist/source/stores/player.store.js +0 -789
  93. package/dist/source/stores/plugins.store.js +0 -177
  94. package/dist/source/types/actions.js +0 -1
  95. package/dist/source/types/cli.types.js +0 -1
  96. package/dist/source/types/config.types.js +0 -1
  97. package/dist/source/types/history.types.js +0 -1
  98. package/dist/source/types/import.types.js +0 -2
  99. package/dist/source/types/keyboard.types.js +0 -1
  100. package/dist/source/types/navigation.types.js +0 -1
  101. package/dist/source/types/player.types.js +0 -1
  102. package/dist/source/types/playlist.types.js +0 -1
  103. package/dist/source/types/plugin.types.js +0 -1
  104. package/dist/source/types/theme.types.js +0 -1
  105. package/dist/source/types/web.types.js +0 -2
  106. package/dist/source/types/youtube-music.types.js +0 -1
  107. package/dist/source/types/youtubei.types.js +0 -3
  108. package/dist/source/utils/constants.js +0 -135
  109. package/dist/source/utils/format.js +0 -24
  110. package/dist/source/utils/icons.js +0 -28
  111. package/dist/source/utils/search-filters.js +0 -100
@@ -1,140 +0,0 @@
1
- import { spawn } from 'node:child_process';
2
- import { createInterface } from 'node:readline/promises';
3
- const REQUIRED_DEPENDENCIES = ['mpv', 'yt-dlp'];
4
- function getDependencyExecutable(dependency) {
5
- if (process.platform === 'win32') {
6
- return dependency === 'mpv' ? 'mpv.exe' : 'yt-dlp.exe';
7
- }
8
- return dependency;
9
- }
10
- function renderInstallCommand(plan) {
11
- return [plan.command, ...plan.args].join(' ');
12
- }
13
- function runCommand(command, args, options) {
14
- return new Promise(resolve => {
15
- const child = spawn(command, args, {
16
- stdio: options.stdio,
17
- });
18
- child.once('error', () => {
19
- resolve(false);
20
- });
21
- child.once('close', code => {
22
- resolve(code === 0);
23
- });
24
- });
25
- }
26
- async function commandExists(command) {
27
- return runCommand(command, ['--version'], { stdio: 'ignore' });
28
- }
29
- async function getMissingDependencies() {
30
- const missing = [];
31
- for (const dependency of REQUIRED_DEPENDENCIES) {
32
- const executable = getDependencyExecutable(dependency);
33
- const exists = await commandExists(executable);
34
- if (!exists) {
35
- missing.push(dependency);
36
- }
37
- }
38
- return missing;
39
- }
40
- export function buildInstallPlan(platform, availableManagers, missingDependencies) {
41
- if (missingDependencies.length === 0) {
42
- return null;
43
- }
44
- const deps = [...missingDependencies];
45
- const hasManager = (manager) => availableManagers.includes(manager);
46
- if ((platform === 'darwin' || platform === 'linux') && hasManager('brew')) {
47
- return { command: 'brew', args: ['install', ...deps] };
48
- }
49
- if (platform === 'win32') {
50
- if (hasManager('scoop')) {
51
- return { command: 'scoop', args: ['install', ...deps] };
52
- }
53
- if (hasManager('choco')) {
54
- return { command: 'choco', args: ['install', ...deps, '-y'] };
55
- }
56
- return null;
57
- }
58
- if (platform === 'linux') {
59
- if (hasManager('apt-get')) {
60
- return {
61
- command: 'sudo',
62
- args: ['apt-get', 'install', '-y', ...deps],
63
- };
64
- }
65
- if (hasManager('pacman')) {
66
- return {
67
- command: 'sudo',
68
- args: ['pacman', '-S', '--needed', ...deps],
69
- };
70
- }
71
- if (hasManager('dnf')) {
72
- return {
73
- command: 'sudo',
74
- args: ['dnf', 'install', '-y', ...deps],
75
- };
76
- }
77
- }
78
- return null;
79
- }
80
- async function getAvailablePackageManagers(platform) {
81
- const candidates = platform === 'win32'
82
- ? ['scoop', 'choco']
83
- : platform === 'darwin'
84
- ? ['brew']
85
- : ['brew', 'apt-get', 'pacman', 'dnf'];
86
- const available = [];
87
- for (const manager of candidates) {
88
- if (await commandExists(manager)) {
89
- available.push(manager);
90
- }
91
- }
92
- return available;
93
- }
94
- function printManualInstallHelp(missing, plan) {
95
- console.error(`\nMissing playback dependencies: ${missing.join(', ')}. Install them and re-run the command.`);
96
- if (plan) {
97
- console.error(`Suggested install command: ${renderInstallCommand(plan)}\n`);
98
- return;
99
- }
100
- console.error('Suggested install commands:\n macOS: brew install mpv yt-dlp\n Windows: scoop install mpv yt-dlp\n Linux (apt): sudo apt-get install -y mpv yt-dlp\n');
101
- }
102
- export async function ensurePlaybackDependencies(options) {
103
- const missing = await getMissingDependencies();
104
- if (missing.length === 0) {
105
- return { ready: true, missing: [] };
106
- }
107
- const availableManagers = await getAvailablePackageManagers(process.platform);
108
- const installPlan = buildInstallPlan(process.platform, availableManagers, missing);
109
- if (!options.interactive || !installPlan) {
110
- printManualInstallHelp(missing, installPlan);
111
- return { ready: false, missing };
112
- }
113
- const prompt = `Missing ${missing.join(', ')}. Install now with "${renderInstallCommand(installPlan)}"? [Y/n] `;
114
- const readline = createInterface({
115
- input: process.stdin,
116
- output: process.stdout,
117
- });
118
- const response = (await readline.question(prompt)).trim().toLowerCase();
119
- readline.close();
120
- if (response === 'n' || response === 'no') {
121
- printManualInstallHelp(missing, installPlan);
122
- return { ready: false, missing };
123
- }
124
- console.log(`\nInstalling dependencies: ${missing.join(', ')}`);
125
- const installSuccess = await runCommand(installPlan.command, installPlan.args, {
126
- stdio: 'inherit',
127
- });
128
- if (!installSuccess) {
129
- console.error('\nAutomatic installation failed.');
130
- printManualInstallHelp(missing, installPlan);
131
- return { ready: false, missing };
132
- }
133
- const missingAfterInstall = await getMissingDependencies();
134
- if (missingAfterInstall.length > 0) {
135
- printManualInstallHelp(missingAfterInstall, installPlan);
136
- return { ready: false, missing: missingAfterInstall };
137
- }
138
- console.log('Playback dependencies installed successfully.\n');
139
- return { ready: true, missing: [] };
140
- }
@@ -1,478 +0,0 @@
1
- // Audio playback service using mpv media player with IPC control
2
- import { spawn } from 'node:child_process';
3
- import { connect } from 'node:net';
4
- import { logger } from "../logger/logger.service.js";
5
- export function buildMpvArgs(url, ipcPath, options) {
6
- const gapless = options.gaplessPlayback ?? true;
7
- const crossfadeDuration = Math.max(0, options.crossfadeDuration ?? 0);
8
- const fadeDuration = Math.max(0, options.volumeFadeDuration ?? 0);
9
- const eqPreset = options.equalizerPreset ?? 'flat';
10
- const audioFilters = [];
11
- if (options.audioNormalization) {
12
- audioFilters.push('dynaudnorm');
13
- }
14
- if (fadeDuration > 0) {
15
- audioFilters.push(`afade=t=in:st=0:d=${fadeDuration}`);
16
- audioFilters.push(`afade=t=out:d=${fadeDuration}`);
17
- }
18
- if (crossfadeDuration > 0) {
19
- audioFilters.push(`acrossfade=d=${crossfadeDuration}`);
20
- }
21
- const presetFilters = EQUALIZER_PRESET_FILTERS[eqPreset] ?? [];
22
- if (presetFilters.length > 0) {
23
- audioFilters.push(...presetFilters);
24
- }
25
- const mpvArgs = [
26
- '--no-video',
27
- '--no-terminal',
28
- `--volume=${options.volume}`,
29
- '--no-audio-display',
30
- '--really-quiet',
31
- '--msg-level=all=error',
32
- `--input-ipc-server=${ipcPath}`,
33
- '--idle=yes',
34
- '--cache=yes',
35
- '--cache-secs=30',
36
- '--network-timeout=10',
37
- `--gapless-audio=${gapless ? 'yes' : 'no'}`,
38
- ];
39
- if (audioFilters.length > 0) {
40
- mpvArgs.push(`--af=${audioFilters.join(',')}`);
41
- }
42
- if (options.proxy) {
43
- mpvArgs.push(`--http-proxy=${options.proxy}`);
44
- }
45
- mpvArgs.push(url);
46
- return mpvArgs;
47
- }
48
- const EQUALIZER_PRESET_FILTERS = {
49
- flat: [],
50
- bass_boost: ['equalizer=f=60:width_type=o:width=2:g=5'],
51
- vocal: ['equalizer=f=2500:width_type=o:width=2:g=3'],
52
- bright: [
53
- 'equalizer=f=4000:width_type=o:width=2:g=3',
54
- 'equalizer=f=8000:width_type=o:width=2:g=2',
55
- ],
56
- warm: [
57
- 'equalizer=f=100:width_type=o:width=2:g=4',
58
- 'equalizer=f=250:width_type=o:width=2:g=2',
59
- ],
60
- };
61
- class PlayerService {
62
- static instance;
63
- mpvProcess = null;
64
- ipcSocket = null;
65
- ipcPath = null;
66
- currentUrl = null;
67
- currentVolume = 70;
68
- isPlaying = false;
69
- eventCallback = null;
70
- ipcConnectRetries = 0;
71
- maxIpcRetries = 10;
72
- currentTrackId = null; // Track currently playing
73
- playSessionId = 0; // Incremented per play() call for unique IPC paths
74
- constructor() { }
75
- static getInstance() {
76
- if (!PlayerService.instance) {
77
- PlayerService.instance = new PlayerService();
78
- }
79
- return PlayerService.instance;
80
- }
81
- getCurrentTrackId() {
82
- return this.currentTrackId;
83
- }
84
- /**
85
- * Register callback for player events (time position, duration updates)
86
- */
87
- onEvent(callback) {
88
- this.eventCallback = callback;
89
- }
90
- /**
91
- * Generate IPC socket path based on platform, unique per play session
92
- */
93
- getIpcPath() {
94
- if (process.platform === 'win32') {
95
- // Windows named pipe
96
- return `\\\\.\\pipe\\mpvsocket-${process.pid}-${this.playSessionId}`;
97
- }
98
- else {
99
- // Unix domain socket
100
- return `/tmp/mpvsocket-${process.pid}-${this.playSessionId}`;
101
- }
102
- }
103
- getMpvCommand() {
104
- const configuredPath = process.env['MPV_PATH']?.trim();
105
- if (configuredPath) {
106
- return configuredPath;
107
- }
108
- return process.platform === 'win32' ? 'mpv.exe' : 'mpv';
109
- }
110
- /**
111
- * Connect to mpv IPC socket
112
- */
113
- async connectIpc() {
114
- if (!this.ipcPath) {
115
- throw new Error('IPC path not set');
116
- }
117
- return new Promise((resolve, reject) => {
118
- const attemptConnect = () => {
119
- logger.debug('PlayerService', 'Attempting IPC connection', {
120
- path: this.ipcPath,
121
- attempt: this.ipcConnectRetries + 1,
122
- });
123
- this.ipcSocket = connect(this.ipcPath);
124
- this.ipcSocket.on('connect', () => {
125
- logger.info('PlayerService', 'IPC socket connected');
126
- this.ipcConnectRetries = 0;
127
- // Request property observations
128
- this.sendIpcCommand(['observe_property', 1, 'time-pos']);
129
- this.sendIpcCommand(['observe_property', 2, 'duration']);
130
- this.sendIpcCommand(['observe_property', 3, 'pause']);
131
- this.sendIpcCommand(['observe_property', 4, 'eof-reached']);
132
- resolve();
133
- });
134
- this.ipcSocket.on('data', (data) => {
135
- this.handleIpcMessage(data.toString());
136
- });
137
- this.ipcSocket.on('error', (err) => {
138
- logger.debug('PlayerService', 'IPC socket error', {
139
- error: err.message,
140
- attempt: this.ipcConnectRetries + 1,
141
- });
142
- if (this.ipcConnectRetries < this.maxIpcRetries) {
143
- this.ipcConnectRetries++;
144
- setTimeout(attemptConnect, 100); // Retry after 100ms
145
- }
146
- else {
147
- reject(new Error(`Failed to connect to IPC socket after ${this.maxIpcRetries} attempts`));
148
- }
149
- });
150
- this.ipcSocket.on('close', () => {
151
- logger.debug('PlayerService', 'IPC socket closed');
152
- this.ipcSocket = null;
153
- });
154
- };
155
- attemptConnect();
156
- });
157
- }
158
- /**
159
- * Send command to mpv via IPC
160
- */
161
- sendIpcCommand(command) {
162
- if (!this.ipcSocket || this.ipcSocket.destroyed) {
163
- logger.warn('PlayerService', 'Cannot send IPC command: socket not connected');
164
- return;
165
- }
166
- const message = JSON.stringify({ command }) + '\n';
167
- this.ipcSocket.write(message);
168
- logger.debug('PlayerService', 'Sent IPC command', {
169
- command: command[0],
170
- });
171
- }
172
- /**
173
- * Handle IPC message from mpv
174
- */
175
- handleIpcMessage(data) {
176
- const lines = data.trim().split('\n');
177
- for (const line of lines) {
178
- try {
179
- const message = JSON.parse(line);
180
- if (message.event === 'property-change') {
181
- this.handlePropertyChange(message);
182
- }
183
- else if (message.error !== 'success' && message.error) {
184
- logger.warn('PlayerService', 'IPC error response', {
185
- error: message.error,
186
- });
187
- }
188
- }
189
- catch (err) {
190
- logger.debug('PlayerService', 'Failed to parse IPC message', {
191
- data: line,
192
- error: err instanceof Error ? err.message : String(err),
193
- });
194
- }
195
- }
196
- }
197
- /**
198
- * Handle property change events from mpv
199
- */
200
- handlePropertyChange(message) {
201
- if (!this.eventCallback)
202
- return;
203
- const event = {};
204
- switch (message.name) {
205
- case 'time-pos':
206
- event.timePos = message.data;
207
- logger.debug('PlayerService', 'Time position updated', {
208
- timePos: event.timePos,
209
- });
210
- break;
211
- case 'duration':
212
- event.duration = message.data;
213
- logger.debug('PlayerService', 'Duration updated', {
214
- duration: event.duration,
215
- });
216
- break;
217
- case 'pause':
218
- event.paused = message.data;
219
- logger.debug('PlayerService', 'Pause state changed', {
220
- paused: event.paused,
221
- });
222
- break;
223
- case 'eof-reached':
224
- event.eof = message.data;
225
- if (event.eof) {
226
- this.isPlaying = false;
227
- logger.info('PlayerService', 'End of file reached');
228
- }
229
- break;
230
- }
231
- this.eventCallback(event);
232
- }
233
- async play(url, options) {
234
- logger.info('PlayerService', 'play() called with mpv', {
235
- urlLength: url.length,
236
- urlPreview: url.substring(0, 100),
237
- volume: options?.volume || this.currentVolume,
238
- });
239
- // Extract videoId from URL
240
- const videoIdMatch = url.match(/[?&]v=([^&]+)/);
241
- const videoId = videoIdMatch ? videoIdMatch[1] : null;
242
- // Guard: Don't spawn if same track already playing
243
- if (this.currentTrackId === videoId && this.mpvProcess && this.isPlaying) {
244
- logger.info('PlayerService', 'Same track already playing, skipping spawn', {
245
- videoId,
246
- });
247
- return;
248
- }
249
- this.currentTrackId = videoId || null;
250
- // Stop any existing playback
251
- this.stop();
252
- this.currentUrl = url;
253
- if (options?.volume !== undefined) {
254
- this.currentVolume = options.volume;
255
- }
256
- // Build YouTube URL from videoId if needed
257
- let playUrl = url;
258
- if (!url.startsWith('http')) {
259
- playUrl = `https://www.youtube.com/watch?v=${url}`;
260
- }
261
- // Increment session ID for a unique IPC socket path per play call
262
- this.playSessionId++;
263
- // Generate IPC socket path
264
- this.ipcPath = this.getIpcPath();
265
- return new Promise((resolve, reject) => {
266
- try {
267
- logger.debug('PlayerService', 'Spawning mpv process with IPC', {
268
- url: playUrl,
269
- volume: this.currentVolume,
270
- ipcPath: this.ipcPath,
271
- });
272
- const mpvArgs = buildMpvArgs(playUrl, this.ipcPath, {
273
- volume: this.currentVolume,
274
- audioNormalization: options?.audioNormalization,
275
- proxy: options?.proxy,
276
- gaplessPlayback: options?.gaplessPlayback,
277
- crossfadeDuration: options?.crossfadeDuration,
278
- equalizerPreset: options?.equalizerPreset,
279
- });
280
- // Capture process in local var so stale exit handlers from a killed
281
- // process don't overwrite state belonging to a newly-spawned process.
282
- const spawnedProcess = spawn(this.getMpvCommand(), mpvArgs, {
283
- detached: true,
284
- stdio: ['ignore', 'pipe', 'pipe'],
285
- windowsHide: true,
286
- });
287
- this.mpvProcess = spawnedProcess;
288
- if (!spawnedProcess.stdout || !spawnedProcess.stderr) {
289
- throw new Error('Failed to create mpv process streams');
290
- }
291
- this.isPlaying = true;
292
- // Connect to IPC socket after a short delay (let mpv start)
293
- setTimeout(() => {
294
- void this.connectIpc().catch(error => {
295
- logger.warn('PlayerService', 'Failed to connect IPC', {
296
- error: error.message,
297
- });
298
- // Continue without IPC - basic playback will still work
299
- });
300
- }, 200);
301
- // Handle stdout (should be minimal with --really-quiet)
302
- spawnedProcess.stdout.on('data', (data) => {
303
- logger.debug('PlayerService', 'mpv stdout', {
304
- output: data.toString().trim(),
305
- });
306
- });
307
- // Handle stderr (errors)
308
- spawnedProcess.stderr.on('data', (data) => {
309
- const error = data.toString().trim();
310
- if (error) {
311
- logger.error('PlayerService', 'mpv stderr', { error });
312
- }
313
- });
314
- // Handle process exit — guard against stale handlers from killed processes
315
- spawnedProcess.on('exit', (code, signal) => {
316
- logger.info('PlayerService', 'mpv process exited', {
317
- code,
318
- signal,
319
- wasPlaying: this.isPlaying,
320
- });
321
- // Only update shared state if this is still the active process
322
- if (this.mpvProcess === spawnedProcess) {
323
- this.isPlaying = false;
324
- this.mpvProcess = null;
325
- }
326
- if (code === 0) {
327
- // Normal exit (track finished)
328
- resolve();
329
- }
330
- else if (code !== null && code > 0) {
331
- // Error exit
332
- reject(new Error(`mpv exited with code ${code}`));
333
- }
334
- // If killed by signal, don't reject (user stopped it)
335
- });
336
- // Handle errors — same guard
337
- spawnedProcess.on('error', (error) => {
338
- logger.error('PlayerService', 'mpv process error', {
339
- error: error.message,
340
- stack: error.stack,
341
- });
342
- if (this.mpvProcess === spawnedProcess) {
343
- this.isPlaying = false;
344
- this.mpvProcess = null;
345
- }
346
- if ('code' in error && error.code === 'ENOENT') {
347
- reject(new Error("mpv executable not found. Install mpv and ensure it's in PATH (or set MPV_PATH)."));
348
- return;
349
- }
350
- reject(error);
351
- });
352
- logger.info('PlayerService', 'mpv process started successfully');
353
- }
354
- catch (error) {
355
- logger.error('PlayerService', 'Exception in play()', {
356
- error: error instanceof Error ? error.message : String(error),
357
- stack: error instanceof Error ? error.stack : undefined,
358
- });
359
- this.isPlaying = false;
360
- reject(error);
361
- }
362
- });
363
- }
364
- pause() {
365
- logger.debug('PlayerService', 'pause() called');
366
- this.isPlaying = false;
367
- if (this.ipcSocket && !this.ipcSocket.destroyed) {
368
- this.sendIpcCommand(['set_property', 'pause', true]);
369
- }
370
- }
371
- resume() {
372
- logger.debug('PlayerService', 'resume() called');
373
- this.isPlaying = true;
374
- if (this.ipcSocket && !this.ipcSocket.destroyed) {
375
- this.sendIpcCommand(['set_property', 'pause', false]);
376
- // Reapply volume after resume to ensure audio isn't muted
377
- if (this.currentVolume !== undefined) {
378
- setTimeout(() => {
379
- this.sendIpcCommand(['set_property', 'volume', this.currentVolume]);
380
- }, 100);
381
- }
382
- }
383
- else if (!this.isPlaying && this.currentUrl) {
384
- void this.play(this.currentUrl, { volume: this.currentVolume });
385
- }
386
- }
387
- stop() {
388
- logger.debug('PlayerService', 'stop() called');
389
- // Close IPC socket
390
- if (this.ipcSocket && !this.ipcSocket.destroyed) {
391
- this.ipcSocket.destroy();
392
- this.ipcSocket = null;
393
- }
394
- if (this.mpvProcess) {
395
- try {
396
- this.mpvProcess.kill('SIGTERM');
397
- this.mpvProcess = null;
398
- this.isPlaying = false;
399
- this.currentTrackId = null; // Clear track ID on stop
400
- logger.info('PlayerService', 'mpv process killed');
401
- }
402
- catch (error) {
403
- logger.error('PlayerService', 'Error killing mpv process', {
404
- error: error instanceof Error ? error.message : String(error),
405
- });
406
- }
407
- }
408
- this.ipcPath = null;
409
- this.ipcConnectRetries = 0;
410
- }
411
- /**
412
- * Detach mode: Save state and clear references without killing mpv process
413
- * Returns the IPC path and current URL for later reattachment
414
- */
415
- detach() {
416
- logger.info('PlayerService', 'Detaching from player', {
417
- ipcPath: this.ipcPath,
418
- currentUrl: this.currentUrl,
419
- });
420
- const info = {
421
- ipcPath: this.ipcPath,
422
- currentUrl: this.currentUrl,
423
- };
424
- if (this.mpvProcess) {
425
- // Close piped stdio handles so Node has no open references that could
426
- // prevent clean exit or send SIGHUP to the detached mpv process.
427
- this.mpvProcess.stdout?.destroy();
428
- this.mpvProcess.stderr?.destroy();
429
- // Allow detached mpv process to survive after CLI exits.
430
- this.mpvProcess.unref();
431
- }
432
- // Clear references but DON'T kill mpv process - it keeps playing
433
- this.mpvProcess = null;
434
- this.ipcSocket = null;
435
- this.ipcPath = null;
436
- this.isPlaying = false;
437
- return info;
438
- }
439
- /**
440
- * Reattach to an existing mpv process via IPC
441
- */
442
- async reattach(ipcPath, options) {
443
- logger.info('PlayerService', 'Reattaching to player', { ipcPath });
444
- this.ipcPath = ipcPath;
445
- await this.connectIpc();
446
- this.isPlaying = true;
447
- if (options?.trackId)
448
- this.currentTrackId = options.trackId;
449
- if (options?.currentUrl)
450
- this.currentUrl = options.currentUrl;
451
- logger.info('PlayerService', 'Successfully reattached to player');
452
- }
453
- setVolume(volume) {
454
- logger.debug('PlayerService', 'setVolume() called', {
455
- oldVolume: this.currentVolume,
456
- newVolume: volume,
457
- });
458
- this.currentVolume = Math.max(0, Math.min(100, volume));
459
- // Update mpv volume via IPC if connected
460
- if (this.ipcSocket && !this.ipcSocket.destroyed) {
461
- this.sendIpcCommand(['set_property', 'volume', this.currentVolume]);
462
- }
463
- }
464
- getVolume() {
465
- return this.currentVolume;
466
- }
467
- setSpeed(speed) {
468
- const clamped = Math.max(0.25, Math.min(4.0, speed));
469
- logger.debug('PlayerService', 'setSpeed() called', { speed: clamped });
470
- if (this.ipcSocket && !this.ipcSocket.destroyed) {
471
- this.sendIpcCommand(['set_property', 'speed', clamped]);
472
- }
473
- }
474
- isCurrentlyPlaying() {
475
- return this.isPlaying;
476
- }
477
- }
478
- export const getPlayerService = () => PlayerService.getInstance();