@involvex/youtube-music-cli 0.0.38 → 0.0.44

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 CHANGED
@@ -1,3 +1,31 @@
1
+ ## [0.0.44](https://github.com/involvex/youtube-music-cli/compare/v0.0.43...v0.0.44) (2026-02-23)
2
+
3
+ ### Features
4
+
5
+ - add standalone executable support with build-time versioning ([18f730c](https://github.com/involvex/youtube-music-cli/commit/18f730cef46dfe862cfbce0065af4b4989296ef0))
6
+
7
+ ## [0.0.43](https://github.com/involvex/youtube-music-cli/compare/v0.0.42...v0.0.43) (2026-02-23)
8
+
9
+ ## [0.0.42](https://github.com/involvex/youtube-music-cli/compare/v0.0.41...v0.0.42) (2026-02-23)
10
+
11
+ ## [0.0.41](https://github.com/involvex/youtube-music-cli/compare/v0.0.40...v0.0.41) (2026-02-23)
12
+
13
+ ### Features
14
+
15
+ - add shell completions for bash, zsh, powershell, and fish ([5adecc3](https://github.com/involvex/youtube-music-cli/commit/5adecc3af1cf6dae5a188c24c598e67c8260c844))
16
+
17
+ ## [0.0.40](https://github.com/involvex/youtube-music-cli/compare/v0.0.39...v0.0.40) (2026-02-23)
18
+
19
+ ### Features
20
+
21
+ - enable MSIX packaging for Windows distribution ([14eafbf](https://github.com/involvex/youtube-music-cli/commit/14eafbf522f1d2fbacf7ed1243df035cd254891b))
22
+
23
+ ## [0.0.39](https://github.com/involvex/youtube-music-cli/compare/v0.0.38...v0.0.39) (2026-02-23)
24
+
25
+ ### Features
26
+
27
+ - add visual flash feedback when shortcuts are pressed ([dc3efa8](https://github.com/involvex/youtube-music-cli/commit/dc3efa8f642619ad2e9ce937528e76f4d388e4dc))
28
+
1
29
  ## [0.0.38](https://github.com/involvex/youtube-music-cli/compare/v0.0.36...v0.0.38) (2026-02-22)
2
30
 
3
31
  ### Bug Fixes
@@ -0,0 +1,120 @@
1
+ {
2
+ "name": "@involvex/youtube-music-cli",
3
+ "version": "0.0.44",
4
+ "description": "- A Commandline music player for youtube-music",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/involvex/youtube-music-cli.git"
8
+ },
9
+ "funding": "https://github.com/sponsors/involvex",
10
+ "license": "MIT",
11
+ "author": "involvex",
12
+ "type": "module",
13
+ "bin": {
14
+ "youtube-music-cli": "dist/source/cli.js",
15
+ "ymc": "dist/source/cli.js"
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "README.md",
20
+ "CHANGELOG.md",
21
+ "LICENSE",
22
+ ".github/FUNDING.yml"
23
+ ],
24
+ "icon": "assets/icon2.PNG",
25
+ "sponsor": {
26
+ "url": "https://github.com/sponsors/involvex"
27
+ },
28
+ "homepage": "https://involvex.github.io/youtube-music-cli/",
29
+ "keywords": [
30
+ "cli",
31
+ "youtube",
32
+ "youtube-music",
33
+ "youtube-cli",
34
+ "youtube-music-cli",
35
+ "ink",
36
+ "cli-music"
37
+ ],
38
+ "scripts": {
39
+ "prebuild": "bun run format && bun run lint:fix && bun run typecheck",
40
+ "build": "tsc",
41
+ "bun:build": "bun build source/cli.tsx --outfile dist/index.js --target node --footer \"//Copyright (c) 2026 involvex\"",
42
+ "compile": "bun scripts/build-cli.ts",
43
+ "dev": "bun run --bun source/cli.tsx",
44
+ "dev:watch": "bun run --bun --watch source/cli.tsx",
45
+ "format": "prettier --write .",
46
+ "format:check": "prettier --check .",
47
+ "lint": "eslint . --ext .js,.jsx,.ts,.tsx --ignore-pattern dist",
48
+ "lint:fix": "eslint . --ext .js,.jsx,.ts,.tsx --fix --ignore-pattern dist",
49
+ "start": "bun run dist/source/cli.js",
50
+ "test": "bun run build && ava",
51
+ "typecheck": "tsc --noEmit",
52
+ "changelog": "conventional-changelog -p angular -i CHANGELOG.md -s -r 0",
53
+ "clean": "rimraf dist",
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",
58
+ "msix": "bun run compile && msix-packager-cli package --config ./msix-config.json --skip-build"
59
+ },
60
+ "prettier": "@vdemedes/prettier-config",
61
+ "ava": {
62
+ "files": [
63
+ "tests/**/*.js"
64
+ ]
65
+ },
66
+ "dependencies": {
67
+ "@distube/ytdl-core": "^4.16.12",
68
+ "@types/bun": "^1.3.9",
69
+ "ansi-escapes": "^7.3.0",
70
+ "discord-rpc": "^4.0.1",
71
+ "ink": "^6.8.0",
72
+ "ink-table": "^3.1.0",
73
+ "ink-text-input": "^6.0.0",
74
+ "jiti": "^2.6.1",
75
+ "meow": "^14.1.0",
76
+ "node-notifier": "^10.0.1",
77
+ "node-youtube-music": "^0.10.3",
78
+ "play-sound": "^1.1.6",
79
+ "react": "^19.2.4",
80
+ "ws": "^8.19.0",
81
+ "youtube-ext": "^1.1.25",
82
+ "youtubei.js": "^16.0.1"
83
+ },
84
+ "devDependencies": {
85
+ "@eslint/js": "^10.0.1",
86
+ "@sindresorhus/tsconfig": "^8.1.0",
87
+ "@types/node": "^25.3.0",
88
+ "@types/node-notifier": "^8.0.5",
89
+ "@types/react": "^19.2.14",
90
+ "@types/ws": "^8.18.1",
91
+ "@vdemedes/prettier-config": "^2.0.1",
92
+ "ava": "^6.4.1",
93
+ "chalk": "^5.6.2",
94
+ "conventional-changelog-cli": "^5.0.0",
95
+ "eslint": "^10.0.1",
96
+ "eslint-plugin-react": "^7.37.5",
97
+ "eslint-plugin-react-hooks": "^7.0.1",
98
+ "globals": "^17.3.0",
99
+ "ink-testing-library": "^4.0.0",
100
+ "prettier": "^3.8.1",
101
+ "prettier-plugin-organize-imports": "^4.3.0",
102
+ "prettier-plugin-packagejson": "^3.0.0",
103
+ "prettier-plugin-sort-imports": "^1.8.11",
104
+ "react-devtools-core": "^7.0.1",
105
+ "rimraf": "^6.1.3",
106
+ "ts-node": "^10.9.2",
107
+ "typescript": "^5.9.3",
108
+ "typescript-eslint": "^8.56.0"
109
+ },
110
+ "engines": {
111
+ "node": ">=16"
112
+ },
113
+ "sponsors": "https://github.com/sponsors/involvex",
114
+ "overrides": {
115
+ "minimatch": "^10.2.1"
116
+ },
117
+ "trustedDependencies": [
118
+ "unrs-resolver"
119
+ ]
120
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,46 @@
1
+ import path from 'node:path';
2
+ import pkg from '../package.json' with { type: 'json' };
3
+ const rootDir = process.cwd();
4
+ const isWindows = process.platform === 'win32';
5
+ const outputName = isWindows ? 'youtube-music-cli.exe' : 'youtube-music-cli';
6
+ const outfile = path.join(rootDir, 'dist', outputName);
7
+ const banner = '//Copyright (c) 2026 involvex';
8
+ const iconPath = path.join(rootDir, 'assets', 'icon.ico');
9
+ const platformTarget = isWindows
10
+ ? 'bun-windows-x64'
11
+ : process.platform === 'darwin'
12
+ ? 'bun-darwin-x64'
13
+ : 'bun-linux-x64';
14
+ const compileOptions = isWindows
15
+ ? {
16
+ target: platformTarget,
17
+ outfile,
18
+ footer: banner,
19
+ windowsTitle: 'YouTube Music CLI',
20
+ windowsPublisher: 'involvex',
21
+ windowsIcon: iconPath,
22
+ windowsCopyright: 'Copyright (c) 2026 involvex',
23
+ windowsDescription: 'A Commandline music player for youtube-music',
24
+ }
25
+ : {
26
+ target: platformTarget,
27
+ outfile,
28
+ };
29
+ const buildOptions = {
30
+ entrypoints: [path.join(rootDir, 'source', 'cli.tsx')],
31
+ compile: compileOptions,
32
+ footer: banner,
33
+ minify: true,
34
+ sourcemap: 'linked',
35
+ bytecode: false,
36
+ define: {
37
+ 'process.env.NODE_ENV': JSON.stringify('production'),
38
+ VERSION: JSON.stringify(pkg.version ?? '0.0.0'),
39
+ },
40
+ };
41
+ const result = await Bun.build(buildOptions);
42
+ if (!result.success) {
43
+ console.error('Build failed', result.logs ?? result);
44
+ process.exit(1);
45
+ }
46
+ console.log('Build succeeded:', result.outputs.map(output => output.path));
@@ -10,12 +10,19 @@ import { getImportService } from "./services/import/import.service.js";
10
10
  import { getWebServerManager } from "./services/web/web-server-manager.js";
11
11
  import { getWebStreamingService } from "./services/web/web-streaming.service.js";
12
12
  import { getVersionCheckService } from "./services/version-check/version-check.service.js";
13
+ import { generateCompletion, } from "./services/completions/completions.service.js";
13
14
  import { getConfigService } from "./services/config/config.service.js";
14
15
  import { getPlayerService } from "./services/player/player.service.js";
15
16
  import { APP_VERSION } from "./utils/constants.js";
16
17
  import { ensurePlaybackDependencies } from "./services/player/dependency-check.service.js";
17
18
  import { getMusicService } from "./services/youtube-music/api.js";
19
+ const isStandalone = process
20
+ .isStandaloneExecutable ||
21
+ globalThis.Bun?.isStandalone;
22
+ const argv = isStandalone ? process.argv.slice(1) : process.argv.slice(2);
18
23
  const cli = meow(`
24
+ youtube-music-cli@${APP_VERSION}
25
+
19
26
  Usage
20
27
  $ youtube-music-cli
21
28
  $ youtube-music-cli play <track-id|youtube-url>
@@ -53,6 +60,12 @@ const cli = meow(`
53
60
  --name Custom name for imported playlist
54
61
  --help, -h Show this help
55
62
 
63
+ Shell Completions
64
+ $ youtube-music-cli completions bash
65
+ $ youtube-music-cli completions zsh
66
+ $ youtube-music-cli completions powershell
67
+ $ youtube-music-cli completions fish
68
+
56
69
  Examples
57
70
  $ youtube-music-cli
58
71
  $ youtube-music-cli play dQw4w9WgXcQ
@@ -61,8 +74,10 @@ const cli = meow(`
61
74
  $ youtube-music-cli plugins install adblock
62
75
  $ youtube-music-cli import spotify "https://open.spotify.com/playlist/..."
63
76
  $ youtube-music-cli --web --web-port 3000
77
+ $ youtube-music-cli completions powershell | Out-File $PROFILE
64
78
  `, {
65
79
  importMeta: import.meta,
80
+ argv,
66
81
  flags: {
67
82
  theme: {
68
83
  type: 'string',
@@ -307,7 +322,17 @@ if (command === 'plugins') {
307
322
  }
308
323
  else {
309
324
  // Handle other direct commands
310
- if (command === 'play' && args[0]) {
325
+ if (command === 'completions') {
326
+ const shell = args[0];
327
+ const validShells = ['bash', 'zsh', 'powershell', 'fish'];
328
+ if (!shell || !validShells.includes(shell)) {
329
+ console.error('Usage: youtube-music-cli completions <bash|zsh|powershell|fish>');
330
+ process.exit(1);
331
+ }
332
+ console.log(generateCompletion(shell));
333
+ process.exit(0);
334
+ }
335
+ else if (command === 'play' && args[0]) {
311
336
  // Play specific track
312
337
  cli.flags.playTrack = args[0];
313
338
  }
@@ -1,16 +1,27 @@
1
1
  import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
2
  // Shortcuts bar component
3
+ import { useState } from 'react';
3
4
  import { Box, Text } from 'ink';
4
5
  import { usePlayer } from "../../hooks/usePlayer.js";
5
6
  import { useTheme } from "../../hooks/useTheme.js";
6
7
  import { useKeyBinding } from "../../hooks/useKeyboard.js";
7
8
  import { KEYBINDINGS } from "../../utils/constants.js";
8
9
  import { ICONS } from "../../utils/icons.js";
10
+ const FLASH_DURATION_MS = 300;
9
11
  export default function ShortcutsBar() {
10
12
  const { theme } = useTheme();
11
13
  const { state: playerState, pause, resume, next, previous, volumeUp, volumeDown, volumeFineUp, volumeFineDown, toggleShuffle, toggleRepeat, } = usePlayer();
14
+ const [flashState, setFlashState] = useState({});
15
+ const flash = (key) => {
16
+ setFlashState(prev => ({ ...prev, [key]: true }));
17
+ setTimeout(() => {
18
+ setFlashState(prev => ({ ...prev, [key]: false }));
19
+ }, FLASH_DURATION_MS);
20
+ };
21
+ const shortcutColor = (key) => flashState[key] ? theme.colors.success : theme.colors.text;
12
22
  // Register key bindings globally
13
23
  const handlePlayPause = () => {
24
+ flash('playPause');
14
25
  if (playerState.isPlaying) {
15
26
  pause();
16
27
  }
@@ -19,16 +30,51 @@ export default function ShortcutsBar() {
19
30
  }
20
31
  };
21
32
  useKeyBinding(KEYBINDINGS.PLAY_PAUSE, handlePlayPause);
22
- useKeyBinding(KEYBINDINGS.NEXT, next);
23
- useKeyBinding(KEYBINDINGS.PREVIOUS, previous);
24
- useKeyBinding(KEYBINDINGS.VOLUME_UP, volumeUp);
25
- useKeyBinding(KEYBINDINGS.VOLUME_DOWN, volumeDown);
26
- useKeyBinding(KEYBINDINGS.VOLUME_FINE_UP, volumeFineUp);
27
- useKeyBinding(KEYBINDINGS.VOLUME_FINE_DOWN, volumeFineDown);
28
- useKeyBinding(KEYBINDINGS.SHUFFLE, toggleShuffle);
29
- useKeyBinding(KEYBINDINGS.REPEAT, toggleRepeat);
33
+ useKeyBinding(KEYBINDINGS.NEXT, () => {
34
+ flash('next');
35
+ next();
36
+ });
37
+ useKeyBinding(KEYBINDINGS.PREVIOUS, () => {
38
+ flash('prev');
39
+ previous();
40
+ });
41
+ useKeyBinding(KEYBINDINGS.VOLUME_UP, () => {
42
+ flash('volume');
43
+ volumeUp();
44
+ });
45
+ useKeyBinding(KEYBINDINGS.VOLUME_DOWN, () => {
46
+ flash('volume');
47
+ volumeDown();
48
+ });
49
+ useKeyBinding(KEYBINDINGS.VOLUME_FINE_UP, () => {
50
+ flash('volume');
51
+ volumeFineUp();
52
+ });
53
+ useKeyBinding(KEYBINDINGS.VOLUME_FINE_DOWN, () => {
54
+ flash('volume');
55
+ volumeFineDown();
56
+ });
57
+ useKeyBinding(KEYBINDINGS.SHUFFLE, () => {
58
+ flash('shuffle');
59
+ toggleShuffle();
60
+ });
61
+ useKeyBinding(KEYBINDINGS.REPEAT, () => {
62
+ flash('repeat');
63
+ toggleRepeat();
64
+ });
30
65
  // Note: SETTINGS keybinding handled by MainLayout to avoid double-dispatch
31
- return (_jsxs(Box, { borderStyle: "single", borderColor: theme.colors.dim, paddingX: 1, justifyContent: "space-between", children: [_jsxs(Text, { color: theme.colors.dim, children: [_jsxs(Text, { color: theme.colors.text, children: [ICONS.PLAY_PAUSE_ON, "/", ICONS.PAUSE, " [Space]"] }), ' ', "| ", _jsxs(Text, { color: theme.colors.text, children: [ICONS.PREV, " [B/\u2190]"] }), " |", ' ', _jsxs(Text, { color: theme.colors.text, children: [ICONS.NEXT, " [N/\u2192]"] }), " |", ' ', _jsxs(Text, { color: theme.colors.text, children: [ICONS.SHUFFLE, " [Shift+S]"] }), " |", ' ', _jsxs(Text, { color: theme.colors.text, children: [ICONS.REPEAT_ALL, " [R]"] }), " |", ' ', _jsxs(Text, { color: theme.colors.text, children: [ICONS.PLAYLIST, " [Shift+P]"] }), " |", ' ', _jsxs(Text, { color: theme.colors.text, children: [ICONS.DOWNLOAD, " [Shift+D]"] }), " |", ' ', _jsxs(Text, { color: theme.colors.text, children: [ICONS.SEARCH, " [/]"] }), " |", ' ', _jsxs(Text, { color: theme.colors.text, children: [ICONS.HELP, " [?]"] }), " |", ' ', _jsxs(Text, { color: theme.colors.text, children: [ICONS.BG_PLAY, " [Shift+Q]"] }), " |", ' ', _jsxs(Text, { color: theme.colors.text, children: [ICONS.RESUME, " [Shift+R]"] }), " |", ' ', _jsxs(Text, { color: theme.colors.text, children: [ICONS.QUIT, " [Q]"] })] }), _jsxs(Text, { color: theme.colors.text, children: [_jsx(Text, { color: playerState.shuffle ? theme.colors.primary : theme.colors.dim, children: ICONS.SHUFFLE }), ' ', _jsx(Text, { color: playerState.repeat === 'off'
32
- ? theme.colors.dim
33
- : theme.colors.secondary, children: playerState.repeat === 'one' ? ICONS.REPEAT_ONE : ICONS.REPEAT_ALL }), ' ', _jsxs(Text, { color: theme.colors.dim, children: [ICONS.VOLUME, " [+/-]"] }), ' ', _jsxs(Text, { color: theme.colors.primary, children: [playerState.volume, "%"] })] })] }));
66
+ const shuffleColor = flashState['shuffle']
67
+ ? theme.colors.success
68
+ : playerState.shuffle
69
+ ? theme.colors.primary
70
+ : theme.colors.dim;
71
+ const repeatColor = flashState['repeat']
72
+ ? theme.colors.success
73
+ : playerState.repeat !== 'off'
74
+ ? theme.colors.secondary
75
+ : theme.colors.dim;
76
+ const volumeColor = flashState['volume']
77
+ ? theme.colors.success
78
+ : theme.colors.primary;
79
+ return (_jsxs(Box, { borderStyle: "single", borderColor: theme.colors.dim, paddingX: 1, justifyContent: "space-between", children: [_jsxs(Text, { color: theme.colors.dim, children: [_jsxs(Text, { color: shortcutColor('playPause'), children: [playerState.isPlaying ? ICONS.PAUSE : ICONS.PLAY_PAUSE_ON, " [Space]"] }), ' ', "| ", _jsxs(Text, { color: shortcutColor('prev'), children: [ICONS.PREV, " [B/\u2190]"] }), " |", ' ', _jsxs(Text, { color: shortcutColor('next'), children: [ICONS.NEXT, " [N/\u2192]"] }), " |", ' ', _jsxs(Text, { color: shuffleColor, children: [ICONS.SHUFFLE, " [Shift+S]"] }), " |", ' ', _jsxs(Text, { color: repeatColor, children: [playerState.repeat === 'one' ? ICONS.REPEAT_ONE : ICONS.REPEAT_ALL, ' ', "[R]"] }), ' ', "| ", _jsxs(Text, { color: theme.colors.text, children: [ICONS.PLAYLIST, " [Shift+P]"] }), " |", ' ', _jsxs(Text, { color: theme.colors.text, children: [ICONS.DOWNLOAD, " [Shift+D]"] }), " |", ' ', _jsxs(Text, { color: theme.colors.text, children: [ICONS.SEARCH, " [/]"] }), " |", ' ', _jsxs(Text, { color: theme.colors.text, children: [ICONS.HELP, " [?]"] }), " |", ' ', _jsxs(Text, { color: theme.colors.text, children: [ICONS.BG_PLAY, " [Shift+Q]"] }), " |", ' ', _jsxs(Text, { color: theme.colors.text, children: [ICONS.RESUME, " [Shift+R]"] }), " |", ' ', _jsxs(Text, { color: theme.colors.text, children: [ICONS.QUIT, " [Q]"] })] }), _jsxs(Text, { color: theme.colors.text, children: [_jsx(Text, { color: shuffleColor, children: ICONS.SHUFFLE }), ' ', _jsx(Text, { color: repeatColor, children: playerState.repeat === 'one' ? ICONS.REPEAT_ONE : ICONS.REPEAT_ALL }), ' ', _jsxs(Text, { color: theme.colors.dim, children: [ICONS.VOLUME, " [+/-]"] }), ' ', _jsxs(Text, { color: volumeColor, children: [playerState.volume, "%"] })] })] }));
34
80
  }
@@ -0,0 +1,2 @@
1
+ export type ShellType = 'bash' | 'zsh' | 'powershell' | 'fish';
2
+ export declare function generateCompletion(shell: ShellType): string;
@@ -0,0 +1,313 @@
1
+ const COMMANDS = [
2
+ 'play',
3
+ 'search',
4
+ 'playlist',
5
+ 'suggestions',
6
+ 'pause',
7
+ 'resume',
8
+ 'skip',
9
+ 'back',
10
+ 'plugins',
11
+ 'import',
12
+ 'completions',
13
+ ];
14
+ const PLUGINS_SUBCOMMANDS = [
15
+ 'list',
16
+ 'install',
17
+ 'remove',
18
+ 'uninstall',
19
+ 'update',
20
+ 'enable',
21
+ 'disable',
22
+ ];
23
+ const IMPORT_SUBCOMMANDS = ['spotify', 'youtube'];
24
+ const COMPLETIONS_SUBCOMMANDS = [
25
+ 'bash',
26
+ 'zsh',
27
+ 'powershell',
28
+ 'fish',
29
+ ];
30
+ const FLAGS = [
31
+ '--theme',
32
+ '--volume',
33
+ '--shuffle',
34
+ '--repeat',
35
+ '--headless',
36
+ '--web',
37
+ '--web-host',
38
+ '--web-port',
39
+ '--web-only',
40
+ '--web-auth',
41
+ '--name',
42
+ '--help',
43
+ '--version',
44
+ ];
45
+ export function generateCompletion(shell) {
46
+ switch (shell) {
47
+ case 'bash':
48
+ return generateBashCompletion();
49
+ case 'zsh':
50
+ return generateZshCompletion();
51
+ case 'powershell':
52
+ return generatePowerShellCompletion();
53
+ case 'fish':
54
+ return generateFishCompletion();
55
+ }
56
+ }
57
+ function generateBashCompletion() {
58
+ const cmds = COMMANDS.join(' ');
59
+ const pluginsSubs = PLUGINS_SUBCOMMANDS.join(' ');
60
+ const importSubs = IMPORT_SUBCOMMANDS.join(' ');
61
+ const completionsSubs = COMPLETIONS_SUBCOMMANDS.join(' ');
62
+ const flags = FLAGS.join(' ');
63
+ return `# youtube-music-cli bash completion
64
+ # Add to ~/.bashrc or ~/.bash_profile:
65
+ # source <(ymc completions bash)
66
+ # # or:
67
+ # ymc completions bash >> ~/.bash_completion
68
+
69
+ _ymc_completions() {
70
+ local cur prev words cword
71
+ _init_completion || return
72
+
73
+ local commands="${cmds}"
74
+ local flags="${flags}"
75
+
76
+ case "$prev" in
77
+ plugins)
78
+ COMPREPLY=($(compgen -W "${pluginsSubs}" -- "$cur"))
79
+ return ;;
80
+ import)
81
+ COMPREPLY=($(compgen -W "${importSubs}" -- "$cur"))
82
+ return ;;
83
+ completions)
84
+ COMPREPLY=($(compgen -W "${completionsSubs}" -- "$cur"))
85
+ return ;;
86
+ --theme|-t)
87
+ COMPREPLY=($(compgen -W "dark light midnight matrix" -- "$cur"))
88
+ return ;;
89
+ --repeat|-r)
90
+ COMPREPLY=($(compgen -W "off all one" -- "$cur"))
91
+ return ;;
92
+ esac
93
+
94
+ if [[ "$cur" == -* ]]; then
95
+ COMPREPLY=($(compgen -W "$flags" -- "$cur"))
96
+ else
97
+ COMPREPLY=($(compgen -W "$commands" -- "$cur"))
98
+ fi
99
+ }
100
+
101
+ complete -F _ymc_completions ymc youtube-music-cli
102
+ `;
103
+ }
104
+ function generateZshCompletion() {
105
+ return `#compdef ymc youtube-music-cli
106
+ # youtube-music-cli zsh completion
107
+ # Add to your zsh config:
108
+ # source <(ymc completions zsh)
109
+ # # or copy to a directory in $fpath:
110
+ # ymc completions zsh > ~/.zsh/completions/_ymc
111
+
112
+ _ymc() {
113
+ local -a commands subcommands flags
114
+
115
+ commands=(
116
+ 'play:Play a track by ID or YouTube URL'
117
+ 'search:Search for tracks'
118
+ 'playlist:Play a playlist by ID'
119
+ 'suggestions:Show music suggestions'
120
+ 'pause:Pause playback'
121
+ 'resume:Resume playback'
122
+ 'skip:Skip to next track'
123
+ 'back:Go to previous track'
124
+ 'plugins:Manage plugins'
125
+ 'import:Import playlists from Spotify or YouTube'
126
+ 'completions:Generate shell completion scripts'
127
+ )
128
+
129
+ flags=(
130
+ '--theme[Theme to use]:theme:(dark light midnight matrix)'
131
+ '--volume[Initial volume (0-100)]:volume:'
132
+ '--shuffle[Enable shuffle mode]'
133
+ '--repeat[Repeat mode]:mode:(off all one)'
134
+ '--headless[Run without TUI]'
135
+ '--web[Enable web UI server]'
136
+ '--web-host[Web server host]:host:'
137
+ '--web-port[Web server port]:port:'
138
+ '--web-only[Run web server without CLI UI]'
139
+ '--web-auth[Authentication token for web server]:token:'
140
+ '--name[Custom name for imported playlist]:name:'
141
+ '--help[Show help]'
142
+ '--version[Show version]'
143
+ )
144
+
145
+ case $words[2] in
146
+ plugins)
147
+ local -a plugin_cmds
148
+ plugin_cmds=(
149
+ 'list:List installed plugins'
150
+ 'install:Install a plugin'
151
+ 'remove:Remove a plugin'
152
+ 'uninstall:Alias for remove'
153
+ 'update:Update a plugin'
154
+ 'enable:Enable a plugin'
155
+ 'disable:Disable a plugin'
156
+ )
157
+ _describe 'plugin commands' plugin_cmds
158
+ return ;;
159
+ import)
160
+ local -a import_sources
161
+ import_sources=('spotify:Import from Spotify' 'youtube:Import from YouTube')
162
+ _describe 'import sources' import_sources
163
+ return ;;
164
+ completions)
165
+ local -a shells
166
+ shells=('bash:Bash completion' 'zsh:Zsh completion' 'powershell:PowerShell completion' 'fish:Fish completion')
167
+ _describe 'shells' shells
168
+ return ;;
169
+ esac
170
+
171
+ _arguments -s \\
172
+ $flags \\
173
+ '1:command:->cmd' \\
174
+ '*::args:->args'
175
+
176
+ case $state in
177
+ cmd)
178
+ _describe 'commands' commands ;;
179
+ args)
180
+ _message 'arguments' ;;
181
+ esac
182
+ }
183
+
184
+ _ymc
185
+ `;
186
+ }
187
+ function generatePowerShellCompletion() {
188
+ const cmds = COMMANDS.map(c => `'${c}'`).join(', ');
189
+ const pluginsSubs = PLUGINS_SUBCOMMANDS.map(c => `'${c}'`).join(', ');
190
+ const importSubs = IMPORT_SUBCOMMANDS.map(c => `'${c}'`).join(', ');
191
+ const completionsSubs = COMPLETIONS_SUBCOMMANDS.map(c => `'${c}'`).join(', ');
192
+ const flags = FLAGS.map(f => `'${f}'`).join(', ');
193
+ return `# youtube-music-cli PowerShell completion
194
+ # Add to your PowerShell profile ($PROFILE):
195
+ # ymc completions powershell | Out-File -Append $PROFILE
196
+ # # or:
197
+ # Invoke-Expression (ymc completions powershell | Out-String)
198
+
199
+ $ymcCompleterBlock = {
200
+ param($wordToComplete, $commandAst, $cursorPosition)
201
+
202
+ $commands = @(${cmds})
203
+ $pluginsSubCommands = @(${pluginsSubs})
204
+ $importSubCommands = @(${importSubs})
205
+ $completionsSubCommands = @(${completionsSubs})
206
+ $flags = @(${flags})
207
+ $themes = @('dark', 'light', 'midnight', 'matrix')
208
+ $repeatModes = @('off', 'all', 'one')
209
+
210
+ $tokens = $commandAst.CommandElements
211
+ $prevToken = if ($tokens.Count -ge 2) { $tokens[$tokens.Count - 2].ToString() } else { '' }
212
+ $firstArg = if ($tokens.Count -ge 2) { $tokens[1].ToString() } else { '' }
213
+
214
+ # Context-aware completions
215
+ switch ($prevToken) {
216
+ 'plugins' {
217
+ $pluginsSubCommands | Where-Object { $_ -like "$wordToComplete*" } |
218
+ ForEach-Object { [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) }
219
+ return
220
+ }
221
+ 'import' {
222
+ $importSubCommands | Where-Object { $_ -like "$wordToComplete*" } |
223
+ ForEach-Object { [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) }
224
+ return
225
+ }
226
+ 'completions' {
227
+ $completionsSubCommands | Where-Object { $_ -like "$wordToComplete*" } |
228
+ ForEach-Object { [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) }
229
+ return
230
+ }
231
+ { $_ -in '--theme', '-t' } {
232
+ $themes | Where-Object { $_ -like "$wordToComplete*" } |
233
+ ForEach-Object { [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) }
234
+ return
235
+ }
236
+ { $_ -in '--repeat', '-r' } {
237
+ $repeatModes | Where-Object { $_ -like "$wordToComplete*" } |
238
+ ForEach-Object { [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) }
239
+ return
240
+ }
241
+ }
242
+
243
+ if ($wordToComplete.StartsWith('-')) {
244
+ $flags | Where-Object { $_ -like "$wordToComplete*" } |
245
+ ForEach-Object { [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) }
246
+ } elseif ($firstArg -eq $wordToComplete -or $tokens.Count -le 1) {
247
+ $commands | Where-Object { $_ -like "$wordToComplete*" } |
248
+ ForEach-Object { [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) }
249
+ }
250
+ }
251
+
252
+ Register-ArgumentCompleter -Native -CommandName @('ymc', 'youtube-music-cli') -ScriptBlock $ymcCompleterBlock
253
+ `;
254
+ }
255
+ function generateFishCompletion() {
256
+ const commandCompletions = COMMANDS.map(cmd => `complete -c ymc -n '__fish_use_subcommand' -f -a '${cmd}' -d '${getFishDescription(cmd)}'`).join('\n');
257
+ const pluginsCompletions = PLUGINS_SUBCOMMANDS.map(sub => `complete -c ymc -n '__fish_seen_subcommand_from plugins' -f -a '${sub}'`).join('\n');
258
+ const importCompletions = IMPORT_SUBCOMMANDS.map(sub => `complete -c ymc -n '__fish_seen_subcommand_from import' -f -a '${sub}'`).join('\n');
259
+ const completionsCompletions = COMPLETIONS_SUBCOMMANDS.map(sub => `complete -c ymc -n '__fish_seen_subcommand_from completions' -f -a '${sub}'`).join('\n');
260
+ return `# youtube-music-cli fish completion
261
+ # Save to: ~/.config/fish/completions/ymc.fish
262
+ # ymc completions fish > ~/.config/fish/completions/ymc.fish
263
+
264
+ # Disable file completions by default
265
+ complete -c ymc -f
266
+
267
+ # Main commands
268
+ ${commandCompletions}
269
+
270
+ # Plugins subcommands
271
+ ${pluginsCompletions}
272
+
273
+ # Import subcommands
274
+ ${importCompletions}
275
+
276
+ # Completions subcommands
277
+ ${completionsCompletions}
278
+
279
+ # Flags
280
+ complete -c ymc -l theme -s t -d 'Theme to use' -r -a 'dark light midnight matrix'
281
+ complete -c ymc -l volume -s v -d 'Initial volume (0-100)' -r
282
+ complete -c ymc -l shuffle -s s -d 'Enable shuffle mode'
283
+ complete -c ymc -l repeat -s r -d 'Repeat mode' -r -a 'off all one'
284
+ complete -c ymc -l headless -d 'Run without TUI'
285
+ complete -c ymc -l web -d 'Enable web UI server'
286
+ complete -c ymc -l web-host -d 'Web server host' -r
287
+ complete -c ymc -l web-port -d 'Web server port' -r
288
+ complete -c ymc -l web-only -d 'Run web server without CLI UI'
289
+ complete -c ymc -l web-auth -d 'Authentication token for web server' -r
290
+ complete -c ymc -l name -d 'Custom name for imported playlist' -r
291
+ complete -c ymc -l help -s h -d 'Show help'
292
+ complete -c ymc -l version -d 'Show version'
293
+
294
+ # Also register for youtube-music-cli
295
+ complete -c youtube-music-cli -w ymc
296
+ `;
297
+ }
298
+ function getFishDescription(cmd) {
299
+ const descriptions = {
300
+ play: 'Play a track by ID or YouTube URL',
301
+ search: 'Search for tracks',
302
+ playlist: 'Play a playlist by ID',
303
+ suggestions: 'Show music suggestions',
304
+ pause: 'Pause playback',
305
+ resume: 'Resume playback',
306
+ skip: 'Skip to next track',
307
+ back: 'Go to previous track',
308
+ plugins: 'Manage plugins',
309
+ import: 'Import playlists from Spotify or YouTube',
310
+ completions: 'Generate shell completion scripts',
311
+ };
312
+ return descriptions[cmd] ?? cmd;
313
+ }
@@ -218,6 +218,7 @@ class PlayerService {
218
218
  case 'eof-reached':
219
219
  event.eof = message.data;
220
220
  if (event.eof) {
221
+ this.isPlaying = false;
221
222
  logger.info('PlayerService', 'End of file reached');
222
223
  }
223
224
  break;
@@ -23,6 +23,7 @@ class WebServerManager {
23
23
  shuffle: false,
24
24
  isLoading: false,
25
25
  error: null,
26
+ playRequestId: 0,
26
27
  };
27
28
  constructor() {
28
29
  // Load config or use defaults
@@ -23,6 +23,7 @@ const initialState = {
23
23
  shuffle: false,
24
24
  isLoading: false,
25
25
  error: null,
26
+ playRequestId: 0,
26
27
  };
27
28
  // Get player service instance
28
29
  const playerService = getPlayerService();
@@ -35,6 +36,7 @@ export function playerReducer(state, action) {
35
36
  isPlaying: true,
36
37
  progress: 0,
37
38
  error: null,
39
+ playRequestId: state.playRequestId + 1,
38
40
  };
39
41
  case 'PAUSE':
40
42
  return { ...state, isPlaying: false };
@@ -62,6 +64,7 @@ export function playerReducer(state, action) {
62
64
  currentTrack: state.queue[randomIndex] ?? null,
63
65
  isPlaying: true,
64
66
  progress: 0,
67
+ playRequestId: state.playRequestId + 1,
65
68
  };
66
69
  }
67
70
  // Sequential mode
@@ -74,6 +77,7 @@ export function playerReducer(state, action) {
74
77
  currentTrack: state.queue[0] ?? null,
75
78
  isPlaying: true,
76
79
  progress: 0,
80
+ playRequestId: state.playRequestId + 1,
77
81
  };
78
82
  }
79
83
  return state;
@@ -84,6 +88,7 @@ export function playerReducer(state, action) {
84
88
  currentTrack: state.queue[nextPosition] ?? null,
85
89
  isPlaying: true,
86
90
  progress: 0,
91
+ playRequestId: state.playRequestId + 1,
87
92
  };
88
93
  }
89
94
  case 'PREVIOUS':
@@ -95,6 +100,7 @@ export function playerReducer(state, action) {
95
100
  return {
96
101
  ...state,
97
102
  progress: 0,
103
+ playRequestId: state.playRequestId + 1,
98
104
  };
99
105
  }
100
106
  return {
@@ -102,6 +108,7 @@ export function playerReducer(state, action) {
102
108
  queuePosition: prevPosition,
103
109
  currentTrack: state.queue[prevPosition] ?? null,
104
110
  progress: 0,
111
+ playRequestId: state.playRequestId + 1,
105
112
  };
106
113
  case 'SEEK':
107
114
  return {
@@ -174,6 +181,7 @@ export function playerReducer(state, action) {
174
181
  queuePosition: action.position,
175
182
  currentTrack: state.queue[action.position] ?? null,
176
183
  progress: 0,
184
+ playRequestId: state.playRequestId + 1,
177
185
  };
178
186
  }
179
187
  return state;
@@ -207,6 +215,7 @@ export function playerReducer(state, action) {
207
215
  hasTrack: !!action.currentTrack,
208
216
  queueLength: action.queue.length,
209
217
  });
218
+ playerService.setVolume(action.volume);
210
219
  return {
211
220
  ...state,
212
221
  currentTrack: action.currentTrack,
@@ -292,6 +301,7 @@ function PlayerManager() {
292
301
  };
293
302
  }, [dispatch, playerService]);
294
303
  // Handle track changes
304
+ const lastPlayedRequestId = useRef(-1);
295
305
  useEffect(() => {
296
306
  const track = state.currentTrack;
297
307
  if (!track) {
@@ -306,14 +316,17 @@ function PlayerManager() {
306
316
  });
307
317
  return;
308
318
  }
309
- // Guard: Only play if track actually changed
319
+ // Guard: Don't replay same track unless a new play request was explicitly dispatched
310
320
  const currentTrackId = playerService.getCurrentTrackId?.() || '';
311
- if (currentTrackId === track.videoId) {
321
+ const isSameTrack = currentTrackId === track.videoId;
322
+ const isNewPlayRequest = state.playRequestId !== lastPlayedRequestId.current;
323
+ if (isSameTrack && !isNewPlayRequest) {
312
324
  logger.debug('PlayerManager', 'Track already playing, skipping', {
313
325
  videoId: track.videoId,
314
326
  });
315
327
  return;
316
328
  }
329
+ lastPlayedRequestId.current = state.playRequestId;
317
330
  logger.info('PlayerManager', 'Loading track', {
318
331
  title: track.title,
319
332
  videoId: track.videoId,
@@ -424,7 +437,13 @@ function PlayerManager() {
424
437
  void loadAndPlayTrack();
425
438
  // Note: state.volume intentionally excluded - volume changes should not restart playback
426
439
  // eslint-disable-next-line react-hooks/exhaustive-deps
427
- }, [state.currentTrack, state.isPlaying, dispatch, musicService]);
440
+ }, [
441
+ state.currentTrack,
442
+ state.isPlaying,
443
+ state.playRequestId,
444
+ dispatch,
445
+ musicService,
446
+ ]);
428
447
  // Handle progress tracking
429
448
  useEffect(() => {
430
449
  if (state.isPlaying && state.currentTrack) {
@@ -13,5 +13,6 @@ export interface PlayerState {
13
13
  shuffle: boolean;
14
14
  isLoading: boolean;
15
15
  error: string | null;
16
+ playRequestId: number;
16
17
  }
17
18
  export type PlayerAction = PlayAction | PauseAction | ResumeAction | StopAction | NextAction | PreviousAction | SeekAction | SetVolumeAction | VolumeUpAction | VolumeDownAction | VolumeFineUpAction | VolumeFineDownAction | ToggleShuffleAction | ToggleRepeatAction | SetQueueAction | AddToQueueAction | RemoveFromQueueAction | ClearQueueAction | SetQueuePositionAction | UpdateProgressAction | SetDurationAction | TickAction | SetLoadingAction | SetErrorAction | RestoreStateAction | SetSpeedAction;
@@ -1,5 +1,5 @@
1
1
  export declare const APP_NAME = "@involvex/youtube-music-cli";
2
- export declare const APP_VERSION = "0.0.20";
2
+ export declare const APP_VERSION: string;
3
3
  export declare const CONFIG_DIR: string;
4
4
  export declare const CONFIG_FILE: string;
5
5
  export declare const VIEW: {
@@ -1,6 +1,33 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { dirname, resolve } from 'node:path';
4
+ function loadAppVersion() {
5
+ if (typeof VERSION !== 'undefined') {
6
+ return VERSION;
7
+ }
8
+ let dir = dirname(fileURLToPath(import.meta.url));
9
+ for (let i = 0; i < 5; i++) {
10
+ try {
11
+ const content = readFileSync(resolve(dir, 'package.json'), 'utf8');
12
+ const pkg = JSON.parse(content);
13
+ if (typeof pkg.version === 'string' &&
14
+ pkg.name?.includes('youtube-music-cli')) {
15
+ return pkg.version;
16
+ }
17
+ }
18
+ catch {
19
+ /* ignore */
20
+ }
21
+ const parent = dirname(dir);
22
+ if (parent === dir)
23
+ break;
24
+ dir = parent;
25
+ }
26
+ return '0.0.0';
27
+ }
1
28
  // Application constants
2
29
  export const APP_NAME = '@involvex/youtube-music-cli';
3
- export const APP_VERSION = '0.0.20';
30
+ export const APP_VERSION = loadAppVersion();
4
31
  // Config directory
5
32
  export const CONFIG_DIR = process.platform === 'win32'
6
33
  ? `${process.env['USERPROFILE']}\\.youtube-music-cli`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@involvex/youtube-music-cli",
3
- "version": "0.0.38",
3
+ "version": "0.0.44",
4
4
  "description": "- A Commandline music player for youtube-music",
5
5
  "repository": {
6
6
  "type": "git",
@@ -38,8 +38,8 @@
38
38
  "scripts": {
39
39
  "prebuild": "bun run format && bun run lint:fix && bun run typecheck",
40
40
  "build": "tsc",
41
- "bun:build": "bun build source/cli.tsx --outfile dist/index.js --target node",
42
- "compile": "bun build --compile source/cli.tsx --outfile dist/youtube-music-cli.exe",
41
+ "bun:build": "bun build source/cli.tsx --outfile dist/index.js --target node --footer \"//Copyright (c) 2026 involvex\"",
42
+ "compile": "bun scripts/build-cli.ts",
43
43
  "dev": "bun run --bun source/cli.tsx",
44
44
  "dev:watch": "bun run --bun --watch source/cli.tsx",
45
45
  "format": "prettier --write .",
@@ -54,7 +54,8 @@
54
54
  "release": "powershell -File scripts/release.ps1",
55
55
  "build:web": "cd web && bun run build",
56
56
  "dev:web": "cd web && bun run dev",
57
- "build:all": "bun run build && bun run build:web"
57
+ "build:all": "bun run build && bun run build:web",
58
+ "msix": "bun run compile && msix-packager-cli package --config ./msix-config.json --skip-build"
58
59
  },
59
60
  "prettier": "@vdemedes/prettier-config",
60
61
  "ava": {
@@ -64,20 +65,21 @@
64
65
  },
65
66
  "dependencies": {
66
67
  "@distube/ytdl-core": "^4.16.12",
68
+ "@types/bun": "^1.3.9",
67
69
  "ansi-escapes": "^7.3.0",
70
+ "discord-rpc": "^4.0.1",
68
71
  "ink": "^6.8.0",
69
72
  "ink-table": "^3.1.0",
70
73
  "ink-text-input": "^6.0.0",
71
74
  "jiti": "^2.6.1",
72
75
  "meow": "^14.1.0",
73
76
  "node-notifier": "^10.0.1",
74
- "discord-rpc": "^4.0.1",
75
77
  "node-youtube-music": "^0.10.3",
76
78
  "play-sound": "^1.1.6",
77
79
  "react": "^19.2.4",
80
+ "ws": "^8.19.0",
78
81
  "youtube-ext": "^1.1.25",
79
- "youtubei.js": "^16.0.1",
80
- "ws": "^8.19.0"
82
+ "youtubei.js": "^16.0.1"
81
83
  },
82
84
  "devDependencies": {
83
85
  "@eslint/js": "^10.0.1",