@involvex/youtube-music-cli 0.0.0

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 (136) hide show
  1. package/README.md +352 -0
  2. package/dist/eslint.config.d.ts +2 -0
  3. package/dist/eslint.config.js +55 -0
  4. package/dist/source/app.d.ts +4 -0
  5. package/dist/source/app.js +17 -0
  6. package/dist/source/cli.d.ts +2 -0
  7. package/dist/source/cli.js +241 -0
  8. package/dist/source/components/common/ErrorBoundary.d.ts +15 -0
  9. package/dist/source/components/common/ErrorBoundary.js +22 -0
  10. package/dist/source/components/common/Help.d.ts +1 -0
  11. package/dist/source/components/common/Help.js +10 -0
  12. package/dist/source/components/common/ShortcutsBar.d.ts +1 -0
  13. package/dist/source/components/common/ShortcutsBar.js +33 -0
  14. package/dist/source/components/config/ConfigLayout.d.ts +1 -0
  15. package/dist/source/components/config/ConfigLayout.js +84 -0
  16. package/dist/source/components/layouts/MainLayout.d.ts +4 -0
  17. package/dist/source/components/layouts/MainLayout.js +83 -0
  18. package/dist/source/components/layouts/PlayerLayout.d.ts +1 -0
  19. package/dist/source/components/layouts/PlayerLayout.js +10 -0
  20. package/dist/source/components/layouts/PluginsLayout.d.ts +1 -0
  21. package/dist/source/components/layouts/PluginsLayout.js +77 -0
  22. package/dist/source/components/layouts/SearchLayout.d.ts +4 -0
  23. package/dist/source/components/layouts/SearchLayout.js +81 -0
  24. package/dist/source/components/player/NowPlaying.d.ts +1 -0
  25. package/dist/source/components/player/NowPlaying.js +21 -0
  26. package/dist/source/components/player/PlayerControls.d.ts +1 -0
  27. package/dist/source/components/player/PlayerControls.js +41 -0
  28. package/dist/source/components/player/ProgressBar.d.ts +1 -0
  29. package/dist/source/components/player/ProgressBar.js +18 -0
  30. package/dist/source/components/player/QueueList.d.ts +4 -0
  31. package/dist/source/components/player/QueueList.js +30 -0
  32. package/dist/source/components/player/Suggestions.d.ts +1 -0
  33. package/dist/source/components/player/Suggestions.js +47 -0
  34. package/dist/source/components/playlist/PlaylistList.d.ts +1 -0
  35. package/dist/source/components/playlist/PlaylistList.js +11 -0
  36. package/dist/source/components/plugins/PluginInstallDialog.d.ts +5 -0
  37. package/dist/source/components/plugins/PluginInstallDialog.js +41 -0
  38. package/dist/source/components/plugins/PluginsAvailable.d.ts +5 -0
  39. package/dist/source/components/plugins/PluginsAvailable.js +55 -0
  40. package/dist/source/components/plugins/PluginsList.d.ts +8 -0
  41. package/dist/source/components/plugins/PluginsList.js +18 -0
  42. package/dist/source/components/search/SearchBar.d.ts +8 -0
  43. package/dist/source/components/search/SearchBar.js +50 -0
  44. package/dist/source/components/search/SearchResults.d.ts +10 -0
  45. package/dist/source/components/search/SearchResults.js +111 -0
  46. package/dist/source/components/settings/Settings.d.ts +1 -0
  47. package/dist/source/components/settings/Settings.js +42 -0
  48. package/dist/source/components/theme/ThemeSwitcher.d.ts +1 -0
  49. package/dist/source/components/theme/ThemeSwitcher.js +11 -0
  50. package/dist/source/config/themes.config.d.ts +3 -0
  51. package/dist/source/config/themes.config.js +63 -0
  52. package/dist/source/contexts/theme.context.d.ts +13 -0
  53. package/dist/source/contexts/theme.context.js +29 -0
  54. package/dist/source/hooks/useKeyboard.d.ts +10 -0
  55. package/dist/source/hooks/useKeyboard.js +104 -0
  56. package/dist/source/hooks/useNavigation.d.ts +1 -0
  57. package/dist/source/hooks/useNavigation.js +5 -0
  58. package/dist/source/hooks/usePlayer.d.ts +23 -0
  59. package/dist/source/hooks/usePlayer.js +35 -0
  60. package/dist/source/hooks/usePlaylist.d.ts +8 -0
  61. package/dist/source/hooks/usePlaylist.js +50 -0
  62. package/dist/source/hooks/useSearch.d.ts +8 -0
  63. package/dist/source/hooks/useSearch.js +76 -0
  64. package/dist/source/hooks/useTerminalSize.d.ts +4 -0
  65. package/dist/source/hooks/useTerminalSize.js +24 -0
  66. package/dist/source/hooks/useTheme.d.ts +6 -0
  67. package/dist/source/hooks/useTheme.js +5 -0
  68. package/dist/source/hooks/useYouTubeMusic.d.ts +11 -0
  69. package/dist/source/hooks/useYouTubeMusic.js +112 -0
  70. package/dist/source/main.d.ts +4 -0
  71. package/dist/source/main.js +69 -0
  72. package/dist/source/services/config/config.service.d.ts +26 -0
  73. package/dist/source/services/config/config.service.js +125 -0
  74. package/dist/source/services/logger/logger.service.d.ts +10 -0
  75. package/dist/source/services/logger/logger.service.js +52 -0
  76. package/dist/source/services/player/player.service.d.ts +58 -0
  77. package/dist/source/services/player/player.service.js +349 -0
  78. package/dist/source/services/player-state/player-state.service.d.ts +24 -0
  79. package/dist/source/services/player-state/player-state.service.js +122 -0
  80. package/dist/source/services/plugin/plugin-audio-api.d.ts +17 -0
  81. package/dist/source/services/plugin/plugin-audio-api.js +36 -0
  82. package/dist/source/services/plugin/plugin-context.d.ts +5 -0
  83. package/dist/source/services/plugin/plugin-context.js +256 -0
  84. package/dist/source/services/plugin/plugin-hooks.service.d.ts +62 -0
  85. package/dist/source/services/plugin/plugin-hooks.service.js +135 -0
  86. package/dist/source/services/plugin/plugin-installer.service.d.ts +27 -0
  87. package/dist/source/services/plugin/plugin-installer.service.js +247 -0
  88. package/dist/source/services/plugin/plugin-loader.service.d.ts +33 -0
  89. package/dist/source/services/plugin/plugin-loader.service.js +161 -0
  90. package/dist/source/services/plugin/plugin-permissions.service.d.ts +72 -0
  91. package/dist/source/services/plugin/plugin-permissions.service.js +194 -0
  92. package/dist/source/services/plugin/plugin-registry.service.d.ts +76 -0
  93. package/dist/source/services/plugin/plugin-registry.service.js +215 -0
  94. package/dist/source/services/plugin/plugin-ui-api.d.ts +25 -0
  95. package/dist/source/services/plugin/plugin-ui-api.js +46 -0
  96. package/dist/source/services/plugin/plugin-updater.service.d.ts +23 -0
  97. package/dist/source/services/plugin/plugin-updater.service.js +206 -0
  98. package/dist/source/services/youtube-music/api.d.ts +13 -0
  99. package/dist/source/services/youtube-music/api.js +371 -0
  100. package/dist/source/services/youtube-music/search.service.d.ts +11 -0
  101. package/dist/source/services/youtube-music/search.service.js +38 -0
  102. package/dist/source/stores/navigation.store.d.ts +10 -0
  103. package/dist/source/stores/navigation.store.js +67 -0
  104. package/dist/source/stores/player.store.d.ts +28 -0
  105. package/dist/source/stores/player.store.js +458 -0
  106. package/dist/source/stores/plugins.store.d.ts +46 -0
  107. package/dist/source/stores/plugins.store.js +177 -0
  108. package/dist/source/types/actions.d.ts +119 -0
  109. package/dist/source/types/actions.js +1 -0
  110. package/dist/source/types/cli.types.d.ts +14 -0
  111. package/dist/source/types/cli.types.js +1 -0
  112. package/dist/source/types/config.types.d.ts +19 -0
  113. package/dist/source/types/config.types.js +1 -0
  114. package/dist/source/types/keyboard.types.d.ts +5 -0
  115. package/dist/source/types/keyboard.types.js +1 -0
  116. package/dist/source/types/navigation.types.d.ts +14 -0
  117. package/dist/source/types/navigation.types.js +1 -0
  118. package/dist/source/types/player.types.d.ts +16 -0
  119. package/dist/source/types/player.types.js +1 -0
  120. package/dist/source/types/playlist.types.d.ts +12 -0
  121. package/dist/source/types/playlist.types.js +1 -0
  122. package/dist/source/types/plugin.types.d.ts +239 -0
  123. package/dist/source/types/plugin.types.js +1 -0
  124. package/dist/source/types/theme.types.d.ts +18 -0
  125. package/dist/source/types/theme.types.js +1 -0
  126. package/dist/source/types/youtube-music.types.d.ts +35 -0
  127. package/dist/source/types/youtube-music.types.js +1 -0
  128. package/dist/source/types/youtubei.types.d.ts +60 -0
  129. package/dist/source/types/youtubei.types.js +3 -0
  130. package/dist/source/utils/constants.d.ts +65 -0
  131. package/dist/source/utils/constants.js +82 -0
  132. package/dist/source/utils/format.d.ts +3 -0
  133. package/dist/source/utils/format.js +24 -0
  134. package/dist/test.d.ts +1 -0
  135. package/dist/test.js +13 -0
  136. package/package.json +100 -0
