@involvex/youtube-music-cli 0.0.19 → 0.0.21

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 (29) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/LICENSE +21 -0
  3. package/dist/source/cli.js +21 -0
  4. package/dist/source/components/export/ExportLayout.d.ts +1 -0
  5. package/dist/source/components/export/ExportLayout.js +111 -0
  6. package/dist/source/components/layouts/MainLayout.js +29 -4
  7. package/dist/source/components/settings/Settings.js +6 -2
  8. package/dist/source/main.js +20 -0
  9. package/dist/source/services/config/config.service.d.ts +15 -0
  10. package/dist/source/services/config/config.service.js +31 -0
  11. package/dist/source/services/export/export.service.d.ts +41 -0
  12. package/dist/source/services/export/export.service.js +131 -0
  13. package/dist/source/services/player/player.service.d.ts +12 -0
  14. package/dist/source/services/player/player.service.js +30 -0
  15. package/dist/source/services/version-check/version-check.service.d.ts +32 -0
  16. package/dist/source/services/version-check/version-check.service.js +121 -0
  17. package/dist/source/services/web/static-file.service.js +2 -13
  18. package/dist/source/services/web/web-server-manager.d.ts +22 -0
  19. package/dist/source/services/web/web-server-manager.js +285 -2
  20. package/dist/source/services/web/websocket.server.d.ts +6 -1
  21. package/dist/source/services/web/websocket.server.js +14 -0
  22. package/dist/source/types/actions.d.ts +3 -0
  23. package/dist/source/types/config.types.d.ts +7 -0
  24. package/dist/source/types/navigation.types.d.ts +2 -2
  25. package/dist/source/types/web.types.d.ts +40 -2
  26. package/dist/source/utils/constants.d.ts +3 -1
  27. package/dist/source/utils/constants.js +3 -1
  28. package/dist/youtube-music-cli.exe +0 -0
  29. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,3 +1,11 @@
