@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.
- package/README.md +352 -0
- package/dist/eslint.config.d.ts +2 -0
- package/dist/eslint.config.js +55 -0
- package/dist/source/app.d.ts +4 -0
- package/dist/source/app.js +17 -0
- package/dist/source/cli.d.ts +2 -0
- package/dist/source/cli.js +241 -0
- package/dist/source/components/common/ErrorBoundary.d.ts +15 -0
- package/dist/source/components/common/ErrorBoundary.js +22 -0
- package/dist/source/components/common/Help.d.ts +1 -0
- package/dist/source/components/common/Help.js +10 -0
- package/dist/source/components/common/ShortcutsBar.d.ts +1 -0
- package/dist/source/components/common/ShortcutsBar.js +33 -0
- package/dist/source/components/config/ConfigLayout.d.ts +1 -0
- package/dist/source/components/config/ConfigLayout.js +84 -0
- package/dist/source/components/layouts/MainLayout.d.ts +4 -0
- package/dist/source/components/layouts/MainLayout.js +83 -0
- package/dist/source/components/layouts/PlayerLayout.d.ts +1 -0
- package/dist/source/components/layouts/PlayerLayout.js +10 -0
- package/dist/source/components/layouts/PluginsLayout.d.ts +1 -0
- package/dist/source/components/layouts/PluginsLayout.js +77 -0
- package/dist/source/components/layouts/SearchLayout.d.ts +4 -0
- package/dist/source/components/layouts/SearchLayout.js +81 -0
- package/dist/source/components/player/NowPlaying.d.ts +1 -0
- package/dist/source/components/player/NowPlaying.js +21 -0
- package/dist/source/components/player/PlayerControls.d.ts +1 -0
- package/dist/source/components/player/PlayerControls.js +41 -0
- package/dist/source/components/player/ProgressBar.d.ts +1 -0
- package/dist/source/components/player/ProgressBar.js +18 -0
- package/dist/source/components/player/QueueList.d.ts +4 -0
- package/dist/source/components/player/QueueList.js +30 -0
- package/dist/source/components/player/Suggestions.d.ts +1 -0
- package/dist/source/components/player/Suggestions.js +47 -0
- package/dist/source/components/playlist/PlaylistList.d.ts +1 -0
- package/dist/source/components/playlist/PlaylistList.js +11 -0
- package/dist/source/components/plugins/PluginInstallDialog.d.ts +5 -0
- package/dist/source/components/plugins/PluginInstallDialog.js +41 -0
- package/dist/source/components/plugins/PluginsAvailable.d.ts +5 -0
- package/dist/source/components/plugins/PluginsAvailable.js +55 -0
- package/dist/source/components/plugins/PluginsList.d.ts +8 -0
- package/dist/source/components/plugins/PluginsList.js +18 -0
- package/dist/source/components/search/SearchBar.d.ts +8 -0
- package/dist/source/components/search/SearchBar.js +50 -0
- package/dist/source/components/search/SearchResults.d.ts +10 -0
- package/dist/source/components/search/SearchResults.js +111 -0
- package/dist/source/components/settings/Settings.d.ts +1 -0
- package/dist/source/components/settings/Settings.js +42 -0
- package/dist/source/components/theme/ThemeSwitcher.d.ts +1 -0
- package/dist/source/components/theme/ThemeSwitcher.js +11 -0
- package/dist/source/config/themes.config.d.ts +3 -0
- package/dist/source/config/themes.config.js +63 -0
- package/dist/source/contexts/theme.context.d.ts +13 -0
- package/dist/source/contexts/theme.context.js +29 -0
- package/dist/source/hooks/useKeyboard.d.ts +10 -0
- package/dist/source/hooks/useKeyboard.js +104 -0
- package/dist/source/hooks/useNavigation.d.ts +1 -0
- package/dist/source/hooks/useNavigation.js +5 -0
- package/dist/source/hooks/usePlayer.d.ts +23 -0
- package/dist/source/hooks/usePlayer.js +35 -0
- package/dist/source/hooks/usePlaylist.d.ts +8 -0
- package/dist/source/hooks/usePlaylist.js +50 -0
- package/dist/source/hooks/useSearch.d.ts +8 -0
- package/dist/source/hooks/useSearch.js +76 -0
- package/dist/source/hooks/useTerminalSize.d.ts +4 -0
- package/dist/source/hooks/useTerminalSize.js +24 -0
- package/dist/source/hooks/useTheme.d.ts +6 -0
- package/dist/source/hooks/useTheme.js +5 -0
- package/dist/source/hooks/useYouTubeMusic.d.ts +11 -0
- package/dist/source/hooks/useYouTubeMusic.js +112 -0
- package/dist/source/main.d.ts +4 -0
- package/dist/source/main.js +69 -0
- package/dist/source/services/config/config.service.d.ts +26 -0
- package/dist/source/services/config/config.service.js +125 -0
- package/dist/source/services/logger/logger.service.d.ts +10 -0
- package/dist/source/services/logger/logger.service.js +52 -0
- package/dist/source/services/player/player.service.d.ts +58 -0
- package/dist/source/services/player/player.service.js +349 -0
- package/dist/source/services/player-state/player-state.service.d.ts +24 -0
- package/dist/source/services/player-state/player-state.service.js +122 -0
- package/dist/source/services/plugin/plugin-audio-api.d.ts +17 -0
- package/dist/source/services/plugin/plugin-audio-api.js +36 -0
- package/dist/source/services/plugin/plugin-context.d.ts +5 -0
- package/dist/source/services/plugin/plugin-context.js +256 -0
- package/dist/source/services/plugin/plugin-hooks.service.d.ts +62 -0
- package/dist/source/services/plugin/plugin-hooks.service.js +135 -0
- package/dist/source/services/plugin/plugin-installer.service.d.ts +27 -0
- package/dist/source/services/plugin/plugin-installer.service.js +247 -0
- package/dist/source/services/plugin/plugin-loader.service.d.ts +33 -0
- package/dist/source/services/plugin/plugin-loader.service.js +161 -0
- package/dist/source/services/plugin/plugin-permissions.service.d.ts +72 -0
- package/dist/source/services/plugin/plugin-permissions.service.js +194 -0
- package/dist/source/services/plugin/plugin-registry.service.d.ts +76 -0
- package/dist/source/services/plugin/plugin-registry.service.js +215 -0
- package/dist/source/services/plugin/plugin-ui-api.d.ts +25 -0
- package/dist/source/services/plugin/plugin-ui-api.js +46 -0
- package/dist/source/services/plugin/plugin-updater.service.d.ts +23 -0
- package/dist/source/services/plugin/plugin-updater.service.js +206 -0
- package/dist/source/services/youtube-music/api.d.ts +13 -0
- package/dist/source/services/youtube-music/api.js +371 -0
- package/dist/source/services/youtube-music/search.service.d.ts +11 -0
- package/dist/source/services/youtube-music/search.service.js +38 -0
- package/dist/source/stores/navigation.store.d.ts +10 -0
- package/dist/source/stores/navigation.store.js +67 -0
- package/dist/source/stores/player.store.d.ts +28 -0
- package/dist/source/stores/player.store.js +458 -0
- package/dist/source/stores/plugins.store.d.ts +46 -0
- package/dist/source/stores/plugins.store.js +177 -0
- package/dist/source/types/actions.d.ts +119 -0
- package/dist/source/types/actions.js +1 -0
- package/dist/source/types/cli.types.d.ts +14 -0
- package/dist/source/types/cli.types.js +1 -0
- package/dist/source/types/config.types.d.ts +19 -0
- package/dist/source/types/config.types.js +1 -0
- package/dist/source/types/keyboard.types.d.ts +5 -0
- package/dist/source/types/keyboard.types.js +1 -0
- package/dist/source/types/navigation.types.d.ts +14 -0
- package/dist/source/types/navigation.types.js +1 -0
- package/dist/source/types/player.types.d.ts +16 -0
- package/dist/source/types/player.types.js +1 -0
- package/dist/source/types/playlist.types.d.ts +12 -0
- package/dist/source/types/playlist.types.js +1 -0
- package/dist/source/types/plugin.types.d.ts +239 -0
- package/dist/source/types/plugin.types.js +1 -0
- package/dist/source/types/theme.types.d.ts +18 -0
- package/dist/source/types/theme.types.js +1 -0
- package/dist/source/types/youtube-music.types.d.ts +35 -0
- package/dist/source/types/youtube-music.types.js +1 -0
- package/dist/source/types/youtubei.types.d.ts +60 -0
- package/dist/source/types/youtubei.types.js +3 -0
- package/dist/source/utils/constants.d.ts +65 -0
- package/dist/source/utils/constants.js +82 -0
- package/dist/source/utils/format.d.ts +3 -0
- package/dist/source/utils/format.js +24 -0
- package/dist/test.d.ts +1 -0
- package/dist/test.js +13 -0
- 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;
|