@@ -0,0 +1,206 @@
1
+ import { CONFIG_DIR } from "../../utils/constants.js";
2
+ import { logger } from "../logger/logger.service.js";
3
+ import { join } from 'node:path';
4
+ import { existsSync, mkdirSync, rmSync, cpSync, readdirSync } from 'node:fs';
5
+ import { execSync } from 'node:child_process';
6
+ const PLUGINS_DIR = join(CONFIG_DIR, 'plugins');
7
+ /**
8
+ * Plugin updater service
9
+ */
10
+ class PluginUpdaterService {
11
+ /**
12
+ * Check if updates are available for a plugin
13
+ */
14
+ async checkForUpdates(pluginId) {
15
+ const pluginDir = join(PLUGINS_DIR, pluginId);
16
+ if (!existsSync(pluginDir)) {
17
+ return { hasUpdate: false };
18
+ }
19
+ // Check if plugin is a git repository
20
+ const gitDir = join(pluginDir, '.git');
21
+ if (!existsSync(gitDir)) {
22
+ logger.warn('PluginUpdaterService', `Plugin ${pluginId} is not a git repository, cannot check for updates`);
23
+ return { hasUpdate: false };
24
+ }
25
+ try {
26
+ // Fetch latest from remote
27
+ execSync('git fetch origin', {
28
+ cwd: pluginDir,
29
+ stdio: 'pipe',
30
+ windowsHide: true,
31
+ });
32
+ // Check if local is behind remote
33
+ const status = execSync('git status -uno', {
34
+ cwd: pluginDir,
35
+ stdio: 'pipe',
36
+ windowsHide: true,
37
+ }).toString();
38
+ const hasUpdate = status.includes('Your branch is behind');
39
+ // Get current version from manifest
40
+ const manifestPath = join(pluginDir, 'plugin.json');
41
+ let currentVersion = 'unknown';
42
+ if (existsSync(manifestPath)) {
43
+ const { readFileSync: fsReadFileSync } = await import('node:fs');
44
+ const manifest = fsReadFileSync(manifestPath, 'utf-8');
45
+ const parsedManifest = typeof manifest === 'string' ? JSON.parse(manifest) : manifest;
46
+ currentVersion = parsedManifest.version;
47
+ }
48
+ return {
49
+ hasUpdate,
50
+ currentVersion,
51
+ latestVersion: hasUpdate ? 'available' : currentVersion,
52
+ };
53
+ }
54
+ catch (error) {
55
+ logger.error('PluginUpdaterService', `Failed to check updates for ${pluginId}:`, error);
56
+ return { hasUpdate: false };
57
+ }
58
+ }
59
+ /**
60
+ * Update a plugin with smart merge (preserve user data)
61
+ */
62
+ async updatePlugin(pluginId) {
63
+ const pluginDir = join(PLUGINS_DIR, pluginId);
64
+ if (!existsSync(pluginDir)) {
65
+ return {
66
+ success: false,
67
+ error: `Plugin ${pluginId} is not installed`,
68
+ };
69
+ }
70
+ try {
71
+ // Get current version
72
+ const manifestPath = join(pluginDir, 'plugin.json');
73
+ let oldVersion = 'unknown';
74
+ if (existsSync(manifestPath)) {
75
+ const { readFileSync: fsReadFileSync } = await import('node:fs');
76
+ const manifest = fsReadFileSync(manifestPath, 'utf-8');
77
+ const parsedManifest = typeof manifest === 'string' ? JSON.parse(manifest) : manifest;
78
+ oldVersion = parsedManifest.version;
79
+ }
80
+ // Backup current version
81
+ const backupDir = join(pluginDir, '.backup');
82
+ if (existsSync(backupDir)) {
83
+ rmSync(backupDir, { recursive: true, force: true });
84
+ }
85
+ mkdirSync(backupDir, { recursive: true });
86
+ // Backup everything except data/ and config.json
87
+ const entries = readdirSync(pluginDir, { withFileTypes: true });
88
+ for (const entry of entries) {
89
+ if (entry.name === 'data' ||
90
+ entry.name === 'config.json' ||
91
+ entry.name === '.backup') {
92
+ continue;
93
+ }
94
+ const sourcePath = join(pluginDir, entry.name);
95
+ const targetPath = join(backupDir, entry.name);
96
+ if (entry.isDirectory()) {
97
+ cpSync(sourcePath, targetPath, { recursive: true });
98
+ }
99
+ else {
100
+ cpSync(sourcePath, targetPath);
101
+ }
102
+ }
103
+ // Check if it's a git repository
104
+ const gitDir = join(pluginDir, '.git');
105
+ if (existsSync(gitDir)) {
106
+ // Git pull
107
+ try {
108
+ execSync('git pull origin main', {
109
+ cwd: pluginDir,
110
+ stdio: 'pipe',
111
+ windowsHide: true,
112
+ });
113
+ }
114
+ catch {
115
+ // Try master branch
116
+ execSync('git pull origin master', {
117
+ cwd: pluginDir,
118
+ stdio: 'pipe',
119
+ windowsHide: true,
120
+ });
121
+ }
122
+ }
123
+ else {
124
+ logger.warn('PluginUpdaterService', `Plugin ${pluginId} is not a git repository, cannot update`);
125
+ return {
126
+ success: false,
127
+ error: 'Plugin is not a git repository',
128
+ };
129
+ }
130
+ // Get new version
131
+ let newVersion = 'unknown';
132
+ if (existsSync(manifestPath)) {
133
+ const { readFileSync: fsReadFileSync } = await import('node:fs');
134
+ const manifest = fsReadFileSync(manifestPath, 'utf-8');
135
+ const parsedManifest = typeof manifest === 'string' ? JSON.parse(manifest) : manifest;
136
+ newVersion = parsedManifest.version;
137
+ }
138
+ // Install/update dependencies
139
+ const packageJsonPath = join(pluginDir, 'package.json');
140
+ if (existsSync(packageJsonPath)) {
141
+ try {
142
+ execSync('bun install', {
143
+ cwd: pluginDir,
144
+ stdio: 'pipe',
145
+ windowsHide: true,
146
+ });
147
+ }
148
+ catch (error) {
149
+ logger.warn('PluginUpdaterService', 'Failed to install dependencies:', error);
150
+ }
151
+ }
152
+ logger.info('PluginUpdaterService', `Successfully updated ${pluginId} from ${oldVersion} to ${newVersion}`);
153
+ return {
154
+ success: true,
155
+ pluginId,
156
+ oldVersion,
157
+ newVersion,
158
+ message: `Updated from ${oldVersion} to ${newVersion}`,
159
+ };
160
+ }
161
+ catch (error) {
162
+ logger.error('PluginUpdaterService', `Failed to update ${pluginId}:`, error);
163
+ // Try to restore from backup
164
+ const backupDir = join(pluginDir, '.backup');
165
+ if (existsSync(backupDir)) {
166
+ logger.info('PluginUpdaterService', 'Restoring from backup...');
167
+ try {
168
+ const entries = readdirSync(backupDir, { withFileTypes: true });
169
+ for (const entry of entries) {
170
+ const sourcePath = join(backupDir, entry.name);
171
+ const targetPath = join(pluginDir, entry.name);
172
+ // Remove target first
173
+ if (existsSync(targetPath)) {
174
+ rmSync(targetPath, { recursive: true, force: true });
175
+ }
176
+ if (entry.isDirectory()) {
177
+ cpSync(sourcePath, targetPath, { recursive: true });
178
+ }
179
+ else {
180
+ cpSync(sourcePath, targetPath);
181
+ }
182
+ }
183
+ logger.info('PluginUpdaterService', 'Restored from backup');
184
+ }
185
+ catch (restoreError) {
186
+ logger.error('PluginUpdaterService', 'Failed to restore from backup:', restoreError);
187
+ }
188
+ }
189
+ return {
190
+ success: false,
191
+ error: `Update failed: ${error instanceof Error ? error.message : String(error)}`,
192
+ };
193
+ }
194
+ }
195
+ }
196
+ // Singleton instance
197
+ let instance = null;
198
+ /**
199
+ * Get the plugin updater service singleton
200
+ */
201
+ export function getPluginUpdaterService() {
202
+ if (!instance) {
203
+ instance = new PluginUpdaterService();
204
+ }
205
+ return instance;
206
+ }
@@ -0,0 +1,13 @@
1
+ import type { Track, Album, Artist, Playlist, SearchOptions, SearchResponse } from '../../types/youtube-music.types.ts';
2
+ declare class MusicService {
3
+ search(query: string, options?: SearchOptions): Promise<SearchResponse>;
4
+ getTrack(videoId: string): Promise<Track | null>;
5
+ getAlbum(albumId: string): Promise<Album>;
6
+ getArtist(artistId: string): Promise<Artist>;
7
+ getPlaylist(playlistId: string): Promise<Playlist>;
8
+ getSuggestions(trackId: string): Promise<Track[]>;
9
+ getStreamUrl(videoId: string): Promise<string>;
10
+ private getInvidiousStreamUrl;
11
+ }
12
+ export declare function getMusicService(): MusicService;
13
+ export {};
@@ -0,0 +1,371 @@
1
+ import { Innertube } from 'youtubei.js';
2
+ import { logger } from "../logger/logger.service.js";
3
+ // Initialize YouTube client
4
+ let ytClient = null;
5
+ async function getClient() {
6
+ if (!ytClient) {
7
+ ytClient = await Innertube.create();
8
+ }
9
+ return ytClient;
10
+ }
11
+ class MusicService {
12
+ async search(query, options = {}) {
13
+ const results = [];
14
+ const searchType = options.type || 'all';
15
+ try {
16
+ const yt = await getClient();
17
+ const search = (await yt.search(query));
18
+ // Process search results based on type
19
+ if (searchType === 'all' || searchType === 'songs') {
20
+ const videos = search.videos;
21
+ if (videos) {
22
+ for (const video of videos) {
23
+ if (video.type === 'Video' || video.id) {
24
+ results.push({
25
+ type: 'song',
26
+ data: {
27
+ videoId: video.id || video.video_id || '',
28
+ title: (typeof video.title === 'string'
29
+ ? video.title
30
+ : video.title?.text) || 'Unknown',
31
+ artists: [
32
+ {
33
+ artistId: video.channel_id || video.channel?.id || '',
34
+ name: (typeof video.author === 'string'
35
+ ? video.author
36
+ : video.author?.name) || 'Unknown',
37
+ },
38
+ ],
39
+ duration: (typeof video.duration === 'number'
40
+ ? video.duration
41
+ : video.duration?.seconds) || 0,
42
+ },
43
+ });
44
+ }
45
+ }
46
+ }
47
+ }
48
+ if (searchType === 'all' || searchType === 'playlists') {
49
+ const playlists = search.playlists;
50
+ if (playlists) {
51
+ for (const playlist of playlists) {
52
+ results.push({
53
+ type: 'playlist',
54
+ data: {
55
+ playlistId: playlist.id || '',
56
+ name: (typeof playlist.title === 'string'
57
+ ? playlist.title
58
+ : playlist.title?.text) || 'Unknown Playlist',
59
+ tracks: [],
60
+ },
61
+ });
62
+ }
63
+ }
64
+ }
65
+ if (searchType === 'all' || searchType === 'artists') {
66
+ const channels = search.channels;
67
+ if (channels) {
68
+ for (const channel of channels) {
69
+ results.push({
70
+ type: 'artist',
71
+ data: {
72
+ artistId: channel.id || channel.channelId || '',
73
+ name: (typeof channel.author === 'string'
74
+ ? channel.author
75
+ : channel.author?.name) || 'Unknown Artist',
76
+ },
77
+ });
78
+ }
79
+ }
80
+ }
81
+ }
82
+ catch (error) {
83
+ console.error('Search failed:', error);
84
+ }
85
+ return {
86
+ results,
87
+ hasMore: false,
88
+ };
89
+ }
90
+ async getTrack(videoId) {
91
+ return {
92
+ videoId,
93
+ title: 'Unknown Track',
94
+ artists: [],
95
+ };
96
+ }
97
+ async getAlbum(albumId) {
98
+ return {
99
+ albumId,
100
+ name: 'Unknown Album',
101
+ artists: [],
102
+ tracks: [],
103
+ };
104
+ }
105
+ async getArtist(artistId) {
106
+ return {
107
+ artistId,
108
+ name: 'Unknown Artist',
109
+ };
110
+ }
111
+ async getPlaylist(playlistId) {
112
+ return {
113
+ playlistId,
114
+ name: 'Unknown Playlist',
115
+ tracks: [],
116
+ };
117
+ }
118
+ async getSuggestions(trackId) {
119
+ try {
120
+ const yt = await getClient();
121
+ const video = (await yt.getInfo(trackId));
122
+ const suggestions = video.related?.contents || [];
123
+ return suggestions.slice(0, 10).map((item) => ({
124
+ videoId: item.id || '',
125
+ title: typeof item.title === 'string'
126
+ ? item.title
127
+ : item.title?.text || 'Unknown',
128
+ artists: [],
129
+ }));
130
+ }
131
+ catch (error) {
132
+ console.error('Failed to get suggestions:', error);
133
+ return [];
134
+ }
135
+ }
136
+ async getStreamUrl(videoId) {
137
+ logger.info('MusicService', 'Starting stream extraction', { videoId });
138
+ // Try Method 1: @distube/ytdl-core (most reliable, actively maintained)
139
+ try {
140
+ logger.debug('MusicService', 'Attempting ytdl-core extraction', {
141
+ videoId,
142
+ });
143
+ const ytdl = await import('@distube/ytdl-core');
144
+ const videoUrl = `https://www.youtube.com/watch?v=${videoId}`;
145
+ const info = await ytdl.default.getInfo(videoUrl);
146
+ logger.debug('MusicService', 'ytdl-core getInfo succeeded', {
147
+ formatCount: info.formats.length,
148
+ });
149
+ const audioFormats = ytdl.default.filterFormats(info.formats, 'audioonly');
150
+ logger.debug('MusicService', 'ytdl-core audio formats filtered', {
151
+ audioFormatCount: audioFormats.length,
152
+ });
153
+ if (audioFormats.length > 0) {
154
+ // Get highest quality audio
155
+ const bestAudio = audioFormats.sort((a, b) => {
156
+ const aBitrate = Number.parseInt(String(a.audioBitrate || 0));
157
+ const bBitrate = Number.parseInt(String(b.audioBitrate || 0));
158
+ return bBitrate - aBitrate;
159
+ })[0];
160
+ if (bestAudio?.url) {
161
+ logger.info('MusicService', 'Using ytdl-core stream', {
162
+ bitrate: bestAudio.audioBitrate,
163
+ urlLength: bestAudio.url.length,
164
+ mimeType: bestAudio.mimeType,
165
+ });
166
+ return bestAudio.url;
167
+ }
168
+ }
169
+ logger.warn('MusicService', 'ytdl-core: No audio formats with URL found');
170
+ }
171
+ catch (error) {
172
+ logger.error('MusicService', 'ytdl-core extraction failed', {
173
+ error: error instanceof Error ? error.message : String(error),
174
+ stack: error instanceof Error ? error.stack : undefined,
175
+ });
176
+ }
177
+ // Try Method 2: youtubei.js (may fail with ParsingError)
178
+ try {
179
+ logger.debug('MusicService', 'Attempting youtubei.js extraction', {
180
+ videoId,
181
+ });
182
+ const yt = await getClient();
183
+ const video = (await yt.getInfo(videoId));
184
+ logger.debug('MusicService', 'youtubei.js getInfo succeeded');
185
+ // Get the download URL for the video
186
+ const streamData = video.chooseFormat?.({
187
+ type: 'audio',
188
+ quality: 'best',
189
+ });
190
+ if (streamData?.url) {
191
+ logger.info('MusicService', 'Using youtubei.js stream (chooseFormat)', {
192
+ urlLength: streamData.url.length,
193
+ });
194
+ return streamData.url;
195
+ }
196
+ // Fallback: Manually select from streaming_data.adaptive_formats
197
+ logger.debug('MusicService', 'chooseFormat returned nothing, trying manual selection');
198
+ const streamingData = video.streaming_data;
199
+ if (streamingData?.adaptive_formats) {
200
+ logger.debug('MusicService', 'Found adaptive_formats', {
201
+ count: streamingData.adaptive_formats.length,
202
+ });
203
+ // Filter for audio-only formats
204
+ const audioFormats = streamingData.adaptive_formats.filter((f) => f.mime_type?.includes('audio') || f.type?.includes('audio'));
205
+ logger.debug('MusicService', 'Audio formats found', {
206
+ count: audioFormats.length,
207
+ });
208
+ if (audioFormats.length > 0) {
209
+ // Sort by bitrate (higher is better)
210
+ const sorted = audioFormats.sort((a, b) => {
211
+ const aBitrate = Number.parseInt(String(a.bitrate || 0));
212
+ const bBitrate = Number.parseInt(String(b.bitrate || 0));
213
+ return bBitrate - aBitrate;
214
+ });
215
+ const bestAudio = sorted[0];
216
+ if (bestAudio) {
217
+ // Check for direct URL
218
+ if (bestAudio.url) {
219
+ logger.info('MusicService', 'Using youtubei.js stream (manual)', {
220
+ bitrate: bestAudio.bitrate,
221
+ mimeType: bestAudio.mime_type || bestAudio.type,
222
+ urlLength: bestAudio.url.length,
223
+ });
224
+ return bestAudio.url;
225
+ }
226
+ // Check for signatureCipher (needs decoding)
227
+ if (bestAudio.signature_cipher || bestAudio.signatureCipher) {
228
+ logger.warn('MusicService', 'Format has signature cipher (not supported)', {
229
+ hasCipher: true,
230
+ });
231
+ }
232
+ }
233
+ }
234
+ }
235
+ logger.warn('MusicService', 'youtubei.js: No usable stream URL found');
236
+ }
237
+ catch (error) {
238
+ if (error instanceof Error && error.message.includes('ParsingError')) {
239
+ logger.warn('MusicService', 'youtubei.js parsing error (expected)', {
240
+ error: error.message,
241
+ });
242
+ }
243
+ else {
244
+ logger.error('MusicService', 'youtubei.js extraction failed', {
245
+ error: error instanceof Error ? error.message : String(error),
246
+ stack: error instanceof Error ? error.stack : undefined,
247
+ });
248
+ }
249
+ }
250
+ // Try Method 3: youtube-ext (lightweight, no parsing needed)
251
+ try {
252
+ logger.debug('MusicService', 'Attempting youtube-ext extraction', {
253
+ videoId,
254
+ });
255
+ const { videoInfo, getFormats } = await import('youtube-ext');
256
+ const videoUrl = `https://www.youtube.com/watch?v=${videoId}`;
257
+ const info = await videoInfo(videoUrl);
258
+ logger.debug('MusicService', 'youtube-ext videoInfo succeeded');
259
+ // Decode stream URLs first
260
+ const decodedFormats = await getFormats(info.stream);
261
+ logger.debug('MusicService', 'youtube-ext formats decoded', {
262
+ formatCount: decodedFormats.length,
263
+ });
264
+ // Get best audio format from decoded adaptive formats
265
+ const audioFormats = decodedFormats.filter(f => f.mimeType?.includes('audio') && f.url);
266
+ logger.debug('MusicService', 'youtube-ext audio formats filtered', {
267
+ audioFormatCount: audioFormats.length,
268
+ });
269
+ if (audioFormats.length > 0) {
270
+ // Sort by bitrate descending and get best quality
271
+ const bestAudio = audioFormats.sort((a, b) => (b.bitrate || 0) - (a.bitrate || 0))[0];
272
+ if (bestAudio?.url) {
273
+ logger.info('MusicService', 'Using youtube-ext stream', {
274
+ bitrate: bestAudio.bitrate,
275
+ urlLength: bestAudio.url.length,
276
+ });
277
+ return bestAudio.url;
278
+ }
279
+ }
280
+ logger.warn('MusicService', 'youtube-ext: No audio formats with URL found');
281
+ }
282
+ catch (error) {
283
+ logger.error('MusicService', 'youtube-ext extraction failed', {
284
+ error: error instanceof Error ? error.message : String(error),
285
+ stack: error instanceof Error ? error.stack : undefined,
286
+ });
287
+ }
288
+ // Try Method 4: Invidious API (last resort)
289
+ try {
290
+ logger.debug('MusicService', 'Attempting Invidious extraction', {
291
+ videoId,
292
+ });
293
+ const url = await this.getInvidiousStreamUrl(videoId);
294
+ logger.info('MusicService', 'Using Invidious stream', {
295
+ urlLength: url.length,
296
+ });
297
+ return url;
298
+ }
299
+ catch (error) {
300
+ logger.error('MusicService', 'Invidious extraction failed', {
301
+ error: error instanceof Error ? error.message : String(error),
302
+ stack: error instanceof Error ? error.stack : undefined,
303
+ });
304
+ }
305
+ // All methods failed
306
+ logger.error('MusicService', 'All stream extraction methods failed', {
307
+ videoId,
308
+ });
309
+ throw new Error('All stream extraction methods failed');
310
+ }
311
+ async getInvidiousStreamUrl(videoId) {
312
+ // Try multiple Invidious instances as fallback
313
+ const instances = [
314
+ 'https://vid.puffyan.us',
315
+ 'https://invidious.perennialte.ch',
316
+ 'https://yewtu.be',
317
+ ];
318
+ for (const instance of instances) {
319
+ try {
320
+ logger.debug('MusicService', 'Trying Invidious instance', { instance });
321
+ const response = await fetch(`${instance}/api/v1/videos/${videoId}`);
322
+ if (!response.ok) {
323
+ logger.debug('MusicService', 'Invidious instance returned non-OK', {
324
+ instance,
325
+ status: response.status,
326
+ });
327
+ continue;
328
+ }
329
+ const videoData = (await response.json());
330
+ // Look for audio-only streams
331
+ const audioFormats = [
332
+ ...(videoData.adaptiveFormats || []),
333
+ ...(videoData.formatStreams || []),
334
+ ].filter(f => f.type?.toLowerCase().includes('audio'));
335
+ logger.debug('MusicService', 'Invidious audio formats found', {
336
+ instance,
337
+ count: audioFormats.length,
338
+ });
339
+ if (audioFormats.length > 0) {
340
+ const firstAudio = audioFormats[0];
341
+ if (firstAudio?.url) {
342
+ logger.debug('MusicService', 'Invidious stream URL obtained', {
343
+ instance,
344
+ urlLength: firstAudio.url.length,
345
+ type: firstAudio.type,
346
+ });
347
+ return firstAudio.url;
348
+ }
349
+ }
350
+ }
351
+ catch (error) {
352
+ logger.debug('MusicService', 'Invidious instance error', {
353
+ instance,
354
+ error: error instanceof Error ? error.message : String(error),
355
+ });
356
+ // Try next instance
357
+ continue;
358
+ }
359
+ }
360
+ // If all Invidious instances fail, throw error instead of returning watch URL
361
+ throw new Error('No Invidious instance returned a valid stream URL');
362
+ }
363
+ }
364
+ // Singleton instance
365
+ let musicServiceInstance = null;
366
+ export function getMusicService() {
367
+ if (!musicServiceInstance) {
368
+ musicServiceInstance = new MusicService();
369
+ }
370
+ return musicServiceInstance;
371
+ }
@@ -0,0 +1,11 @@
1
+ import type { SearchOptions, Track, Album, Artist, Playlist } from '../../types/youtube-music.types.ts';
2
+ declare class SearchService {
3
+ private musicService;
4
+ search(query: string, options?: SearchOptions): Promise<import('../../types/youtube-music.types.ts').SearchResponse>;
5
+ searchSongs(query: string, limit?: number): Promise<Track[]>;
6
+ searchAlbums(query: string, limit?: number): Promise<Album[]>;
7
+ searchArtists(query: string, limit?: number): Promise<Artist[]>;
8
+ searchPlaylists(query: string, limit?: number): Promise<Playlist[]>;
9
+ }
10
+ export declare function getSearchService(): SearchService;
11
+ export {};
@@ -0,0 +1,38 @@
1
+ import { getMusicService } from "./api.js";
2
+ class SearchService {
3
+ musicService = getMusicService();
4
+ async search(query, options = {}) {
5
+ return this.musicService.search(query, options);
6
+ }
7
+ async searchSongs(query, limit = 20) {
8
+ const response = await this.search(query, { type: 'songs', limit });
9
+ return response.results
10
+ .filter(r => r.type === 'song')
11
+ .map(r => r.data);
12
+ }
13
+ async searchAlbums(query, limit = 10) {
14
+ const response = await this.search(query, { type: 'albums', limit });
15
+ return response.results
16
+ .filter(r => r.type === 'album')
17
+ .map(r => r.data);
18
+ }
19
+ async searchArtists(query, limit = 10) {
20
+ const response = await this.search(query, { type: 'artists', limit });
21
+ return response.results
22
+ .filter(r => r.type === 'artist')
23
+ .map(r => r.data);
24
+ }
25
+ async searchPlaylists(query, limit = 10) {
26
+ const response = await this.search(query, { type: 'playlists', limit });
27
+ return response.results
28
+ .filter(r => r.type === 'playlist')
29
+ .map(r => r.data);
30
+ }
31
+ }
32
+ let searchServiceInstance = null;
33
+ export function getSearchService() {
34
+ if (!searchServiceInstance) {
35
+ searchServiceInstance = new SearchService();
36
+ }
37
+ return searchServiceInstance;
38
+ }
@@ -0,0 +1,10 @@
1
+ import type { NavigationState, NavigationAction } from '../types/navigation.types.ts';
2
+ import { type ReactNode } from 'react';
3
+ export type NavigationContextValue = {
4
+ state: NavigationState;
5
+ dispatch: (action: NavigationAction) => void;
6
+ };
7
+ export declare function NavigationProvider({ children }: {
8
+ children: ReactNode;
9
+ }): import("react/jsx-runtime").JSX.Element;
10
+ export declare function useNavigation(): NavigationContextValue;