1
+ ## [0.0.21](https://github.com/involvex/youtube-music-cli/compare/v0.0.20...v0.0.21) (2026-02-20)
2
+
3
+ ## [0.0.20](https://github.com/involvex/youtube-music-cli/compare/v0.0.19...v0.0.20) (2026-02-20)
4
+
5
+ ### Bug Fixes
6
+
7
+ - keyboard shortcut conflicts and web UI TypeScript errors ([af01f16](https://github.com/involvex/youtube-music-cli/commit/af01f16a151416c15891e44bb1eee3696a1ddc0e))
8
+
1
9
  ## [0.0.19](https://github.com/involvex/youtube-music-cli/compare/v0.0.18...v0.0.19) (2026-02-20)
2
10
 
3
11
  ## [0.0.18](https://github.com/involvex/youtube-music-cli/compare/v0.0.17...v0.0.18) (2026-02-18)
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 involvex
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -9,6 +9,9 @@ import { getPluginRegistryService } from "./services/plugin/plugin-registry.serv
9
9
  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
+ import { getVersionCheckService } from "./services/version-check/version-check.service.js";
13
+ import { getConfigService } from "./services/config/config.service.js";
14
+ import { APP_VERSION } from "./utils/constants.js";
12
15
  const cli = meow(`
13
16
  Usage
14
17
  $ youtube-music-cli
@@ -350,6 +353,24 @@ else {
350
353
  })();
351
354
  }
352
355
  else {
356
+ // Check for updates before rendering the app (skip in web-only mode)
357
+ if (!cli.flags.webOnly) {
358
+ void (async () => {
359
+ const versionCheck = getVersionCheckService();
360
+ const config = getConfigService();
361
+ const lastCheck = config.getLastVersionCheck();
362
+ if (versionCheck.shouldCheck(lastCheck)) {
363
+ const result = await versionCheck.checkForUpdates(APP_VERSION);
364
+ config.setLastVersionCheck(versionCheck.markChecked());
365
+ if (result.hasUpdate) {
366
+ console.log('');
367
+ console.log(` Update available: ${APP_VERSION} → ${result.latestVersion}`);
368
+ console.log('Run: npm install -g @involvex/youtube-music-cli');
369
+ console.log('');
370
+ }
371
+ }
372
+ })();
373
+ }
353
374
  // Render the app
354
375
  render(_jsx(App, { flags: cli.flags }));
355
376
  }
@@ -0,0 +1 @@
1
+ export default function ExportLayout(): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,111 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ // Export layout component for playlist export
3
+ import { useState, useCallback, useMemo } from 'react';
4
+ import { Box, Text } from 'ink';
5
+ import { useTheme } from "../../hooks/useTheme.js";
6
+ import { useNavigation } from "../../hooks/useNavigation.js";
7
+ import { useKeyBinding } from "../../hooks/useKeyboard.js";
8
+ import { KEYBINDINGS } from "../../utils/constants.js";
9
+ import { getExportService, } from "../../services/export/export.service.js";
10
+ import { getConfigService } from "../../services/config/config.service.js";
11
+ const FORMATS = [
12
+ { key: 'json', label: 'JSON' },
13
+ { key: 'm3u8', label: 'M3U8' },
14
+ { key: 'both', label: 'Both (JSON + M3U8)' },
15
+ ];
16
+ export default function ExportLayout() {
17
+ const { theme } = useTheme();
18
+ const { dispatch } = useNavigation();
19
+ const config = getConfigService();
20
+ const exportService = getExportService();
21
+ const [step, setStep] = useState('format');
22
+ const [selectedFormat, setSelectedFormat] = useState(2); // Default to 'both'
23
+ const [selectedPlaylist, setSelectedPlaylist] = useState(-1); // -1 means "Export All"
24
+ const [results, setResults] = useState([]);
25
+ const playlists = useMemo(() => config.get('playlists') || [], [config]);
26
+ const goBack = useCallback(() => {
27
+ if (step === 'format') {
28
+ dispatch({ category: 'GO_BACK' });
29
+ }
30
+ else if (step === 'playlist') {
31
+ setStep('format');
32
+ }
33
+ else if (step === 'result') {
34
+ setStep('format');
35
+ setResults([]);
36
+ }
37
+ }, [step, dispatch]);
38
+ const selectFormat = useCallback(() => {
39
+ setStep('playlist');
40
+ }, []);
41
+ const startExport = useCallback(async () => {
42
+ setStep('exporting');
43
+ const format = FORMATS[selectedFormat].key;
44
+ try {
45
+ if (selectedPlaylist === -1) {
46
+ // Export all playlists
47
+ const exportResults = await exportService.exportAllPlaylists(playlists, {
48
+ format,
49
+ });
50
+ setResults(exportResults);
51
+ }
52
+ else {
53
+ // Export single playlist
54
+ const result = await exportService.exportPlaylist(playlists[selectedPlaylist], {
55
+ format,
56
+ });
57
+ setResults([result]);
58
+ }
59
+ }
60
+ catch (error) {
61
+ setResults([
62
+ {
63
+ playlistName: 'Error',
64
+ success: false,
65
+ files: [],
66
+ error: error instanceof Error ? error.message : String(error),
67
+ },
68
+ ]);
69
+ }
70
+ finally {
71
+ setStep('result');
72
+ }
73
+ }, [selectedFormat, selectedPlaylist, playlists, exportService]);
74
+ // Keyboard bindings
75
+ useKeyBinding(KEYBINDINGS.UP, () => {
76
+ if (step === 'format') {
77
+ setSelectedFormat(prev => Math.max(0, prev - 1));
78
+ }
79
+ else if (step === 'playlist') {
80
+ setSelectedPlaylist(prev => Math.max(-1, prev - 1));
81
+ }
82
+ });
83
+ useKeyBinding(KEYBINDINGS.DOWN, () => {
84
+ if (step === 'format') {
85
+ setSelectedFormat(prev => Math.min(FORMATS.length - 1, prev + 1));
86
+ }
87
+ else if (step === 'playlist') {
88
+ setSelectedPlaylist(prev => Math.min(playlists.length - 1, prev === -1 && playlists.length > 0 ? 0 : prev + 1));
89
+ }
90
+ });
91
+ useKeyBinding(KEYBINDINGS.SELECT, () => {
92
+ if (step === 'format')
93
+ selectFormat();
94
+ else if (step === 'playlist')
95
+ startExport();
96
+ else if (step === 'result')
97
+ goBack();
98
+ });
99
+ useKeyBinding(KEYBINDINGS.BACK, goBack);
100
+ return (_jsxs(Box, { flexDirection: "column", gap: 1, paddingX: 1, children: [_jsx(Box, { borderStyle: "double", borderColor: theme.colors.secondary, paddingX: 1, marginBottom: 1, children: _jsx(Text, { bold: true, color: theme.colors.primary, children: "Export Playlists" }) }), step === 'format' && (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsx(Text, { color: theme.colors.dim, children: "Select export format:" }), FORMATS.map((format, index) => (_jsx(Box, { paddingX: 1, children: _jsxs(Text, { backgroundColor: index === selectedFormat ? theme.colors.primary : undefined, color: index === selectedFormat
101
+ ? theme.colors.background
102
+ : theme.colors.text, bold: index === selectedFormat, children: [index === selectedFormat ? '► ' : ' ', format.label] }) }, format.key))), playlists.length === 0 && (_jsx(Box, { marginTop: 1, paddingX: 1, children: _jsx(Text, { color: theme.colors.error, children: "No playlists found. Create or import playlists first." }) }))] })), step === 'playlist' && (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsx(Text, { color: theme.colors.dim, children: "Select playlist to export:" }), _jsx(Box, { paddingX: 1, children: _jsxs(Text, { backgroundColor: selectedPlaylist === -1 ? theme.colors.primary : undefined, color: selectedPlaylist === -1
103
+ ? theme.colors.background
104
+ : theme.colors.text, bold: selectedPlaylist === -1, children: [selectedPlaylist === -1 ? '► ' : ' ', "Export All (", playlists.length, " playlists)"] }) }), playlists.map((playlist, index) => (_jsx(Box, { paddingX: 1, children: _jsxs(Text, { backgroundColor: index === selectedPlaylist ? theme.colors.primary : undefined, color: index === selectedPlaylist
105
+ ? theme.colors.background
106
+ : theme.colors.text, bold: index === selectedPlaylist, children: [index === selectedPlaylist ? '► ' : ' ', playlist.name, " (", playlist.tracks.length, " tracks)"] }) }, playlist.playlistId)))] })), step === 'exporting' && (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsx(Text, { color: theme.colors.primary, children: "Exporting..." }), _jsx(Text, { color: theme.colors.dim, children: selectedPlaylist === -1
107
+ ? `Exporting ${playlists.length} playlists...`
108
+ : `Exporting ${playlists[selectedPlaylist]?.name || 'playlist'}...` })] })), step === 'result' && (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsx(Box, { paddingX: 1, children: _jsx(Text, { color: theme.colors.success, bold: true, children: "Export completed!" }) }), results.map((result, index) => (_jsx(Box, { flexDirection: "column", gap: 1, paddingX: 1, children: result.success ? (_jsxs(_Fragment, { children: [_jsxs(Text, { color: theme.colors.success, children: ["\u2713 ", result.playlistName] }), result.files.length > 0 && (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, children: [_jsx(Text, { color: theme.colors.dim, children: "Exported to:" }), result.files.map(file => (_jsxs(Text, { color: theme.colors.primary, children: ["\u2022 ", file] }, file)))] }))] })) : (_jsxs(Text, { color: theme.colors.error, children: ["\u2717 ", result.playlistName, ": ", result.error] })) }, index))), _jsx(Box, { marginTop: 1, paddingX: 1, children: _jsx(Text, { color: theme.colors.dim, children: "Press Enter to continue" }) })] })), step !== 'exporting' && step !== 'result' && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.colors.dim, children: step === 'format'
109
+ ? '↑↓ to select, Enter to continue, Esc/q to go back'
110
+ : '↑↓ to select, Enter to export, Esc to go back' }) }))] }));
111
+ }
@@ -21,9 +21,12 @@ import KeybindingsLayout from "../config/KeybindingsLayout.js";
21
21
  import TrendingLayout from "./TrendingLayout.js";
22
22
  import ExploreLayout from "./ExploreLayout.js";
23
23
  import ImportLayout from "../import/ImportLayout.js";
24
+ import ExportLayout from "../export/ExportLayout.js";
24
25
  import { KEYBINDINGS, VIEW } from "../../utils/constants.js";
25
26
  import { Box } from 'ink';
26
27
  import { useTerminalSize } from "../../hooks/useTerminalSize.js";
28
+ import { getPlayerService } from "../../services/player/player.service.js";
29
+ import { getConfigService } from "../../services/config/config.service.js";
27
30
  function MainLayout() {
28
31
  const { theme } = useTheme();
29
32
  const { state: navState, dispatch } = useNavigation();
@@ -64,11 +67,30 @@ function MainLayout() {
64
67
  dispatch({ category: 'NAVIGATE', view: VIEW.TRENDING });
65
68
  }, [dispatch]);
66
69
  const goToExplore = useCallback(() => {
67
- dispatch({ category: 'NAVIGATE', view: VIEW.EXPLORE });
68
- }, [dispatch]);
70
+ // Don't navigate to explore if we're in plugins view (e key is used for enable/disable there)
71
+ if (navState.currentView !== VIEW.PLUGINS) {
72
+ dispatch({ category: 'NAVIGATE', view: VIEW.EXPLORE });
73
+ }
74
+ }, [dispatch, navState.currentView]);
69
75
  const goToImport = useCallback(() => {
70
- dispatch({ category: 'NAVIGATE', view: VIEW.IMPORT });
71
- }, [dispatch]);
76
+ // Don't navigate to import if we're in plugins view (i key is used for plugin install there)
77
+ if (navState.currentView !== VIEW.PLUGINS) {
78
+ dispatch({ category: 'NAVIGATE', view: VIEW.IMPORT });
79
+ }
80
+ }, [dispatch, navState.currentView]);
81
+ const handleDetach = useCallback(() => {
82
+ // Detach mode: Exit CLI while keeping music playing
83
+ const player = getPlayerService();
84
+ const config = getConfigService();
85
+ // Get the IPC path and current URL before detaching
86
+ const { ipcPath, currentUrl } = player.detach();
87
+ // Save the background playback state if we have an active session
88
+ if (ipcPath && currentUrl) {
89
+ config.setBackgroundPlaybackState({ ipcPath, currentUrl });
90
+ }
91
+ // Exit the app
92
+ process.exit(0);
93
+ }, []);
72
94
  const togglePlayerMode = useCallback(() => {
73
95
  dispatch({ category: 'TOGGLE_PLAYER_MODE' });
74
96
  }, [dispatch]);
@@ -85,6 +107,7 @@ function MainLayout() {
85
107
  useKeyBinding(['T'], goToTrending);
86
108
  useKeyBinding(['e'], goToExplore);
87
109
  useKeyBinding(['i'], goToImport);
110
+ useKeyBinding(KEYBINDINGS.DETACH, handleDetach);
88
111
  // Memoize the view component to prevent unnecessary remounts
89
112
  // Only recreate when currentView actually changes
90
113
  const currentView = useMemo(() => {
@@ -121,6 +144,8 @@ function MainLayout() {
121
144
  return _jsx(ExploreLayout, {}, "explore");
122
145
  case 'import':
123
146
  return _jsx(ImportLayout, {}, "import");
147
+ case 'export_playlists':
148
+ return _jsx(ExportLayout, {}, "export_playlists");
124
149
  case 'help':
125
150
  return _jsx(Help, {}, "help");
126
151
  default:
@@ -23,6 +23,7 @@ const SETTINGS_ITEMS = [
23
23
  'Download Format',
24
24
  'Sleep Timer',
25
25
  'Import Playlists',
26
+ 'Export Playlists',
26
27
  'Custom Keybindings',
27
28
  'Manage Plugins',
28
29
  ];
@@ -120,9 +121,12 @@ export default function Settings() {
120
121
  dispatch({ category: 'NAVIGATE', view: VIEW.IMPORT });
121
122
  }
122
123
  else if (selectedIndex === 9) {
123
- dispatch({ category: 'NAVIGATE', view: VIEW.KEYBINDINGS });
124
+ dispatch({ category: 'NAVIGATE', view: VIEW.EXPORT_PLAYLISTS });
124
125
  }
125
126
  else if (selectedIndex === 10) {
127
+ dispatch({ category: 'NAVIGATE', view: VIEW.KEYBINDINGS });
128
+ }
129
+ else if (selectedIndex === 11) {
126
130
  dispatch({ category: 'NAVIGATE', view: VIEW.PLUGINS });
127
131
  }
128
132
  };
@@ -146,5 +150,5 @@ export default function Settings() {
146
150
  ? theme.colors.background
147
151
  : isActive
148
152
  ? theme.colors.accent
149
- : theme.colors.text, bold: selectedIndex === 7, children: sleepTimerLabel }) }), _jsx(Box, { paddingX: 1, children: _jsx(Text, { backgroundColor: selectedIndex === 8 ? theme.colors.primary : undefined, color: selectedIndex === 8 ? theme.colors.background : theme.colors.text, bold: selectedIndex === 8, children: "Import Playlists \u2192" }) }), _jsx(Box, { paddingX: 1, children: _jsx(Text, { backgroundColor: selectedIndex === 9 ? theme.colors.primary : undefined, color: selectedIndex === 9 ? theme.colors.background : theme.colors.text, bold: selectedIndex === 9, children: "Custom Keybindings \u2192" }) }), _jsx(Box, { paddingX: 1, children: _jsx(Text, { backgroundColor: selectedIndex === 10 ? theme.colors.primary : undefined, color: selectedIndex === 10 ? theme.colors.background : theme.colors.text, bold: selectedIndex === 10, children: "Manage Plugins" }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.colors.dim, children: "Arrows to navigate, Enter to select, Esc/q to go back" }) })] }));
153
+ : theme.colors.text, bold: selectedIndex === 7, children: sleepTimerLabel }) }), _jsx(Box, { paddingX: 1, children: _jsx(Text, { backgroundColor: selectedIndex === 8 ? theme.colors.primary : undefined, color: selectedIndex === 8 ? theme.colors.background : theme.colors.text, bold: selectedIndex === 8, children: "Import Playlists \u2192" }) }), _jsx(Box, { paddingX: 1, children: _jsx(Text, { backgroundColor: selectedIndex === 9 ? theme.colors.primary : undefined, color: selectedIndex === 9 ? theme.colors.background : theme.colors.text, bold: selectedIndex === 9, children: "Export Playlists \u2192" }) }), _jsx(Box, { paddingX: 1, children: _jsx(Text, { backgroundColor: selectedIndex === 10 ? theme.colors.primary : undefined, color: selectedIndex === 10 ? theme.colors.background : theme.colors.text, bold: selectedIndex === 10, children: "Custom Keybindings \u2192" }) }), _jsx(Box, { paddingX: 1, children: _jsx(Text, { backgroundColor: selectedIndex === 11 ? theme.colors.primary : undefined, color: selectedIndex === 11 ? theme.colors.background : theme.colors.text, bold: selectedIndex === 11, children: "Manage Plugins" }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.colors.dim, children: "Arrows to navigate, Enter to select, Esc/q to go back" }) })] }));
150
154
  }
@@ -14,11 +14,31 @@ import { useNavigation } from "./hooks/useNavigation.js";
14
14
  import { usePlayer } from "./hooks/usePlayer.js";
15
15
  import { useYouTubeMusic } from "./hooks/useYouTubeMusic.js";
16
16
  import { VIEW } from "./utils/constants.js";
17
+ import { getConfigService } from "./services/config/config.service.js";
18
+ import { getPlayerService } from "./services/player/player.service.js";
19
+ import { getNotificationService } from "./services/notification/notification.service.js";
17
20
  function Initializer({ flags }) {
18
21
  const { dispatch } = useNavigation();
19
22
  const { play } = usePlayer();
20
23
  const { getTrack, getPlaylist } = useYouTubeMusic();
21
24
  useEffect(() => {
25
+ // Check for background playback state on startup
26
+ const config = getConfigService();
27
+ const player = getPlayerService();
28
+ const backgroundState = config.getBackgroundPlaybackState();
29
+ if (backgroundState.enabled) {
30
+ // Show notification about background playback
31
+ const notification = getNotificationService();
32
+ notification.setEnabled(true);
33
+ void notification.notify('Background Playback Active', 'Press R to resume control');
34
+ // Try to reattach to the existing mpv process
35
+ if (backgroundState.ipcPath) {
36
+ void player.reattach(backgroundState.ipcPath).catch(() => {
37
+ // Reattach failed, clear the state
38
+ config.clearBackgroundPlaybackState();
39
+ });
40
+ }
41
+ }
22
42
  if (flags?.showSuggestions) {
23
43
  dispatch({ category: 'NAVIGATE', view: VIEW.SUGGESTIONS });
24
44
  }
@@ -23,6 +23,21 @@ declare class ConfigService {
23
23
  removeFavorite(trackId: string): void;
24
24
  isFavorite(trackId: string): boolean;
25
25
  getFavorites(): string[];
26
+ setBackgroundPlaybackState(state: {
27
+ ipcPath: string;
28
+ currentUrl: string;
29
+ }): void;
30
+ getBackgroundPlaybackState(): {
31
+ enabled: true;
32
+ ipcPath: string;
33
+ currentUrl: string;
34
+ timestamp: string;
35
+ } | {
36
+ enabled: false;
37
+ };
38
+ clearBackgroundPlaybackState(): void;
39
+ setLastVersionCheck(timestamp: string): void;
40
+ getLastVersionCheck(): string | undefined;
26
41
  }
27
42
  export declare function getConfigService(): ConfigService;
28
43
  export {};
@@ -145,6 +145,37 @@ class ConfigService {
145
145
  getFavorites() {
146
146
  return this.config.favorites;
147
147
  }
148
+ setBackgroundPlaybackState(state) {
149
+ this.config.backgroundPlayback = {
150
+ enabled: true,
151
+ ipcPath: state.ipcPath,
152
+ currentUrl: state.currentUrl,
153
+ timestamp: new Date().toISOString(),
154
+ };
155
+ this.save();
156
+ }
157
+ getBackgroundPlaybackState() {
158
+ if (this.config.backgroundPlayback?.enabled) {
159
+ return {
160
+ enabled: true,
161
+ ipcPath: this.config.backgroundPlayback.ipcPath,
162
+ currentUrl: this.config.backgroundPlayback.currentUrl,
163
+ timestamp: this.config.backgroundPlayback.timestamp,
164
+ };
165
+ }
166
+ return { enabled: false };
167
+ }
168
+ clearBackgroundPlaybackState() {
169
+ this.config.backgroundPlayback = undefined;
170
+ this.save();
171
+ }
172
+ setLastVersionCheck(timestamp) {
173
+ this.config.lastVersionCheck = timestamp;
174
+ this.save();
175
+ }
176
+ getLastVersionCheck() {
177
+ return this.config.lastVersionCheck;
178
+ }
148
179
  }
149
180
  // Singleton instance
150
181
  let configServiceInstance = null;
@@ -0,0 +1,41 @@
1
+ import type { Playlist } from '../../types/youtube-music.types.ts';
2
+ export type ExportFormat = 'json' | 'm3u8' | 'both';
3
+ export interface ExportOptions {
4
+ format: ExportFormat;
5
+ outputDir?: string;
6
+ }
7
+ export interface ExportResult {
8
+ playlistName: string;
9
+ format: ExportFormat;
10
+ files: string[];
11
+ success: boolean;
12
+ error?: string;
13
+ }
14
+ declare class ExportService {
15
+ private static instance;
16
+ private readonly DEFAULT_EXPORT_DIR;
17
+ private constructor();
18
+ static getInstance(): ExportService;
19
+ /**
20
+ * Get the export directory, create if it doesn't exist
21
+ */
22
+ private getExportDir;
23
+ /**
24
+ * Sanitize filename for safe file system usage
25
+ */
26
+ private sanitizeFilename;
27
+ /**
28
+ * Generate M3U8 format content for a playlist
29
+ */
30
+ private generateM3U8;
31
+ /**
32
+ * Export a single playlist to the specified format(s)
33
+ */
34
+ exportPlaylist(playlist: Playlist, options: ExportOptions): Promise<ExportResult>;
35
+ /**
36
+ * Export multiple playlists to the specified format(s)
37
+ */
38
+ exportAllPlaylists(playlists: Playlist[], options: ExportOptions): Promise<ExportResult[]>;
39
+ }
40
+ export declare const getExportService: () => ExportService;
41
+ export {};
@@ -0,0 +1,131 @@
1
+ // Playlist export service for JSON and M3U8 formats
2
+ import { mkdirSync, writeFileSync, existsSync } from 'node:fs';
3
+ import { CONFIG_DIR } from "../../utils/constants.js";
4
+ import { logger } from "../logger/logger.service.js";
5
+ class ExportService {
6
+ static instance;
7
+ DEFAULT_EXPORT_DIR = `${CONFIG_DIR}/exports`;
8
+ constructor() { }
9
+ static getInstance() {
10
+ if (!ExportService.instance) {
11
+ ExportService.instance = new ExportService();
12
+ }
13
+ return ExportService.instance;
14
+ }
15
+ /**
16
+ * Get the export directory, create if it doesn't exist
17
+ */
18
+ getExportDir(customDir) {
19
+ const exportDir = customDir || this.DEFAULT_EXPORT_DIR;
20
+ if (!existsSync(exportDir)) {
21
+ try {
22
+ mkdirSync(exportDir, { recursive: true });
23
+ logger.info('ExportService', 'Created export directory', { exportDir });
24
+ }
25
+ catch (error) {
26
+ logger.error('ExportService', 'Failed to create export directory', {
27
+ error: error instanceof Error ? error.message : String(error),
28
+ });
29
+ throw new Error(`Failed to create export directory: ${exportDir}`);
30
+ }
31
+ }
32
+ return exportDir;
33
+ }
34
+ /**
35
+ * Sanitize filename for safe file system usage
36
+ */
37
+ sanitizeFilename(name) {
38
+ // Remove or replace characters that are unsafe for filenames
39
+ return name
40
+ .replace(/[<>:"/\\|?*]/g, '') // Remove unsafe chars
41
+ .replace(/\s+/g, '_') // Replace spaces with underscores
42
+ .substring(0, 200); // Limit length
43
+ }
44
+ /**
45
+ * Generate M3U8 format content for a playlist
46
+ */
47
+ generateM3U8(playlist) {
48
+ const lines = ['#EXTM3U', ''];
49
+ for (const track of playlist.tracks) {
50
+ if (track.artists && track.artists.length > 0) {
51
+ const artistNames = track.artists.map(a => a.name).join(', ');
52
+ const duration = track.duration
53
+ ? Math.round(track.duration / 1000)
54
+ : -1;
55
+ lines.push(`#EXTINF:${duration},${artistNames} - ${track.title}`);
56
+ }
57
+ else {
58
+ lines.push(`#EXTINF:-1,${track.title}`);
59
+ }
60
+ // Use the videoId to generate YouTube URL
61
+ if (track.videoId) {
62
+ lines.push(`https://www.youtube.com/watch?v=${track.videoId}`);
63
+ }
64
+ }
65
+ return lines.join('\n');
66
+ }
67
+ /**
68
+ * Export a single playlist to the specified format(s)
69
+ */
70
+ async exportPlaylist(playlist, options) {
71
+ try {
72
+ logger.info('ExportService', 'Exporting playlist', {
73
+ playlist: playlist.name,
74
+ format: options.format,
75
+ });
76
+ const exportDir = this.getExportDir(options.outputDir);
77
+ const sanitizedName = this.sanitizeFilename(playlist.name);
78
+ const files = [];
79
+ // Export to JSON
80
+ if (options.format === 'json' || options.format === 'both') {
81
+ const jsonPath = `${exportDir}/${sanitizedName}.json`;
82
+ const jsonContent = JSON.stringify(playlist, null, 2);
83
+ writeFileSync(jsonPath, jsonContent, 'utf-8');
84
+ files.push(jsonPath);
85
+ logger.info('ExportService', 'Exported to JSON', { path: jsonPath });
86
+ }
87
+ // Export to M3U8
88
+ if (options.format === 'm3u8' || options.format === 'both') {
89
+ const m3u8Path = `${exportDir}/${sanitizedName}.m3u8`;
90
+ const m3u8Content = this.generateM3U8(playlist);
91
+ writeFileSync(m3u8Path, m3u8Content, 'utf-8');
92
+ files.push(m3u8Path);
93
+ logger.info('ExportService', 'Exported to M3U8', { path: m3u8Path });
94
+ }
95
+ return {
96
+ playlistName: playlist.name,
97
+ format: options.format,
98
+ files,
99
+ success: true,
100
+ };
101
+ }
102
+ catch (error) {
103
+ logger.error('ExportService', 'Failed to export playlist', {
104
+ error: error instanceof Error ? error.message : String(error),
105
+ });
106
+ return {
107
+ playlistName: playlist.name,
108
+ format: options.format,
109
+ files: [],
110
+ success: false,
111
+ error: error instanceof Error ? error.message : String(error),
112
+ };
113
+ }
114
+ }
115
+ /**
116
+ * Export multiple playlists to the specified format(s)
117
+ */
118
+ async exportAllPlaylists(playlists, options) {
119
+ logger.info('ExportService', 'Exporting all playlists', {
120
+ count: playlists.length,
121
+ format: options.format,
122
+ });
123
+ const results = [];
124
+ for (const playlist of playlists) {
125
+ const result = await this.exportPlaylist(playlist, options);
126
+ results.push(result);
127
+ }
128
+ return results;
129
+ }
130
+ }
131
+ export const getExportService = () => ExportService.getInstance();
@@ -54,6 +54,18 @@ declare class PlayerService {
54
54
  pause(): void;
55
55
  resume(): void;
56
56
  stop(): void;
57
+ /**
58
+ * Detach mode: Save state and clear references without killing mpv process
59
+ * Returns the IPC path and current URL for later reattachment
60
+ */
61
+ detach(): {
62
+ ipcPath: string | null;
63
+ currentUrl: string | null;
64
+ };
65
+ /**
66
+ * Reattach to an existing mpv process via IPC
67
+ */
68
+ reattach(ipcPath: string): Promise<void>;
57
69
  setVolume(volume: number): void;
58
70
  getVolume(): number;
59
71
  setSpeed(speed: number): void;
@@ -360,6 +360,36 @@ class PlayerService {
360
360
  this.ipcPath = null;
361
361
  this.ipcConnectRetries = 0;
362
362
  }
363
+ /**
364
+ * Detach mode: Save state and clear references without killing mpv process
365
+ * Returns the IPC path and current URL for later reattachment
366
+ */
367
+ detach() {
368
+ logger.info('PlayerService', 'Detaching from player', {
369
+ ipcPath: this.ipcPath,
370
+ currentUrl: this.currentUrl,
371
+ });
372
+ const info = {
373
+ ipcPath: this.ipcPath,
374
+ currentUrl: this.currentUrl,
375
+ };
376
+ // Clear references but DON'T kill mpv process - it keeps playing
377
+ this.mpvProcess = null;
378
+ this.ipcSocket = null;
379
+ this.ipcPath = null;
380
+ this.isPlaying = false;
381
+ return info;
382
+ }
383
+ /**
384
+ * Reattach to an existing mpv process via IPC
385
+ */
386
+ async reattach(ipcPath) {
387
+ logger.info('PlayerService', 'Reattaching to player', { ipcPath });
388
+ this.ipcPath = ipcPath;
389
+ await this.connectIpc();
390
+ this.isPlaying = true;
391
+ logger.info('PlayerService', 'Successfully reattached to player');
392
+ }
363
393
  setVolume(volume) {
364
394
  logger.debug('PlayerService', 'setVolume() called', {
365
395
  oldVolume: this.currentVolume,
@@ -0,0 +1,32 @@
1
+ export interface VersionCheckResult {
2
+ hasUpdate: boolean;
3
+ currentVersion: string;
4
+ latestVersion: string;
5
+ }
6
+ declare class VersionCheckService {
7
+ private static instance;
8
+ private readonly NPM_REGISTRY_URL;
9
+ private readonly CHECK_INTERVAL;
10
+ private constructor();
11
+ static getInstance(): VersionCheckService;
12
+ /**
13
+ * Compare two semantic version strings
14
+ * Returns: 1 if a > b, -1 if a < b, 0 if equal
15
+ */
16
+ private compareVersions;
17
+ /**
18
+ * Check if a version check should be performed (once per 24 hours)
19
+ */
20
+ shouldCheck(lastCheck: string | undefined): boolean;
21
+ /**
22
+ * Mark that a version check has been performed
23
+ * Returns the timestamp string to store
24
+ */
25
+ markChecked(): string;
26
+ /**
27
+ * Check npm registry for available updates
28
+ */
29
+ checkForUpdates(currentVersion?: string): Promise<VersionCheckResult>;
30
+ }
31
+ export declare const getVersionCheckService: () => VersionCheckService;
32
+ export {};