streamify-audio 2.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.
@@ -0,0 +1,166 @@
1
+ const { spawn } = require('child_process');
2
+ const { buildFfmpegArgs } = require('../filters/ffmpeg');
3
+ const { registerStream, unregisterStream } = require('../utils/stream');
4
+ const log = require('../utils/logger');
5
+
6
+ async function search(query, limit, config) {
7
+ const startTime = Date.now();
8
+ log.info('SOUNDCLOUD', `Searching: "${query}" (limit: ${limit})`);
9
+
10
+ return new Promise((resolve, reject) => {
11
+ const args = [
12
+ '-q', '--no-warnings',
13
+ '--flat-playlist',
14
+ '--skip-download',
15
+ '-J',
16
+ `scsearch${limit}:${query}`
17
+ ];
18
+
19
+ const proc = spawn(config.ytdlpPath, args, {
20
+ env: { ...process.env, PATH: '/usr/local/bin:/root/.deno/bin:' + process.env.PATH }
21
+ });
22
+
23
+ let stdout = '';
24
+ let stderr = '';
25
+
26
+ proc.stdout.on('data', (data) => { stdout += data; });
27
+ proc.stderr.on('data', (data) => { stderr += data; });
28
+
29
+ proc.on('close', (code) => {
30
+ if (code !== 0) {
31
+ return reject(new Error(stderr || `yt-dlp exited with code ${code}`));
32
+ }
33
+ try {
34
+ const data = JSON.parse(stdout);
35
+ const tracks = (data.entries || []).map(entry => ({
36
+ id: entry.id,
37
+ title: entry.title,
38
+ duration: entry.duration,
39
+ author: entry.uploader,
40
+ thumbnail: entry.thumbnails?.[0]?.url,
41
+ uri: entry.url || entry.webpage_url,
42
+ streamUrl: `/soundcloud/stream/${entry.id}`,
43
+ source: 'soundcloud'
44
+ }));
45
+ const elapsed = Date.now() - startTime;
46
+ log.info('SOUNDCLOUD', `Found ${tracks.length} results (${elapsed}ms)`);
47
+ resolve({ tracks, source: 'soundcloud', searchTime: elapsed });
48
+ } catch (e) {
49
+ reject(new Error('Failed to parse yt-dlp output'));
50
+ }
51
+ });
52
+ });
53
+ }
54
+
55
+ async function getInfo(trackId, config) {
56
+ return new Promise((resolve, reject) => {
57
+ const args = [
58
+ '-q', '--no-warnings',
59
+ '--skip-download',
60
+ '-J',
61
+ `https://soundcloud.com/track/${trackId}`
62
+ ];
63
+
64
+ const proc = spawn(config.ytdlpPath, args, {
65
+ env: { ...process.env, PATH: '/usr/local/bin:/root/.deno/bin:' + process.env.PATH }
66
+ });
67
+
68
+ let stdout = '';
69
+ let stderr = '';
70
+
71
+ proc.stdout.on('data', (data) => { stdout += data; });
72
+ proc.stderr.on('data', (data) => { stderr += data; });
73
+
74
+ proc.on('close', (code) => {
75
+ if (code !== 0) {
76
+ return reject(new Error(stderr || `yt-dlp exited with code ${code}`));
77
+ }
78
+ try {
79
+ const data = JSON.parse(stdout);
80
+ resolve({
81
+ id: data.id,
82
+ title: data.title,
83
+ duration: data.duration,
84
+ author: data.uploader,
85
+ thumbnail: data.thumbnail,
86
+ uri: data.webpage_url,
87
+ streamUrl: `/soundcloud/stream/${data.id}`,
88
+ source: 'soundcloud'
89
+ });
90
+ } catch (e) {
91
+ reject(new Error('Failed to parse yt-dlp output'));
92
+ }
93
+ });
94
+ });
95
+ }
96
+
97
+ function stream(trackUrl, filters, config, res) {
98
+ const streamId = `sc-${Date.now()}`;
99
+ const streamStartTime = Date.now();
100
+
101
+ const filterStr = Object.entries(filters || {})
102
+ .filter(([k, v]) => v !== undefined && v !== null)
103
+ .map(([k, v]) => `${k}=${v}`)
104
+ .join(', ') || 'none';
105
+
106
+ log.info('SOUNDCLOUD', `Stream: ${trackUrl} | Filters: ${filterStr}`);
107
+
108
+ res.setHeader('Content-Type', 'audio/ogg');
109
+ res.setHeader('Transfer-Encoding', 'chunked');
110
+ res.setHeader('Cache-Control', 'no-cache');
111
+ res.flushHeaders();
112
+
113
+ const url = trackUrl.startsWith('http') ? trackUrl : `https://api.soundcloud.com/tracks/${trackUrl}/stream`;
114
+
115
+ const ytdlp = spawn(config.ytdlpPath, [
116
+ '-f', 'bestaudio/best',
117
+ '-o', '-',
118
+ url
119
+ ], {
120
+ env: { ...process.env, PATH: '/usr/local/bin:/root/.deno/bin:' + process.env.PATH }
121
+ });
122
+
123
+ const ffmpegArgs = buildFfmpegArgs(filters, config);
124
+ const ffmpeg = spawn(config.ffmpegPath, ffmpegArgs);
125
+
126
+ registerStream(streamId, { ytdlp, ffmpeg, source: 'soundcloud', trackId: trackUrl, filters });
127
+
128
+ ytdlp.stdout.pipe(ffmpeg.stdin);
129
+ ffmpeg.stdout.pipe(res);
130
+
131
+ ytdlp.stderr.on('data', (data) => {
132
+ const msg = data.toString();
133
+ if (!msg.includes('[download]') && !msg.includes('ETA')) {
134
+ log.debug('SOUNDCLOUD', msg.trim());
135
+ }
136
+ });
137
+
138
+ ffmpeg.stderr.on('data', (data) => {
139
+ const msg = data.toString();
140
+ if (msg.includes('Error') || msg.includes('error')) {
141
+ log.error('FFMPEG', msg.trim());
142
+ }
143
+ });
144
+
145
+ const cleanup = (code = 0, error = null) => {
146
+ if (ytdlp && !ytdlp.killed) ytdlp.kill('SIGTERM');
147
+ if (ffmpeg && !ffmpeg.killed) ffmpeg.kill('SIGTERM');
148
+ unregisterStream(streamId, code, error);
149
+ };
150
+
151
+ ytdlp.on('error', (err) => cleanup(1, err));
152
+ ffmpeg.on('error', (err) => cleanup(1, err));
153
+ ffmpeg.on('close', (code) => {
154
+ const elapsed = Date.now() - streamStartTime;
155
+ log.info('SOUNDCLOUD', `Stream ended: ${trackUrl} | ${elapsed}ms | Code: ${code}`);
156
+ cleanup(code);
157
+ });
158
+ ytdlp.on('close', () => ffmpeg.stdin.end());
159
+ res.on('close', () => {
160
+ const elapsed = Date.now() - streamStartTime;
161
+ log.info('SOUNDCLOUD', `Client disconnected: ${trackUrl} | ${elapsed}ms`);
162
+ cleanup(0);
163
+ });
164
+ }
165
+
166
+ module.exports = { search, getInfo, stream };
@@ -0,0 +1,216 @@
1
+ const youtube = require('./youtube');
2
+ const log = require('../utils/logger');
3
+
4
+ let accessToken = null;
5
+ let tokenExpiry = 0;
6
+
7
+ const youtubeIdCache = new Map();
8
+ const CACHE_TTL = 300000;
9
+
10
+ async function getAccessToken(config) {
11
+ if (accessToken && Date.now() < tokenExpiry) {
12
+ return accessToken;
13
+ }
14
+
15
+ const { clientId, clientSecret } = config.spotify;
16
+ if (!clientId || !clientSecret) {
17
+ throw new Error('Spotify credentials not configured');
18
+ }
19
+
20
+ const response = await fetch('https://accounts.spotify.com/api/token', {
21
+ method: 'POST',
22
+ headers: {
23
+ 'Content-Type': 'application/x-www-form-urlencoded',
24
+ 'Authorization': 'Basic ' + Buffer.from(`${clientId}:${clientSecret}`).toString('base64')
25
+ },
26
+ body: 'grant_type=client_credentials'
27
+ });
28
+
29
+ if (!response.ok) {
30
+ throw new Error('Failed to get Spotify access token');
31
+ }
32
+
33
+ const data = await response.json();
34
+ accessToken = data.access_token;
35
+ tokenExpiry = Date.now() + (data.expires_in * 1000) - 60000;
36
+
37
+ return accessToken;
38
+ }
39
+
40
+ async function spotifyApi(endpoint, config) {
41
+ const token = await getAccessToken(config);
42
+ const response = await fetch(`https://api.spotify.com/v1${endpoint}`, {
43
+ headers: {
44
+ 'Authorization': `Bearer ${token}`
45
+ }
46
+ });
47
+
48
+ if (!response.ok) {
49
+ throw new Error(`Spotify API error: ${response.status}`);
50
+ }
51
+
52
+ return response.json();
53
+ }
54
+
55
+ async function search(query, limit, config) {
56
+ const startTime = Date.now();
57
+ log.info('SPOTIFY', `Searching: "${query}" (limit: ${limit})`);
58
+
59
+ const data = await spotifyApi(`/search?q=${encodeURIComponent(query)}&type=track&limit=${limit}`, config);
60
+
61
+ const tracks = (data.tracks?.items || []).map(track => ({
62
+ id: track.id,
63
+ title: track.name,
64
+ author: track.artists.map(a => a.name).join(', '),
65
+ album: track.album.name,
66
+ duration: Math.floor(track.duration_ms / 1000),
67
+ thumbnail: track.album.images?.[0]?.url,
68
+ uri: track.external_urls.spotify,
69
+ streamUrl: `/spotify/stream/${track.id}`,
70
+ source: 'spotify'
71
+ }));
72
+
73
+ const elapsed = Date.now() - startTime;
74
+ log.info('SPOTIFY', `Found ${tracks.length} results (${elapsed}ms)`);
75
+ return { tracks, source: 'spotify', searchTime: elapsed };
76
+ }
77
+
78
+ async function getInfo(trackId, config) {
79
+ log.info('SPOTIFY', `Getting info: ${trackId}`);
80
+ const track = await spotifyApi(`/tracks/${trackId}`, config);
81
+
82
+ return {
83
+ id: track.id,
84
+ title: track.name,
85
+ author: track.artists.map(a => a.name).join(', '),
86
+ album: track.album.name,
87
+ duration: Math.floor(track.duration_ms / 1000),
88
+ thumbnail: track.album.images?.[0]?.url,
89
+ uri: track.external_urls.spotify,
90
+ streamUrl: `/spotify/stream/${track.id}`,
91
+ source: 'spotify'
92
+ };
93
+ }
94
+
95
+ async function resolveToYouTube(trackId, config) {
96
+ const cached = youtubeIdCache.get(trackId);
97
+ if (cached && Date.now() < cached.expires) {
98
+ log.info('SPOTIFY', `Using cached YouTube ID: ${cached.videoId}`);
99
+ return cached.videoId;
100
+ }
101
+
102
+ const track = await getInfo(trackId, config);
103
+ const searchQuery = `${track.author} - ${track.title}`;
104
+ log.info('SPOTIFY', `Searching YouTube: "${searchQuery}"`);
105
+
106
+ const ytResults = await youtube.search(searchQuery, 1, config);
107
+
108
+ if (!ytResults.tracks?.length) {
109
+ throw new Error('Could not find matching YouTube video');
110
+ }
111
+
112
+ const videoId = ytResults.tracks[0].id;
113
+ youtubeIdCache.set(trackId, { videoId, expires: Date.now() + CACHE_TTL });
114
+ log.info('SPOTIFY', `Resolved to YouTube: ${videoId}`);
115
+
116
+ return videoId;
117
+ }
118
+
119
+ async function stream(trackId, filters, config, res) {
120
+ const startTime = Date.now();
121
+ log.info('SPOTIFY', `Stream: ${trackId}`);
122
+
123
+ try {
124
+ const videoId = await resolveToYouTube(trackId, config);
125
+ const elapsed = Date.now() - startTime;
126
+ log.info('SPOTIFY', `Resolution took ${elapsed}ms`);
127
+ return youtube.stream(videoId, filters, config, res);
128
+ } catch (error) {
129
+ log.error('SPOTIFY', error.message);
130
+ res.status(404).json({ error: error.message });
131
+ }
132
+ }
133
+
134
+ async function getPlaylist(playlistId, config) {
135
+ log.info('SPOTIFY', `Getting playlist: ${playlistId}`);
136
+
137
+ const data = await spotifyApi(`/playlists/${playlistId}`, config);
138
+
139
+ const tracks = (data.tracks?.items || [])
140
+ .filter(item => item.track)
141
+ .map(item => ({
142
+ id: item.track.id,
143
+ title: item.track.name,
144
+ author: item.track.artists.map(a => a.name).join(', '),
145
+ album: item.track.album.name,
146
+ duration: Math.floor(item.track.duration_ms / 1000),
147
+ thumbnail: item.track.album.images?.[0]?.url,
148
+ uri: item.track.external_urls.spotify,
149
+ streamUrl: `/spotify/stream/${item.track.id}`,
150
+ source: 'spotify'
151
+ }));
152
+
153
+ log.info('SPOTIFY', `Playlist loaded: ${data.name} (${tracks.length} tracks)`);
154
+
155
+ return {
156
+ id: playlistId,
157
+ title: data.name,
158
+ author: data.owner?.display_name,
159
+ thumbnail: data.images?.[0]?.url,
160
+ tracks,
161
+ source: 'spotify'
162
+ };
163
+ }
164
+
165
+ async function getAlbum(albumId, config) {
166
+ log.info('SPOTIFY', `Getting album: ${albumId}`);
167
+
168
+ const data = await spotifyApi(`/albums/${albumId}`, config);
169
+
170
+ const tracks = (data.tracks?.items || []).map(track => ({
171
+ id: track.id,
172
+ title: track.name,
173
+ author: track.artists.map(a => a.name).join(', '),
174
+ album: data.name,
175
+ duration: Math.floor(track.duration_ms / 1000),
176
+ thumbnail: data.images?.[0]?.url,
177
+ uri: track.external_urls.spotify,
178
+ streamUrl: `/spotify/stream/${track.id}`,
179
+ source: 'spotify'
180
+ }));
181
+
182
+ log.info('SPOTIFY', `Album loaded: ${data.name} (${tracks.length} tracks)`);
183
+
184
+ return {
185
+ id: albumId,
186
+ title: data.name,
187
+ author: data.artists.map(a => a.name).join(', '),
188
+ thumbnail: data.images?.[0]?.url,
189
+ tracks,
190
+ source: 'spotify'
191
+ };
192
+ }
193
+
194
+ async function getRecommendations(trackId, limit, config) {
195
+ log.info('SPOTIFY', `Getting recommendations for: ${trackId} (limit: ${limit})`);
196
+
197
+ const data = await spotifyApi(`/recommendations?seed_tracks=${trackId}&limit=${limit}`, config);
198
+
199
+ const tracks = (data.tracks || []).map(track => ({
200
+ id: track.id,
201
+ title: track.name,
202
+ author: track.artists.map(a => a.name).join(', '),
203
+ album: track.album.name,
204
+ duration: Math.floor(track.duration_ms / 1000),
205
+ thumbnail: track.album.images?.[0]?.url,
206
+ uri: track.external_urls.spotify,
207
+ streamUrl: `/spotify/stream/${track.id}`,
208
+ source: 'spotify',
209
+ isAutoplay: true
210
+ }));
211
+
212
+ log.info('SPOTIFY', `Found ${tracks.length} recommendations`);
213
+ return { tracks, source: 'spotify' };
214
+ }
215
+
216
+ module.exports = { search, getInfo, stream, resolveToYouTube, getPlaylist, getAlbum, getRecommendations };
@@ -0,0 +1,320 @@
1
+ const { spawn } = require('child_process');
2
+ const { buildFfmpegArgs } = require('../filters/ffmpeg');
3
+ const { registerStream, unregisterStream } = require('../utils/stream');
4
+ const log = require('../utils/logger');
5
+
6
+ async function search(query, limit, config) {
7
+ const startTime = Date.now();
8
+ log.info('YOUTUBE', `Searching: "${query}" (limit: ${limit})`);
9
+
10
+ return new Promise((resolve, reject) => {
11
+ const args = [
12
+ '-q', '--no-warnings',
13
+ '--flat-playlist',
14
+ '--skip-download',
15
+ '-J',
16
+ `ytsearch${limit}:${query}`
17
+ ];
18
+
19
+ if (config.cookiesPath) {
20
+ args.unshift('--cookies', config.cookiesPath);
21
+ }
22
+
23
+ const proc = spawn(config.ytdlpPath, args, {
24
+ env: { ...process.env, PATH: '/usr/local/bin:/root/.deno/bin:' + process.env.PATH }
25
+ });
26
+
27
+ let stdout = '';
28
+ let stderr = '';
29
+
30
+ proc.stdout.on('data', (data) => { stdout += data; });
31
+ proc.stderr.on('data', (data) => { stderr += data; });
32
+
33
+ proc.on('close', (code) => {
34
+ if (code !== 0) {
35
+ return reject(new Error(stderr || `yt-dlp exited with code ${code}`));
36
+ }
37
+ try {
38
+ const data = JSON.parse(stdout);
39
+ const tracks = (data.entries || []).map(entry => ({
40
+ id: entry.id,
41
+ title: entry.title,
42
+ duration: entry.duration,
43
+ author: entry.channel || entry.uploader,
44
+ thumbnail: entry.thumbnails?.[0]?.url,
45
+ uri: `https://www.youtube.com/watch?v=${entry.id}`,
46
+ streamUrl: `/youtube/stream/${entry.id}`,
47
+ source: 'youtube'
48
+ }));
49
+ const elapsed = Date.now() - startTime;
50
+ log.info('YOUTUBE', `Found ${tracks.length} results (${elapsed}ms)`);
51
+ resolve({ tracks, source: 'youtube', searchTime: elapsed });
52
+ } catch (e) {
53
+ reject(new Error('Failed to parse yt-dlp output'));
54
+ }
55
+ });
56
+ });
57
+ }
58
+
59
+ async function getInfo(videoId, config) {
60
+ log.info('YOUTUBE', `Getting info: ${videoId}`);
61
+
62
+ return new Promise((resolve, reject) => {
63
+ const args = [
64
+ '-q', '--no-warnings',
65
+ '--skip-download',
66
+ '-J',
67
+ `https://www.youtube.com/watch?v=${videoId}`
68
+ ];
69
+
70
+ if (config.cookiesPath) {
71
+ args.unshift('--cookies', config.cookiesPath);
72
+ }
73
+
74
+ const proc = spawn(config.ytdlpPath, args, {
75
+ env: { ...process.env, PATH: '/usr/local/bin:/root/.deno/bin:' + process.env.PATH }
76
+ });
77
+
78
+ let stdout = '';
79
+ let stderr = '';
80
+
81
+ proc.stdout.on('data', (data) => { stdout += data; });
82
+ proc.stderr.on('data', (data) => { stderr += data; });
83
+
84
+ proc.on('close', (code) => {
85
+ if (code !== 0) {
86
+ return reject(new Error(stderr || `yt-dlp exited with code ${code}`));
87
+ }
88
+ try {
89
+ const data = JSON.parse(stdout);
90
+ resolve({
91
+ id: data.id,
92
+ title: data.title,
93
+ duration: data.duration,
94
+ author: data.channel || data.uploader,
95
+ thumbnail: data.thumbnail,
96
+ uri: data.webpage_url,
97
+ streamUrl: `/youtube/stream/${data.id}`,
98
+ source: 'youtube'
99
+ });
100
+ } catch (e) {
101
+ reject(new Error('Failed to parse yt-dlp output'));
102
+ }
103
+ });
104
+ });
105
+ }
106
+
107
+ async function stream(videoId, filters, config, res) {
108
+ const streamId = `yt-${videoId}-${Date.now()}`;
109
+ const streamStartTime = Date.now();
110
+
111
+ const filterStr = Object.entries(filters || {})
112
+ .filter(([k, v]) => v !== undefined && v !== null)
113
+ .map(([k, v]) => `${k}=${v}`)
114
+ .join(', ') || 'none';
115
+
116
+ log.info('YOUTUBE', `Stream: ${videoId} | Filters: ${filterStr}`);
117
+
118
+ res.setHeader('Content-Type', 'audio/ogg');
119
+ res.setHeader('Transfer-Encoding', 'chunked');
120
+ res.setHeader('Cache-Control', 'no-cache');
121
+ res.flushHeaders();
122
+
123
+ const ytdlpArgs = [
124
+ '-f', '18/bestaudio/best',
125
+ '-o', '-',
126
+ `https://www.youtube.com/watch?v=${videoId}`
127
+ ];
128
+
129
+ if (config.cookiesPath) {
130
+ ytdlpArgs.unshift('--cookies', config.cookiesPath);
131
+ }
132
+
133
+ const ytdlp = spawn(config.ytdlpPath, ytdlpArgs, {
134
+ env: { ...process.env, PATH: '/usr/local/bin:/root/.deno/bin:' + process.env.PATH }
135
+ });
136
+
137
+ const ffmpegArgs = buildFfmpegArgs(filters, config);
138
+ const ffmpeg = spawn(config.ffmpegPath, ffmpegArgs);
139
+
140
+ registerStream(streamId, { ytdlp, ffmpeg, videoId, source: 'youtube', filters });
141
+
142
+ let bytesReceived = 0;
143
+ let bytesSent = 0;
144
+
145
+ ytdlp.stdout.on('data', (chunk) => {
146
+ bytesReceived += chunk.length;
147
+ });
148
+
149
+ ffmpeg.stdout.on('data', (chunk) => {
150
+ bytesSent += chunk.length;
151
+ });
152
+
153
+ ytdlp.stdout.pipe(ffmpeg.stdin);
154
+ ffmpeg.stdout.pipe(res);
155
+
156
+ ytdlp.stderr.on('data', (data) => {
157
+ const msg = data.toString();
158
+ if (!msg.includes('[download]') && !msg.includes('ETA')) {
159
+ log.debug('YTDLP', msg.trim());
160
+ }
161
+ });
162
+
163
+ ffmpeg.stderr.on('data', (data) => {
164
+ const msg = data.toString();
165
+ if (msg.includes('Error') || msg.includes('error')) {
166
+ log.error('FFMPEG', msg.trim());
167
+ }
168
+ });
169
+
170
+ const cleanup = (code = 0, error = null) => {
171
+ if (ytdlp && !ytdlp.killed) ytdlp.kill('SIGTERM');
172
+ if (ffmpeg && !ffmpeg.killed) ffmpeg.kill('SIGTERM');
173
+ unregisterStream(streamId, code, error);
174
+ };
175
+
176
+ ytdlp.on('error', (error) => {
177
+ log.error('YOUTUBE', videoId, error.message);
178
+ cleanup(1, error);
179
+ });
180
+
181
+ ffmpeg.on('error', (error) => {
182
+ log.error('FFMPEG', videoId, error.message);
183
+ cleanup(1, error);
184
+ });
185
+
186
+ ffmpeg.on('close', (code) => {
187
+ const elapsed = Date.now() - streamStartTime;
188
+ log.info('YOUTUBE', `Stream ended: ${videoId} | Code: ${code} | ${elapsed}ms | Received: ${bytesReceived} | Sent: ${bytesSent}`);
189
+ cleanup(code);
190
+ });
191
+
192
+ ytdlp.on('close', (code) => {
193
+ if (code !== 0 && code !== null) {
194
+ log.debug('YOUTUBE', `yt-dlp ended for ${videoId} (code: ${code})`);
195
+ }
196
+ ffmpeg.stdin.end();
197
+ });
198
+
199
+ res.on('close', () => {
200
+ const elapsed = Date.now() - streamStartTime;
201
+ log.info('YOUTUBE', `Client disconnected: ${videoId} | ${elapsed}ms | Received: ${bytesReceived} | Sent: ${bytesSent}`);
202
+ cleanup(0);
203
+ });
204
+ }
205
+
206
+ async function getPlaylist(playlistId, config) {
207
+ log.info('YOUTUBE', `Getting playlist: ${playlistId}`);
208
+
209
+ return new Promise((resolve, reject) => {
210
+ const args = [
211
+ '-q', '--no-warnings',
212
+ '--flat-playlist',
213
+ '--skip-download',
214
+ '-J',
215
+ `https://www.youtube.com/playlist?list=${playlistId}`
216
+ ];
217
+
218
+ if (config.cookiesPath) {
219
+ args.unshift('--cookies', config.cookiesPath);
220
+ }
221
+
222
+ const proc = spawn(config.ytdlpPath, args, {
223
+ env: { ...process.env, PATH: '/usr/local/bin:/root/.deno/bin:' + process.env.PATH }
224
+ });
225
+
226
+ let stdout = '';
227
+ let stderr = '';
228
+
229
+ proc.stdout.on('data', (data) => { stdout += data; });
230
+ proc.stderr.on('data', (data) => { stderr += data; });
231
+
232
+ proc.on('close', (code) => {
233
+ if (code !== 0) {
234
+ return reject(new Error(stderr || `yt-dlp exited with code ${code}`));
235
+ }
236
+ try {
237
+ const data = JSON.parse(stdout);
238
+ const tracks = (data.entries || []).map(entry => ({
239
+ id: entry.id,
240
+ title: entry.title,
241
+ duration: entry.duration,
242
+ author: entry.channel || entry.uploader,
243
+ thumbnail: entry.thumbnails?.[0]?.url,
244
+ uri: `https://www.youtube.com/watch?v=${entry.id}`,
245
+ streamUrl: `/youtube/stream/${entry.id}`,
246
+ source: 'youtube'
247
+ }));
248
+ log.info('YOUTUBE', `Playlist loaded: ${data.title || playlistId} (${tracks.length} tracks)`);
249
+ resolve({
250
+ id: playlistId,
251
+ title: data.title,
252
+ author: data.channel || data.uploader,
253
+ thumbnail: data.thumbnails?.[0]?.url,
254
+ tracks,
255
+ source: 'youtube'
256
+ });
257
+ } catch (e) {
258
+ reject(new Error('Failed to parse playlist data'));
259
+ }
260
+ });
261
+ });
262
+ }
263
+
264
+ async function getRelated(videoId, limit, config) {
265
+ log.info('YOUTUBE', `Getting related for: ${videoId} (limit: ${limit})`);
266
+
267
+ return new Promise((resolve, reject) => {
268
+ const args = [
269
+ '-q', '--no-warnings',
270
+ '--flat-playlist',
271
+ '--skip-download',
272
+ '-J',
273
+ `https://www.youtube.com/watch?v=${videoId}&list=RD${videoId}`
274
+ ];
275
+
276
+ if (config.cookiesPath) {
277
+ args.unshift('--cookies', config.cookiesPath);
278
+ }
279
+
280
+ const proc = spawn(config.ytdlpPath, args, {
281
+ env: { ...process.env, PATH: '/usr/local/bin:/root/.deno/bin:' + process.env.PATH }
282
+ });
283
+
284
+ let stdout = '';
285
+ let stderr = '';
286
+
287
+ proc.stdout.on('data', (data) => { stdout += data; });
288
+ proc.stderr.on('data', (data) => { stderr += data; });
289
+
290
+ proc.on('close', (code) => {
291
+ if (code !== 0) {
292
+ return reject(new Error(stderr || `yt-dlp exited with code ${code}`));
293
+ }
294
+ try {
295
+ const data = JSON.parse(stdout);
296
+ const entries = data.entries || [];
297
+ const tracks = entries
298
+ .filter(entry => entry.id !== videoId)
299
+ .slice(0, limit)
300
+ .map(entry => ({
301
+ id: entry.id,
302
+ title: entry.title,
303
+ duration: entry.duration,
304
+ author: entry.channel || entry.uploader,
305
+ thumbnail: entry.thumbnails?.[0]?.url,
306
+ uri: `https://www.youtube.com/watch?v=${entry.id}`,
307
+ streamUrl: `/youtube/stream/${entry.id}`,
308
+ source: 'youtube',
309
+ isAutoplay: true
310
+ }));
311
+ log.info('YOUTUBE', `Found ${tracks.length} related tracks`);
312
+ resolve({ tracks, source: 'youtube' });
313
+ } catch (e) {
314
+ reject(new Error('Failed to parse related tracks'));
315
+ }
316
+ });
317
+ });
318
+ }
319
+
320
+ module.exports = { search, getInfo, stream, getPlaylist, getRelated };