@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.
- package/CHANGELOG.md +2 -0
- package/dist/cli.js.map +3 -3
- package/dist/youtube-music-cli +0 -0
- package/package.json +1 -1
- package/dist/eslint.config.js +0 -55
- package/dist/package.json +0 -120
- package/dist/scripts/build-cli.js +0 -46
- package/dist/source/app.js +0 -17
- package/dist/source/cli.js +0 -504
- package/dist/source/components/common/ErrorBoundary.js +0 -22
- package/dist/source/components/common/Help.js +0 -18
- package/dist/source/components/common/ShortcutsBar.js +0 -89
- package/dist/source/components/config/ConfigLayout.js +0 -84
- package/dist/source/components/config/KeybindingsLayout.js +0 -107
- package/dist/source/components/export/ExportLayout.js +0 -111
- package/dist/source/components/import/ImportLayout.js +0 -119
- package/dist/source/components/import/ImportProgress.js +0 -73
- package/dist/source/components/layouts/ExploreLayout.js +0 -72
- package/dist/source/components/layouts/HistoryLayout.js +0 -37
- package/dist/source/components/layouts/LyricsLayout.js +0 -89
- package/dist/source/components/layouts/MainLayout.js +0 -190
- package/dist/source/components/layouts/MiniPlayerLayout.js +0 -20
- package/dist/source/components/layouts/PlayerLayout.js +0 -9
- package/dist/source/components/layouts/PluginsLayout.js +0 -77
- package/dist/source/components/layouts/SearchLayout.js +0 -193
- package/dist/source/components/layouts/TrendingLayout.js +0 -59
- package/dist/source/components/player/NowPlaying.js +0 -45
- package/dist/source/components/player/PlayerControls.js +0 -83
- package/dist/source/components/player/ProgressBar.js +0 -19
- package/dist/source/components/player/QueueList.js +0 -36
- package/dist/source/components/player/Suggestions.js +0 -50
- package/dist/source/components/playlist/PlaylistList.js +0 -138
- package/dist/source/components/plugins/PluginInstallDialog.js +0 -41
- package/dist/source/components/plugins/PluginsAvailable.js +0 -55
- package/dist/source/components/plugins/PluginsList.js +0 -18
- package/dist/source/components/search/SearchBar.js +0 -55
- package/dist/source/components/search/SearchHistory.js +0 -35
- package/dist/source/components/search/SearchResults.js +0 -280
- package/dist/source/components/settings/Settings.js +0 -211
- package/dist/source/components/theme/ThemeSwitcher.js +0 -11
- package/dist/source/config/themes.config.js +0 -123
- package/dist/source/contexts/theme.context.js +0 -29
- package/dist/source/hooks/useKeyboard.js +0 -188
- package/dist/source/hooks/useKeyboardBlocker.js +0 -45
- package/dist/source/hooks/useNavigation.js +0 -5
- package/dist/source/hooks/usePlayer.js +0 -43
- package/dist/source/hooks/usePlaylist.js +0 -65
- package/dist/source/hooks/useSearch.js +0 -76
- package/dist/source/hooks/useSleepTimer.js +0 -48
- package/dist/source/hooks/useTerminalSize.js +0 -24
- package/dist/source/hooks/useTheme.js +0 -5
- package/dist/source/hooks/useYouTubeMusic.js +0 -112
- package/dist/source/main.js +0 -127
- package/dist/source/services/cache/cache.service.js +0 -67
- package/dist/source/services/completions/completions.service.js +0 -313
- package/dist/source/services/config/config.service.js +0 -191
- package/dist/source/services/discord/discord-rpc.service.js +0 -95
- package/dist/source/services/download/download.service.js +0 -350
- package/dist/source/services/export/export.service.js +0 -131
- package/dist/source/services/history/history.service.js +0 -83
- package/dist/source/services/import/import.service.js +0 -272
- package/dist/source/services/import/spotify.service.js +0 -171
- package/dist/source/services/import/track-matcher.service.js +0 -271
- package/dist/source/services/import/youtube-import.service.js +0 -84
- package/dist/source/services/logger/logger.service.js +0 -52
- package/dist/source/services/lyrics/lyrics.service.js +0 -93
- package/dist/source/services/mpris/mpris.service.js +0 -78
- package/dist/source/services/notification/notification.service.js +0 -57
- package/dist/source/services/player/dependency-check.service.js +0 -140
- package/dist/source/services/player/player.service.js +0 -478
- package/dist/source/services/player-state/player-state.service.js +0 -123
- package/dist/source/services/plugin/plugin-audio-api.js +0 -36
- package/dist/source/services/plugin/plugin-context.js +0 -256
- package/dist/source/services/plugin/plugin-hooks.service.js +0 -135
- package/dist/source/services/plugin/plugin-installer.service.js +0 -248
- package/dist/source/services/plugin/plugin-loader.service.js +0 -161
- package/dist/source/services/plugin/plugin-permissions.service.js +0 -194
- package/dist/source/services/plugin/plugin-registry.service.js +0 -215
- package/dist/source/services/plugin/plugin-ui-api.js +0 -46
- package/dist/source/services/plugin/plugin-updater.service.js +0 -206
- package/dist/source/services/scrobbling/scrobbling.service.js +0 -115
- package/dist/source/services/sleep-timer/sleep-timer.service.js +0 -45
- package/dist/source/services/version-check/version-check.service.js +0 -121
- package/dist/source/services/web/static-file.service.js +0 -185
- package/dist/source/services/web/web-server-manager.js +0 -507
- package/dist/source/services/web/web-streaming.service.js +0 -292
- package/dist/source/services/web/websocket.server.js +0 -267
- package/dist/source/services/youtube-music/api.js +0 -649
- package/dist/source/services/youtube-music/search.service.js +0 -38
- package/dist/source/stores/history.store.js +0 -64
- package/dist/source/stores/navigation.store.js +0 -90
- package/dist/source/stores/player.store.js +0 -789
- package/dist/source/stores/plugins.store.js +0 -177
- package/dist/source/types/actions.js +0 -1
- package/dist/source/types/cli.types.js +0 -1
- package/dist/source/types/config.types.js +0 -1
- package/dist/source/types/history.types.js +0 -1
- package/dist/source/types/import.types.js +0 -2
- package/dist/source/types/keyboard.types.js +0 -1
- package/dist/source/types/navigation.types.js +0 -1
- package/dist/source/types/player.types.js +0 -1
- package/dist/source/types/playlist.types.js +0 -1
- package/dist/source/types/plugin.types.js +0 -1
- package/dist/source/types/theme.types.js +0 -1
- package/dist/source/types/web.types.js +0 -2
- package/dist/source/types/youtube-music.types.js +0 -1
- package/dist/source/types/youtubei.types.js +0 -3
- package/dist/source/utils/constants.js +0 -135
- package/dist/source/utils/format.js +0 -24
- package/dist/source/utils/icons.js +0 -28
- 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();
|