@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.
Files changed (56) hide show
  1. package/dist/source/components/common/ShortcutsBar.js +3 -1
  2. package/dist/source/components/config/KeybindingsLayout.d.ts +1 -0
  3. package/dist/source/components/config/KeybindingsLayout.js +107 -0
  4. package/dist/source/components/layouts/ExploreLayout.d.ts +1 -0
  5. package/dist/source/components/layouts/ExploreLayout.js +72 -0
  6. package/dist/source/components/layouts/LyricsLayout.d.ts +1 -0
  7. package/dist/source/components/layouts/LyricsLayout.js +89 -0
  8. package/dist/source/components/layouts/MainLayout.js +39 -1
  9. package/dist/source/components/layouts/MiniPlayerLayout.d.ts +1 -0
  10. package/dist/source/components/layouts/MiniPlayerLayout.js +19 -0
  11. package/dist/source/components/layouts/SearchLayout.js +10 -3
  12. package/dist/source/components/layouts/TrendingLayout.d.ts +1 -0
  13. package/dist/source/components/layouts/TrendingLayout.js +59 -0
  14. package/dist/source/components/player/NowPlaying.js +21 -1
  15. package/dist/source/components/player/PlayerControls.js +4 -2
  16. package/dist/source/components/search/SearchBar.js +4 -1
  17. package/dist/source/components/search/SearchHistory.d.ts +5 -0
  18. package/dist/source/components/search/SearchHistory.js +35 -0
  19. package/dist/source/components/settings/Settings.js +74 -11
  20. package/dist/source/config/themes.config.js +60 -0
  21. package/dist/source/hooks/usePlayer.d.ts +5 -0
  22. package/dist/source/hooks/usePlaylist.d.ts +2 -1
  23. package/dist/source/hooks/usePlaylist.js +8 -2
  24. package/dist/source/hooks/useSleepTimer.d.ts +9 -0
  25. package/dist/source/hooks/useSleepTimer.js +48 -0
  26. package/dist/source/services/cache/cache.service.d.ts +14 -0
  27. package/dist/source/services/cache/cache.service.js +67 -0
  28. package/dist/source/services/config/config.service.d.ts +2 -0
  29. package/dist/source/services/config/config.service.js +17 -0
  30. package/dist/source/services/discord/discord-rpc.service.d.ts +17 -0
  31. package/dist/source/services/discord/discord-rpc.service.js +95 -0
  32. package/dist/source/services/lyrics/lyrics.service.d.ts +22 -0
  33. package/dist/source/services/lyrics/lyrics.service.js +93 -0
  34. package/dist/source/services/mpris/mpris.service.d.ts +20 -0
  35. package/dist/source/services/mpris/mpris.service.js +78 -0
  36. package/dist/source/services/notification/notification.service.d.ts +14 -0
  37. package/dist/source/services/notification/notification.service.js +57 -0
  38. package/dist/source/services/player/player.service.d.ts +3 -0
  39. package/dist/source/services/player/player.service.js +20 -3
  40. package/dist/source/services/scrobbling/scrobbling.service.d.ts +23 -0
  41. package/dist/source/services/scrobbling/scrobbling.service.js +115 -0
  42. package/dist/source/services/sleep-timer/sleep-timer.service.d.ts +16 -0
  43. package/dist/source/services/sleep-timer/sleep-timer.service.js +45 -0
  44. package/dist/source/services/youtube-music/api.d.ts +6 -0
  45. package/dist/source/services/youtube-music/api.js +102 -2
  46. package/dist/source/stores/navigation.store.js +6 -0
  47. package/dist/source/stores/player.store.d.ts +5 -0
  48. package/dist/source/stores/player.store.js +141 -24
  49. package/dist/source/types/actions.d.ts +13 -0
  50. package/dist/source/types/config.types.d.ts +15 -1
  51. package/dist/source/types/navigation.types.d.ts +3 -2
  52. package/dist/source/types/player.types.d.ts +3 -2
  53. package/dist/source/utils/constants.d.ts +9 -0
  54. package/dist/source/utils/constants.js +9 -0
  55. package/dist/youtube-music-cli.exe +0 -0
  56. 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, useCallback } from 'react';
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 = ['Stream Quality', 'Manage Plugins'];
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 navigateUp = useCallback(() => {
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
- }, [setSelectedIndex]);
21
- const navigateDown = useCallback(() => {
34
+ };
35
+ const navigateDown = () => {
22
36
  setSelectedIndex(prev => Math.min(SETTINGS_ITEMS.length - 1, prev + 1));
23
- }, [setSelectedIndex]);
24
- const toggleQuality = useCallback(() => {
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
- }, [quality, config]);
30
- const handleSelect = useCallback(() => {
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
- }, [selectedIndex, toggleQuality, dispatch]);
93
+ };
38
94
  useKeyBinding(KEYBINDINGS.UP, navigateUp);
39
95
  useKeyBinding(KEYBINDINGS.DOWN, navigateDown);
40
96
  useKeyBinding(KEYBINDINGS.SELECT, handleSelect);
41
- 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: _jsx(Text, { backgroundColor: selectedIndex === 1 ? theme.colors.primary : undefined, color: selectedIndex === 1 ? theme.colors.background : theme.colors.text, bold: selectedIndex === 1, 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" }) })] }));
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) => void;
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();