@involvex/youtube-music-cli 0.0.2 → 0.0.3
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/dist/source/components/common/ShortcutsBar.js +3 -1
- package/dist/source/components/config/KeybindingsLayout.d.ts +1 -0
- package/dist/source/components/config/KeybindingsLayout.js +107 -0
- package/dist/source/components/layouts/ExploreLayout.d.ts +1 -0
- package/dist/source/components/layouts/ExploreLayout.js +72 -0
- package/dist/source/components/layouts/LyricsLayout.d.ts +1 -0
- package/dist/source/components/layouts/LyricsLayout.js +89 -0
- package/dist/source/components/layouts/MainLayout.js +39 -1
- package/dist/source/components/layouts/MiniPlayerLayout.d.ts +1 -0
- package/dist/source/components/layouts/MiniPlayerLayout.js +19 -0
- package/dist/source/components/layouts/SearchLayout.js +10 -3
- package/dist/source/components/layouts/TrendingLayout.d.ts +1 -0
- package/dist/source/components/layouts/TrendingLayout.js +59 -0
- package/dist/source/components/player/NowPlaying.js +21 -1
- package/dist/source/components/player/PlayerControls.js +4 -2
- package/dist/source/components/search/SearchBar.js +4 -1
- package/dist/source/components/search/SearchHistory.d.ts +5 -0
- package/dist/source/components/search/SearchHistory.js +35 -0
- package/dist/source/components/settings/Settings.js +74 -11
- package/dist/source/config/themes.config.js +60 -0
- package/dist/source/hooks/usePlayer.d.ts +5 -0
- package/dist/source/hooks/usePlaylist.d.ts +2 -1
- package/dist/source/hooks/usePlaylist.js +8 -2
- package/dist/source/hooks/useSleepTimer.d.ts +9 -0
- package/dist/source/hooks/useSleepTimer.js +48 -0
- package/dist/source/services/cache/cache.service.d.ts +14 -0
- package/dist/source/services/cache/cache.service.js +67 -0
- package/dist/source/services/config/config.service.d.ts +2 -0
- package/dist/source/services/config/config.service.js +17 -0
- package/dist/source/services/discord/discord-rpc.service.d.ts +17 -0
- package/dist/source/services/discord/discord-rpc.service.js +95 -0
- package/dist/source/services/lyrics/lyrics.service.d.ts +22 -0
- package/dist/source/services/lyrics/lyrics.service.js +93 -0
- package/dist/source/services/mpris/mpris.service.d.ts +20 -0
- package/dist/source/services/mpris/mpris.service.js +78 -0
- package/dist/source/services/notification/notification.service.d.ts +14 -0
- package/dist/source/services/notification/notification.service.js +57 -0
- package/dist/source/services/player/player.service.d.ts +3 -0
- package/dist/source/services/player/player.service.js +20 -3
- package/dist/source/services/scrobbling/scrobbling.service.d.ts +23 -0
- package/dist/source/services/scrobbling/scrobbling.service.js +115 -0
- package/dist/source/services/sleep-timer/sleep-timer.service.d.ts +16 -0
- package/dist/source/services/sleep-timer/sleep-timer.service.js +45 -0
- package/dist/source/services/youtube-music/api.d.ts +6 -0
- package/dist/source/services/youtube-music/api.js +102 -2
- package/dist/source/stores/navigation.store.js +6 -0
- package/dist/source/stores/player.store.d.ts +5 -0
- package/dist/source/stores/player.store.js +141 -24
- package/dist/source/types/actions.d.ts +13 -0
- package/dist/source/types/config.types.d.ts +15 -1
- package/dist/source/types/navigation.types.d.ts +3 -2
- package/dist/source/types/player.types.d.ts +3 -2
- package/dist/source/utils/constants.d.ts +9 -0
- package/dist/source/utils/constants.js +9 -0
- package/dist/youtube-music-cli.exe +0 -0
- package/package.json +5 -2
|
@@ -1,42 +1,105 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
// Settings component
|
|
3
|
-
import { useState
|
|
3
|
+
import { useState } from 'react';
|
|
4
4
|
import { Box, Text } from 'ink';
|
|
5
5
|
import { useTheme } from "../../hooks/useTheme.js";
|
|
6
6
|
import { useNavigation } from "../../hooks/useNavigation.js";
|
|
7
7
|
import { getConfigService } from "../../services/config/config.service.js";
|
|
8
8
|
import { useKeyBinding } from "../../hooks/useKeyboard.js";
|
|
9
9
|
import { KEYBINDINGS, VIEW } from "../../utils/constants.js";
|
|
10
|
+
import { useSleepTimer } from "../../hooks/useSleepTimer.js";
|
|
11
|
+
import { formatTime } from "../../utils/format.js";
|
|
10
12
|
const QUALITIES = ['low', 'medium', 'high'];
|
|
11
|
-
const SETTINGS_ITEMS = [
|
|
13
|
+
const SETTINGS_ITEMS = [
|
|
14
|
+
'Stream Quality',
|
|
15
|
+
'Audio Normalization',
|
|
16
|
+
'Notifications',
|
|
17
|
+
'Discord Rich Presence',
|
|
18
|
+
'Sleep Timer',
|
|
19
|
+
'Custom Keybindings',
|
|
20
|
+
'Manage Plugins',
|
|
21
|
+
];
|
|
12
22
|
export default function Settings() {
|
|
13
23
|
const { theme } = useTheme();
|
|
14
24
|
const { dispatch } = useNavigation();
|
|
15
25
|
const config = getConfigService();
|
|
16
26
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
17
27
|
const [quality, setQuality] = useState(config.get('streamQuality') || 'high');
|
|
18
|
-
const
|
|
28
|
+
const [audioNormalization, setAudioNormalization] = useState(config.get('audioNormalization') ?? false);
|
|
29
|
+
const [notifications, setNotifications] = useState(config.get('notifications') ?? false);
|
|
30
|
+
const [discordRpc, setDiscordRpc] = useState(config.get('discordRichPresence') ?? false);
|
|
31
|
+
const { isActive, activeMinutes, remainingSeconds, startTimer, cancelTimer, presets, } = useSleepTimer();
|
|
32
|
+
const navigateUp = () => {
|
|
19
33
|
setSelectedIndex(prev => Math.max(0, prev - 1));
|
|
20
|
-
}
|
|
21
|
-
const navigateDown =
|
|
34
|
+
};
|
|
35
|
+
const navigateDown = () => {
|
|
22
36
|
setSelectedIndex(prev => Math.min(SETTINGS_ITEMS.length - 1, prev + 1));
|
|
23
|
-
}
|
|
24
|
-
const toggleQuality =
|
|
37
|
+
};
|
|
38
|
+
const toggleQuality = () => {
|
|
25
39
|
const currentIndex = QUALITIES.indexOf(quality);
|
|
26
40
|
const nextQuality = QUALITIES[(currentIndex + 1) % QUALITIES.length];
|
|
27
41
|
setQuality(nextQuality);
|
|
28
42
|
config.set('streamQuality', nextQuality);
|
|
29
|
-
}
|
|
30
|
-
const
|
|
43
|
+
};
|
|
44
|
+
const toggleNormalization = () => {
|
|
45
|
+
const next = !audioNormalization;
|
|
46
|
+
setAudioNormalization(next);
|
|
47
|
+
config.set('audioNormalization', next);
|
|
48
|
+
};
|
|
49
|
+
const toggleNotifications = () => {
|
|
50
|
+
const next = !notifications;
|
|
51
|
+
setNotifications(next);
|
|
52
|
+
config.set('notifications', next);
|
|
53
|
+
};
|
|
54
|
+
const toggleDiscordRpc = () => {
|
|
55
|
+
const next = !discordRpc;
|
|
56
|
+
setDiscordRpc(next);
|
|
57
|
+
config.set('discordRichPresence', next);
|
|
58
|
+
};
|
|
59
|
+
const cycleSleepTimer = () => {
|
|
60
|
+
if (isActive) {
|
|
61
|
+
cancelTimer();
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
// Find next preset (start from first if none active)
|
|
65
|
+
const currentPresetIndex = activeMinutes
|
|
66
|
+
? presets.indexOf(activeMinutes)
|
|
67
|
+
: -1;
|
|
68
|
+
const nextPreset = presets[(currentPresetIndex + 1) % presets.length];
|
|
69
|
+
startTimer(nextPreset);
|
|
70
|
+
};
|
|
71
|
+
const handleSelect = () => {
|
|
31
72
|
if (selectedIndex === 0) {
|
|
32
73
|
toggleQuality();
|
|
33
74
|
}
|
|
34
75
|
else if (selectedIndex === 1) {
|
|
76
|
+
toggleNormalization();
|
|
77
|
+
}
|
|
78
|
+
else if (selectedIndex === 2) {
|
|
79
|
+
toggleNotifications();
|
|
80
|
+
}
|
|
81
|
+
else if (selectedIndex === 3) {
|
|
82
|
+
toggleDiscordRpc();
|
|
83
|
+
}
|
|
84
|
+
else if (selectedIndex === 4) {
|
|
85
|
+
cycleSleepTimer();
|
|
86
|
+
}
|
|
87
|
+
else if (selectedIndex === 5) {
|
|
88
|
+
dispatch({ category: 'NAVIGATE', view: VIEW.KEYBINDINGS });
|
|
89
|
+
}
|
|
90
|
+
else if (selectedIndex === 6) {
|
|
35
91
|
dispatch({ category: 'NAVIGATE', view: VIEW.PLUGINS });
|
|
36
92
|
}
|
|
37
|
-
}
|
|
93
|
+
};
|
|
38
94
|
useKeyBinding(KEYBINDINGS.UP, navigateUp);
|
|
39
95
|
useKeyBinding(KEYBINDINGS.DOWN, navigateDown);
|
|
40
96
|
useKeyBinding(KEYBINDINGS.SELECT, handleSelect);
|
|
41
|
-
|
|
97
|
+
const sleepTimerLabel = isActive && remainingSeconds !== null
|
|
98
|
+
? `Sleep Timer: ${formatTime(remainingSeconds)} remaining (Enter to cancel)`
|
|
99
|
+
: 'Sleep Timer: Off (Enter to set)';
|
|
100
|
+
return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsx(Box, { borderStyle: "double", borderColor: theme.colors.secondary, paddingX: 1, marginBottom: 1, children: _jsx(Text, { bold: true, color: theme.colors.primary, children: "Settings" }) }), _jsx(Box, { paddingX: 1, children: _jsxs(Text, { backgroundColor: selectedIndex === 0 ? theme.colors.primary : undefined, color: selectedIndex === 0 ? theme.colors.background : theme.colors.text, bold: selectedIndex === 0, children: ["Stream Quality: ", quality.toUpperCase()] }) }), _jsx(Box, { paddingX: 1, children: _jsxs(Text, { backgroundColor: selectedIndex === 1 ? theme.colors.primary : undefined, color: selectedIndex === 1 ? theme.colors.background : theme.colors.text, bold: selectedIndex === 1, children: ["Audio Normalization: ", audioNormalization ? 'ON' : 'OFF'] }) }), _jsx(Box, { paddingX: 1, children: _jsxs(Text, { backgroundColor: selectedIndex === 2 ? theme.colors.primary : undefined, color: selectedIndex === 2 ? theme.colors.background : theme.colors.text, bold: selectedIndex === 2, children: ["Desktop Notifications: ", notifications ? 'ON' : 'OFF'] }) }), _jsx(Box, { paddingX: 1, children: _jsxs(Text, { backgroundColor: selectedIndex === 3 ? theme.colors.primary : undefined, color: selectedIndex === 3 ? theme.colors.background : theme.colors.text, bold: selectedIndex === 3, children: ["Discord Rich Presence: ", discordRpc ? 'ON' : 'OFF'] }) }), _jsx(Box, { paddingX: 1, children: _jsx(Text, { backgroundColor: selectedIndex === 4 ? theme.colors.primary : undefined, color: selectedIndex === 4
|
|
101
|
+
? theme.colors.background
|
|
102
|
+
: isActive
|
|
103
|
+
? theme.colors.accent
|
|
104
|
+
: theme.colors.text, bold: selectedIndex === 4, children: sleepTimerLabel }) }), _jsx(Box, { paddingX: 1, children: _jsx(Text, { backgroundColor: selectedIndex === 5 ? theme.colors.primary : undefined, color: selectedIndex === 5 ? theme.colors.background : theme.colors.text, bold: selectedIndex === 5, children: "Custom Keybindings \u2192" }) }), _jsx(Box, { paddingX: 1, children: _jsx(Text, { backgroundColor: selectedIndex === 6 ? theme.colors.primary : undefined, color: selectedIndex === 6 ? theme.colors.background : theme.colors.text, bold: selectedIndex === 6, 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" }) })] }));
|
|
42
105
|
}
|
|
@@ -59,5 +59,65 @@ export const BUILTIN_THEMES = {
|
|
|
59
59
|
},
|
|
60
60
|
inverse: false,
|
|
61
61
|
},
|
|
62
|
+
dracula: {
|
|
63
|
+
name: 'Dracula',
|
|
64
|
+
colors: {
|
|
65
|
+
primary: 'magenta',
|
|
66
|
+
secondary: 'cyan',
|
|
67
|
+
background: 'black',
|
|
68
|
+
text: 'white',
|
|
69
|
+
accent: 'yellow',
|
|
70
|
+
dim: 'gray',
|
|
71
|
+
error: 'red',
|
|
72
|
+
success: 'green',
|
|
73
|
+
warning: 'yellow',
|
|
74
|
+
},
|
|
75
|
+
inverse: false,
|
|
76
|
+
},
|
|
77
|
+
nord: {
|
|
78
|
+
name: 'Nord',
|
|
79
|
+
colors: {
|
|
80
|
+
primary: 'blue',
|
|
81
|
+
secondary: 'cyan',
|
|
82
|
+
background: 'black',
|
|
83
|
+
text: 'white',
|
|
84
|
+
accent: 'blueBright',
|
|
85
|
+
dim: 'gray',
|
|
86
|
+
error: 'red',
|
|
87
|
+
success: 'greenBright',
|
|
88
|
+
warning: 'yellow',
|
|
89
|
+
},
|
|
90
|
+
inverse: false,
|
|
91
|
+
},
|
|
92
|
+
solarized: {
|
|
93
|
+
name: 'Solarized',
|
|
94
|
+
colors: {
|
|
95
|
+
primary: 'cyan',
|
|
96
|
+
secondary: 'blue',
|
|
97
|
+
background: 'black',
|
|
98
|
+
text: 'white',
|
|
99
|
+
accent: 'yellow',
|
|
100
|
+
dim: 'gray',
|
|
101
|
+
error: 'red',
|
|
102
|
+
success: 'green',
|
|
103
|
+
warning: 'magenta',
|
|
104
|
+
},
|
|
105
|
+
inverse: false,
|
|
106
|
+
},
|
|
107
|
+
catppuccin: {
|
|
108
|
+
name: 'Catppuccin',
|
|
109
|
+
colors: {
|
|
110
|
+
primary: 'magenta',
|
|
111
|
+
secondary: 'blue',
|
|
112
|
+
background: 'black',
|
|
113
|
+
text: 'white',
|
|
114
|
+
accent: 'cyan',
|
|
115
|
+
dim: 'gray',
|
|
116
|
+
error: 'red',
|
|
117
|
+
success: 'green',
|
|
118
|
+
warning: 'yellow',
|
|
119
|
+
},
|
|
120
|
+
inverse: false,
|
|
121
|
+
},
|
|
62
122
|
};
|
|
63
123
|
export const DEFAULT_THEME = BUILTIN_THEMES['dark'];
|
|
@@ -13,6 +13,8 @@ export declare function usePlayer(): {
|
|
|
13
13
|
setVolume: (volume: number) => void;
|
|
14
14
|
volumeUp: () => void;
|
|
15
15
|
volumeDown: () => void;
|
|
16
|
+
volumeFineUp: () => void;
|
|
17
|
+
volumeFineDown: () => void;
|
|
16
18
|
toggleShuffle: () => void;
|
|
17
19
|
toggleRepeat: () => void;
|
|
18
20
|
setQueue: (queue: Track[]) => void;
|
|
@@ -20,4 +22,7 @@ export declare function usePlayer(): {
|
|
|
20
22
|
removeFromQueue: (index: number) => void;
|
|
21
23
|
clearQueue: () => void;
|
|
22
24
|
setQueuePosition: (position: number) => void;
|
|
25
|
+
setSpeed: (speed: number) => void;
|
|
26
|
+
speedUp: () => void;
|
|
27
|
+
speedDown: () => void;
|
|
23
28
|
};
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { Playlist, Track } from '../types/youtube-music.types.ts';
|
|
2
|
+
export type AddTrackResult = 'added' | 'duplicate';
|
|
2
3
|
export declare function usePlaylist(): {
|
|
3
4
|
playlists: Playlist[];
|
|
4
5
|
createPlaylist: (name: string) => void;
|
|
5
6
|
deletePlaylist: (playlistId: string) => void;
|
|
6
|
-
addTrackToPlaylist: (playlistId: string, track: Track) =>
|
|
7
|
+
addTrackToPlaylist: (playlistId: string, track: Track, force?: boolean) => AddTrackResult;
|
|
7
8
|
removeTrackFromPlaylist: (playlistId: string, trackIndex: number) => void;
|
|
8
9
|
};
|
|
@@ -22,14 +22,20 @@ export function usePlaylist() {
|
|
|
22
22
|
setPlaylists(updatedPlaylists);
|
|
23
23
|
configService.set('playlists', updatedPlaylists);
|
|
24
24
|
}, [playlists, configService]);
|
|
25
|
-
const addTrackToPlaylist = useCallback((playlistId, track) => {
|
|
25
|
+
const addTrackToPlaylist = useCallback((playlistId, track, force = false) => {
|
|
26
26
|
const playlistIndex = playlists.findIndex(p => p.playlistId === playlistId);
|
|
27
27
|
if (playlistIndex === -1)
|
|
28
|
-
return;
|
|
28
|
+
return 'added';
|
|
29
|
+
const playlist = playlists[playlistIndex];
|
|
30
|
+
const isDuplicate = playlist.tracks.some(t => t.videoId === track.videoId);
|
|
31
|
+
if (isDuplicate && !force) {
|
|
32
|
+
return 'duplicate';
|
|
33
|
+
}
|
|
29
34
|
const updatedPlaylists = [...playlists];
|
|
30
35
|
updatedPlaylists[playlistIndex].tracks.push(track);
|
|
31
36
|
setPlaylists(updatedPlaylists);
|
|
32
37
|
configService.set('playlists', updatedPlaylists);
|
|
38
|
+
return 'added';
|
|
33
39
|
}, [playlists, configService]);
|
|
34
40
|
const removeTrackFromPlaylist = useCallback((playlistId, trackIndex) => {
|
|
35
41
|
const playlistIndex = playlists.findIndex(p => p.playlistId === playlistId);
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type SleepTimerPreset } from '../services/sleep-timer/sleep-timer.service.ts';
|
|
2
|
+
export declare function useSleepTimer(): {
|
|
3
|
+
isActive: boolean;
|
|
4
|
+
activeMinutes: number | null;
|
|
5
|
+
remainingSeconds: number | null;
|
|
6
|
+
startTimer: (minutes: SleepTimerPreset) => void;
|
|
7
|
+
cancelTimer: () => void;
|
|
8
|
+
presets: readonly [5, 10, 15, 30, 60];
|
|
9
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Hook for managing the sleep timer
|
|
2
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
3
|
+
import { getSleepTimerService, SLEEP_TIMER_PRESETS, } from "../services/sleep-timer/sleep-timer.service.js";
|
|
4
|
+
import { usePlayer } from "./usePlayer.js";
|
|
5
|
+
export function useSleepTimer() {
|
|
6
|
+
const { pause } = usePlayer();
|
|
7
|
+
const timerService = getSleepTimerService();
|
|
8
|
+
const [remainingSeconds, setRemainingSeconds] = useState(null);
|
|
9
|
+
const [activeMinutes, setActiveMinutes] = useState(null);
|
|
10
|
+
// Poll remaining time every second when active
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
if (!timerService.isActive())
|
|
13
|
+
return;
|
|
14
|
+
const interval = setInterval(() => {
|
|
15
|
+
const remaining = timerService.getRemainingSeconds();
|
|
16
|
+
setRemainingSeconds(remaining);
|
|
17
|
+
if (remaining === 0) {
|
|
18
|
+
setActiveMinutes(null);
|
|
19
|
+
clearInterval(interval);
|
|
20
|
+
}
|
|
21
|
+
}, 1000);
|
|
22
|
+
return () => {
|
|
23
|
+
clearInterval(interval);
|
|
24
|
+
};
|
|
25
|
+
}, [activeMinutes, timerService]);
|
|
26
|
+
const startTimer = useCallback((minutes) => {
|
|
27
|
+
setActiveMinutes(minutes);
|
|
28
|
+
setRemainingSeconds(minutes * 60);
|
|
29
|
+
timerService.start(minutes, () => {
|
|
30
|
+
pause();
|
|
31
|
+
setActiveMinutes(null);
|
|
32
|
+
setRemainingSeconds(null);
|
|
33
|
+
});
|
|
34
|
+
}, [timerService, pause]);
|
|
35
|
+
const cancelTimer = useCallback(() => {
|
|
36
|
+
timerService.cancel();
|
|
37
|
+
setActiveMinutes(null);
|
|
38
|
+
setRemainingSeconds(null);
|
|
39
|
+
}, [timerService]);
|
|
40
|
+
return {
|
|
41
|
+
isActive: timerService.isActive(),
|
|
42
|
+
activeMinutes,
|
|
43
|
+
remainingSeconds,
|
|
44
|
+
startTimer,
|
|
45
|
+
cancelTimer,
|
|
46
|
+
presets: SLEEP_TIMER_PRESETS,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export declare class CacheService<T = unknown> {
|
|
2
|
+
private cache;
|
|
3
|
+
private readonly maxSize;
|
|
4
|
+
private readonly defaultTtlMs;
|
|
5
|
+
constructor(maxSize?: number, defaultTtlMs?: number);
|
|
6
|
+
get(key: string): T | null;
|
|
7
|
+
set(key: string, value: T, ttlMs?: number): void;
|
|
8
|
+
has(key: string): boolean;
|
|
9
|
+
delete(key: string): void;
|
|
10
|
+
clear(): void;
|
|
11
|
+
get size(): number;
|
|
12
|
+
private evictLru;
|
|
13
|
+
}
|
|
14
|
+
export declare const getSearchCache: () => CacheService;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// In-memory LRU cache with optional TTL for API responses
|
|
2
|
+
import { logger } from "../logger/logger.service.js";
|
|
3
|
+
export class CacheService {
|
|
4
|
+
cache = new Map();
|
|
5
|
+
maxSize;
|
|
6
|
+
defaultTtlMs;
|
|
7
|
+
constructor(maxSize = 100, defaultTtlMs = 5 * 60 * 1000) {
|
|
8
|
+
this.maxSize = maxSize;
|
|
9
|
+
this.defaultTtlMs = defaultTtlMs;
|
|
10
|
+
}
|
|
11
|
+
get(key) {
|
|
12
|
+
const entry = this.cache.get(key);
|
|
13
|
+
if (!entry)
|
|
14
|
+
return null;
|
|
15
|
+
if (Date.now() > entry.expiresAt) {
|
|
16
|
+
this.cache.delete(key);
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
entry.lastAccessed = Date.now();
|
|
20
|
+
return entry.value;
|
|
21
|
+
}
|
|
22
|
+
set(key, value, ttlMs) {
|
|
23
|
+
// Evict LRU entry if at capacity
|
|
24
|
+
if (this.cache.size >= this.maxSize && !this.cache.has(key)) {
|
|
25
|
+
this.evictLru();
|
|
26
|
+
}
|
|
27
|
+
this.cache.set(key, {
|
|
28
|
+
value,
|
|
29
|
+
expiresAt: Date.now() + (ttlMs ?? this.defaultTtlMs),
|
|
30
|
+
lastAccessed: Date.now(),
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
has(key) {
|
|
34
|
+
return this.get(key) !== null;
|
|
35
|
+
}
|
|
36
|
+
delete(key) {
|
|
37
|
+
this.cache.delete(key);
|
|
38
|
+
}
|
|
39
|
+
clear() {
|
|
40
|
+
this.cache.clear();
|
|
41
|
+
}
|
|
42
|
+
get size() {
|
|
43
|
+
return this.cache.size;
|
|
44
|
+
}
|
|
45
|
+
evictLru() {
|
|
46
|
+
let lruKey = null;
|
|
47
|
+
let lruTime = Infinity;
|
|
48
|
+
for (const [key, entry] of this.cache) {
|
|
49
|
+
if (entry.lastAccessed < lruTime) {
|
|
50
|
+
lruTime = entry.lastAccessed;
|
|
51
|
+
lruKey = key;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (lruKey) {
|
|
55
|
+
logger.debug('CacheService', 'Evicting LRU entry', { key: lruKey });
|
|
56
|
+
this.cache.delete(lruKey);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
// Shared search result cache (100 entries, 5min TTL)
|
|
61
|
+
let searchCacheInstance = null;
|
|
62
|
+
export const getSearchCache = () => {
|
|
63
|
+
if (!searchCacheInstance) {
|
|
64
|
+
searchCacheInstance = new CacheService(100, 5 * 60 * 1000);
|
|
65
|
+
}
|
|
66
|
+
return searchCacheInstance;
|
|
67
|
+
};
|
|
@@ -17,6 +17,8 @@ declare class ConfigService {
|
|
|
17
17
|
setKeybinding(action: string, keys: string[]): void;
|
|
18
18
|
addToHistory(trackId: string): void;
|
|
19
19
|
getHistory(): string[];
|
|
20
|
+
addToSearchHistory(query: string): void;
|
|
21
|
+
getSearchHistory(): string[];
|
|
20
22
|
addFavorite(trackId: string): void;
|
|
21
23
|
removeFavorite(trackId: string): void;
|
|
22
24
|
isFavorite(trackId: string): boolean;
|
|
@@ -18,11 +18,15 @@ class ConfigService {
|
|
|
18
18
|
keybindings: {},
|
|
19
19
|
playlists: [],
|
|
20
20
|
history: [],
|
|
21
|
+
searchHistory: [],
|
|
21
22
|
favorites: [],
|
|
22
23
|
repeat: 'off',
|
|
23
24
|
shuffle: false,
|
|
24
25
|
customTheme: undefined,
|
|
25
26
|
streamQuality: 'high',
|
|
27
|
+
audioNormalization: false,
|
|
28
|
+
notifications: false,
|
|
29
|
+
discordRichPresence: false,
|
|
26
30
|
};
|
|
27
31
|
}
|
|
28
32
|
load() {
|
|
@@ -98,6 +102,19 @@ class ConfigService {
|
|
|
98
102
|
getHistory() {
|
|
99
103
|
return this.config.history;
|
|
100
104
|
}
|
|
105
|
+
addToSearchHistory(query) {
|
|
106
|
+
const trimmed = query.trim();
|
|
107
|
+
if (!trimmed)
|
|
108
|
+
return;
|
|
109
|
+
this.config.searchHistory = [
|
|
110
|
+
trimmed,
|
|
111
|
+
...(this.config.searchHistory ?? []).filter(q => q !== trimmed),
|
|
112
|
+
].slice(0, 100);
|
|
113
|
+
this.save();
|
|
114
|
+
}
|
|
115
|
+
getSearchHistory() {
|
|
116
|
+
return this.config.searchHistory ?? [];
|
|
117
|
+
}
|
|
101
118
|
addFavorite(trackId) {
|
|
102
119
|
if (!this.config.favorites.includes(trackId)) {
|
|
103
120
|
this.config.favorites.push(trackId);
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
interface TrackInfo {
|
|
2
|
+
title: string;
|
|
3
|
+
artist: string;
|
|
4
|
+
startTimestamp?: number;
|
|
5
|
+
}
|
|
6
|
+
export declare class DiscordRpcService {
|
|
7
|
+
private client;
|
|
8
|
+
private connected;
|
|
9
|
+
private enabled;
|
|
10
|
+
setEnabled(enabled: boolean): void;
|
|
11
|
+
connect(): Promise<void>;
|
|
12
|
+
updateActivity(track: TrackInfo): Promise<void>;
|
|
13
|
+
clearActivity(): Promise<void>;
|
|
14
|
+
disconnect(): Promise<void>;
|
|
15
|
+
}
|
|
16
|
+
export declare const getDiscordRpcService: () => DiscordRpcService;
|
|
17
|
+
export {};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Discord Rich Presence service
|
|
2
|
+
// Uses discord-rpc package if available; gracefully no-ops if Discord is not running
|
|
3
|
+
import { logger } from "../logger/logger.service.js";
|
|
4
|
+
export class DiscordRpcService {
|
|
5
|
+
client = null;
|
|
6
|
+
connected = false;
|
|
7
|
+
enabled = false;
|
|
8
|
+
setEnabled(enabled) {
|
|
9
|
+
this.enabled = enabled;
|
|
10
|
+
if (!enabled) {
|
|
11
|
+
void this.disconnect();
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
async connect() {
|
|
15
|
+
if (!this.enabled || this.connected)
|
|
16
|
+
return;
|
|
17
|
+
try {
|
|
18
|
+
// Dynamic import so missing package doesn't crash startup
|
|
19
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
20
|
+
// @ts-ignore
|
|
21
|
+
const rpc = await import('discord-rpc');
|
|
22
|
+
const client = new rpc.Client({ transport: 'ipc' });
|
|
23
|
+
await new Promise((resolve, reject) => {
|
|
24
|
+
const timeout = setTimeout(() => {
|
|
25
|
+
reject(new Error('Discord RPC connection timeout'));
|
|
26
|
+
}, 5000);
|
|
27
|
+
client.on('ready', () => {
|
|
28
|
+
clearTimeout(timeout);
|
|
29
|
+
this.connected = true;
|
|
30
|
+
logger.info('DiscordRpcService', 'Connected to Discord');
|
|
31
|
+
resolve();
|
|
32
|
+
});
|
|
33
|
+
client
|
|
34
|
+
.login({ clientId: '1234567890' }) // Public client ID for music players
|
|
35
|
+
.catch(reject);
|
|
36
|
+
});
|
|
37
|
+
this.client = client;
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
logger.warn('DiscordRpcService', 'Could not connect to Discord', {
|
|
41
|
+
error: error instanceof Error ? error.message : String(error),
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
async updateActivity(track) {
|
|
46
|
+
if (!this.enabled || !this.connected || !this.client)
|
|
47
|
+
return;
|
|
48
|
+
try {
|
|
49
|
+
const c = this.client;
|
|
50
|
+
await c.setActivity({
|
|
51
|
+
details: track.title,
|
|
52
|
+
state: `by ${track.artist}`,
|
|
53
|
+
startTimestamp: track.startTimestamp ?? Date.now(),
|
|
54
|
+
largeImageKey: 'logo',
|
|
55
|
+
largeImageText: 'YouTube Music CLI',
|
|
56
|
+
instance: false,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
logger.warn('DiscordRpcService', 'Failed to update Discord activity', {
|
|
61
|
+
error: error instanceof Error ? error.message : String(error),
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
async clearActivity() {
|
|
66
|
+
if (!this.connected || !this.client)
|
|
67
|
+
return;
|
|
68
|
+
try {
|
|
69
|
+
const c = this.client;
|
|
70
|
+
await c.clearActivity();
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
// Ignore
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
async disconnect() {
|
|
77
|
+
if (!this.client)
|
|
78
|
+
return;
|
|
79
|
+
try {
|
|
80
|
+
const c = this.client;
|
|
81
|
+
await c.destroy();
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
// Ignore
|
|
85
|
+
}
|
|
86
|
+
this.client = null;
|
|
87
|
+
this.connected = false;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
let instance = null;
|
|
91
|
+
export const getDiscordRpcService = () => {
|
|
92
|
+
if (!instance)
|
|
93
|
+
instance = new DiscordRpcService();
|
|
94
|
+
return instance;
|
|
95
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export interface LyricLine {
|
|
2
|
+
time: number;
|
|
3
|
+
text: string;
|
|
4
|
+
}
|
|
5
|
+
export interface Lyrics {
|
|
6
|
+
synced: LyricLine[] | null;
|
|
7
|
+
plain: string | null;
|
|
8
|
+
}
|
|
9
|
+
declare class LyricsService {
|
|
10
|
+
private static instance;
|
|
11
|
+
private cache;
|
|
12
|
+
private constructor();
|
|
13
|
+
static getInstance(): LyricsService;
|
|
14
|
+
/** Parse LRC format into timed lines */
|
|
15
|
+
private parseLrc;
|
|
16
|
+
getLyrics(trackName: string, artistName: string, duration?: number): Promise<Lyrics | null>;
|
|
17
|
+
/** Get the current lyric line index based on playback position */
|
|
18
|
+
getCurrentLineIndex(lines: LyricLine[], currentTime: number): number;
|
|
19
|
+
clearCache(): void;
|
|
20
|
+
}
|
|
21
|
+
export declare const getLyricsService: () => LyricsService;
|
|
22
|
+
export {};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Lyrics service using LRCLIB API (https://lrclib.net)
|
|
2
|
+
// Free, no authentication required
|
|
3
|
+
import { logger } from "../logger/logger.service.js";
|
|
4
|
+
const LRCLIB_BASE = 'https://lrclib.net/api';
|
|
5
|
+
class LyricsService {
|
|
6
|
+
static instance;
|
|
7
|
+
cache = new Map();
|
|
8
|
+
constructor() { }
|
|
9
|
+
static getInstance() {
|
|
10
|
+
if (!LyricsService.instance) {
|
|
11
|
+
LyricsService.instance = new LyricsService();
|
|
12
|
+
}
|
|
13
|
+
return LyricsService.instance;
|
|
14
|
+
}
|
|
15
|
+
/** Parse LRC format into timed lines */
|
|
16
|
+
parseLrc(lrc) {
|
|
17
|
+
const lines = [];
|
|
18
|
+
const lineRegex = /\[(\d{2}):(\d{2})\.(\d{2,3})\](.*)/;
|
|
19
|
+
for (const rawLine of lrc.split('\n')) {
|
|
20
|
+
const match = lineRegex.exec(rawLine.trim());
|
|
21
|
+
if (match) {
|
|
22
|
+
const minutes = Number.parseInt(match[1], 10);
|
|
23
|
+
const seconds = Number.parseInt(match[2], 10);
|
|
24
|
+
const centiseconds = Number.parseInt(match[3].padEnd(3, '0'), 10);
|
|
25
|
+
const time = minutes * 60 + seconds + centiseconds / 1000;
|
|
26
|
+
const text = match[4].trim();
|
|
27
|
+
lines.push({ time, text });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return lines.sort((a, b) => a.time - b.time);
|
|
31
|
+
}
|
|
32
|
+
async getLyrics(trackName, artistName, duration) {
|
|
33
|
+
const cacheKey = `${trackName}::${artistName}`;
|
|
34
|
+
if (this.cache.has(cacheKey)) {
|
|
35
|
+
return this.cache.get(cacheKey) ?? null;
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
const params = new URLSearchParams({
|
|
39
|
+
track_name: trackName,
|
|
40
|
+
artist_name: artistName,
|
|
41
|
+
...(duration ? { duration: String(Math.round(duration)) } : {}),
|
|
42
|
+
});
|
|
43
|
+
const response = await fetch(`${LRCLIB_BASE}/get?${params.toString()}`);
|
|
44
|
+
if (!response.ok) {
|
|
45
|
+
if (response.status === 404) {
|
|
46
|
+
logger.debug('LyricsService', 'No lyrics found', {
|
|
47
|
+
trackName,
|
|
48
|
+
artistName,
|
|
49
|
+
});
|
|
50
|
+
this.cache.set(cacheKey, null);
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
throw new Error(`LRCLIB API error: ${response.status}`);
|
|
54
|
+
}
|
|
55
|
+
const data = (await response.json());
|
|
56
|
+
const lyrics = {
|
|
57
|
+
synced: data.syncedLyrics ? this.parseLrc(data.syncedLyrics) : null,
|
|
58
|
+
plain: data.plainLyrics ?? null,
|
|
59
|
+
};
|
|
60
|
+
this.cache.set(cacheKey, lyrics);
|
|
61
|
+
logger.info('LyricsService', 'Lyrics loaded', {
|
|
62
|
+
trackName,
|
|
63
|
+
hasSynced: !!lyrics.synced,
|
|
64
|
+
hasPlain: !!lyrics.plain,
|
|
65
|
+
});
|
|
66
|
+
return lyrics;
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
logger.warn('LyricsService', 'Failed to fetch lyrics', {
|
|
70
|
+
error: error instanceof Error ? error.message : String(error),
|
|
71
|
+
});
|
|
72
|
+
this.cache.set(cacheKey, null);
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/** Get the current lyric line index based on playback position */
|
|
77
|
+
getCurrentLineIndex(lines, currentTime) {
|
|
78
|
+
let index = 0;
|
|
79
|
+
for (let i = 0; i < lines.length; i++) {
|
|
80
|
+
if (lines[i].time <= currentTime) {
|
|
81
|
+
index = i;
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return index;
|
|
88
|
+
}
|
|
89
|
+
clearCache() {
|
|
90
|
+
this.cache.clear();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
export const getLyricsService = () => LyricsService.getInstance();
|