@supasayan/ytm 1.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/bin/cli.js +44 -0
- package/client/index.html +13 -0
- package/client/package-lock.json +1003 -0
- package/client/package.json +22 -0
- package/client/public/favicon.svg +1 -0
- package/client/public/icons.svg +24 -0
- package/client/src/App.jsx +210 -0
- package/client/src/assets/hero.png +0 -0
- package/client/src/assets/typescript.svg +1 -0
- package/client/src/assets/vite.svg +1 -0
- package/client/src/components/Download/DownloadItem.css +246 -0
- package/client/src/components/Download/DownloadItem.jsx +108 -0
- package/client/src/components/Download/FlyingThumbnail.css +33 -0
- package/client/src/components/Download/FlyingThumbnail.jsx +48 -0
- package/client/src/components/Layout/Header.css +91 -0
- package/client/src/components/Layout/Header.jsx +49 -0
- package/client/src/components/Layout/SystemCheckScreen.css +250 -0
- package/client/src/components/Layout/SystemCheckScreen.jsx +110 -0
- package/client/src/components/Player/AudioPlayer.css +280 -0
- package/client/src/components/Player/AudioPlayer.jsx +154 -0
- package/client/src/components/Playlist/PlaylistInput.css +71 -0
- package/client/src/components/Playlist/PlaylistInput.jsx +44 -0
- package/client/src/components/Playlist/PlaylistView.css +150 -0
- package/client/src/components/Playlist/PlaylistView.jsx +104 -0
- package/client/src/components/Search/SearchBar.css +163 -0
- package/client/src/components/Search/SearchBar.jsx +127 -0
- package/client/src/components/Search/SearchResults.css +42 -0
- package/client/src/components/Search/SearchResults.jsx +64 -0
- package/client/src/components/Search/SongCard.css +296 -0
- package/client/src/components/Search/SongCard.jsx +204 -0
- package/client/src/components/Settings/SettingsPanel.css +345 -0
- package/client/src/components/Settings/SettingsPanel.jsx +174 -0
- package/client/src/hooks/useDownload.js +107 -0
- package/client/src/hooks/usePlayer.js +188 -0
- package/client/src/hooks/usePlaylist.js +52 -0
- package/client/src/hooks/useSearch.js +34 -0
- package/client/src/index.css +245 -0
- package/client/src/main.jsx +10 -0
- package/client/src/pages/DownloadsPage.css +37 -0
- package/client/src/pages/DownloadsPage.jsx +80 -0
- package/client/src/pages/PlaylistPage.jsx +46 -0
- package/client/src/pages/SearchPage.jsx +63 -0
- package/client/tsconfig.json +24 -0
- package/client/vite.config.js +12 -0
- package/main.js +79 -0
- package/package.json +48 -0
- package/server/index.js +54 -0
- package/server/package-lock.json +1428 -0
- package/server/package.json +14 -0
- package/server/routes/browse.js +23 -0
- package/server/routes/download.js +339 -0
- package/server/routes/playLocal.js +50 -0
- package/server/routes/playlist.js +28 -0
- package/server/routes/search.js +21 -0
- package/server/routes/stream.js +53 -0
- package/server/services/downloadService.js +143 -0
- package/server/services/ytmusicService.js +62 -0
- package/server/utils/dependencyChecker.js +174 -0
- package/server/utils/paths.js +11 -0
- package/server/utils/sanitize.js +9 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ytm-downloader-server",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "node --watch index.js"
|
|
7
|
+
},
|
|
8
|
+
"dependencies": {
|
|
9
|
+
"express": "^4.21.0",
|
|
10
|
+
"cors": "^2.8.5",
|
|
11
|
+
"ytmusic-api": "^5.0.0",
|
|
12
|
+
"ffmpeg-static": "^5.2.0"
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import { exec } from 'child_process';
|
|
3
|
+
|
|
4
|
+
const router = express.Router();
|
|
5
|
+
|
|
6
|
+
router.get('/', (req, res) => {
|
|
7
|
+
// Use macOS native AppleScript to open a folder picker
|
|
8
|
+
const script = `osascript -e 'POSIX path of (choose folder with prompt "Select Download Directory")'`;
|
|
9
|
+
|
|
10
|
+
exec(script, (error, stdout, stderr) => {
|
|
11
|
+
if (error) {
|
|
12
|
+
console.error('Browse folder error:', error);
|
|
13
|
+
// User probably clicked 'Cancel' in the dialog
|
|
14
|
+
return res.status(400).json({ error: 'Folder selection cancelled or failed' });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Clean up the trailing newline
|
|
18
|
+
const selectedPath = stdout.trim();
|
|
19
|
+
res.json({ path: selectedPath });
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export default router;
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import crypto from 'crypto';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import { spawn } from 'child_process';
|
|
6
|
+
import { downloadTrack } from '../services/downloadService.js';
|
|
7
|
+
import { getLyricsText } from '../services/ytmusicService.js';
|
|
8
|
+
import { FFMPEG_PATH, YTDLP_PATH } from '../utils/dependencyChecker.js';
|
|
9
|
+
import { getDataDir } from '../utils/paths.js';
|
|
10
|
+
|
|
11
|
+
const router = express.Router();
|
|
12
|
+
|
|
13
|
+
const downloadsMap = new Map();
|
|
14
|
+
const sseClients = new Set();
|
|
15
|
+
const dirtyDownloads = new Set();
|
|
16
|
+
|
|
17
|
+
// Queue state
|
|
18
|
+
let downloadQueue = [];
|
|
19
|
+
let activeDownloads = 0;
|
|
20
|
+
const MAX_CONCURRENT = 3;
|
|
21
|
+
let isQueuePaused = false;
|
|
22
|
+
let queueSaveTimeout = null;
|
|
23
|
+
|
|
24
|
+
const QUEUE_FILE = path.join(getDataDir(), 'queue.json');
|
|
25
|
+
const QUEUE_FILE_TEMP = path.join(getDataDir(), 'queue.temp.json');
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------
|
|
28
|
+
// Disk Persistence
|
|
29
|
+
// ---------------------------------------------------------
|
|
30
|
+
function loadQueueFromDisk() {
|
|
31
|
+
if (fs.existsSync(QUEUE_FILE)) {
|
|
32
|
+
try {
|
|
33
|
+
const data = JSON.parse(fs.readFileSync(QUEUE_FILE, 'utf-8'));
|
|
34
|
+
if (Array.isArray(data.downloadsMap)) {
|
|
35
|
+
for (const [id, dl] of data.downloadsMap) {
|
|
36
|
+
// Reset 'downloading' to 'queued' on startup to allow them to retry
|
|
37
|
+
if (dl.status === 'downloading') {
|
|
38
|
+
dl.status = 'queued';
|
|
39
|
+
dl.percent = '0%';
|
|
40
|
+
dl.speed = '';
|
|
41
|
+
dl.eta = '';
|
|
42
|
+
}
|
|
43
|
+
downloadsMap.set(id, dl);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (Array.isArray(data.downloadQueue)) {
|
|
47
|
+
downloadQueue = data.downloadQueue;
|
|
48
|
+
}
|
|
49
|
+
console.log(`[Queue] Loaded ${downloadQueue.length} pending tasks and ${downloadsMap.size} map entries from disk.`);
|
|
50
|
+
} catch (err) {
|
|
51
|
+
console.error('[Queue] Failed to load queue.json:', err);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function saveQueueToDisk() {
|
|
57
|
+
try {
|
|
58
|
+
const data = {
|
|
59
|
+
downloadsMap: Array.from(downloadsMap.entries()),
|
|
60
|
+
downloadQueue
|
|
61
|
+
};
|
|
62
|
+
fs.writeFileSync(QUEUE_FILE_TEMP, JSON.stringify(data, null, 2));
|
|
63
|
+
fs.renameSync(QUEUE_FILE_TEMP, QUEUE_FILE);
|
|
64
|
+
} catch (err) {
|
|
65
|
+
console.error('[Queue] Failed to save queue to disk:', err);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function scheduleSave() {
|
|
70
|
+
if (queueSaveTimeout) clearTimeout(queueSaveTimeout);
|
|
71
|
+
queueSaveTimeout = setTimeout(saveQueueToDisk, 2000);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Load on boot, then start processing immediately just in case
|
|
75
|
+
loadQueueFromDisk();
|
|
76
|
+
processQueue(); // Kickoff any pending items loaded from disk
|
|
77
|
+
|
|
78
|
+
// ---------------------------------------------------------
|
|
79
|
+
// SSE Delta Streaming
|
|
80
|
+
// ---------------------------------------------------------
|
|
81
|
+
setInterval(() => {
|
|
82
|
+
if (dirtyDownloads.size > 0 && sseClients.size > 0) {
|
|
83
|
+
const updates = [];
|
|
84
|
+
for (const id of dirtyDownloads) {
|
|
85
|
+
const dl = downloadsMap.get(id);
|
|
86
|
+
if (dl) updates.push([id, dl]);
|
|
87
|
+
}
|
|
88
|
+
const payload = JSON.stringify({ type: 'DELTA', data: updates });
|
|
89
|
+
for (const client of sseClients) {
|
|
90
|
+
client.write(`data: ${payload}\n\n`);
|
|
91
|
+
}
|
|
92
|
+
dirtyDownloads.clear();
|
|
93
|
+
}
|
|
94
|
+
}, 500);
|
|
95
|
+
|
|
96
|
+
function markDirty(downloadId) {
|
|
97
|
+
dirtyDownloads.add(downloadId);
|
|
98
|
+
scheduleSave();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ---------------------------------------------------------
|
|
102
|
+
// Queue Processing
|
|
103
|
+
// ---------------------------------------------------------
|
|
104
|
+
async function processQueue() {
|
|
105
|
+
if (isQueuePaused || activeDownloads >= MAX_CONCURRENT || downloadQueue.length === 0) {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
activeDownloads++;
|
|
110
|
+
const task = downloadQueue.shift();
|
|
111
|
+
const { downloadId, videoId, title, outputDir, downloadLyrics } = task;
|
|
112
|
+
|
|
113
|
+
// Add 1.5s jitter before starting
|
|
114
|
+
await new Promise(resolve => setTimeout(resolve, 1500));
|
|
115
|
+
|
|
116
|
+
const onProgress = (progress) => {
|
|
117
|
+
const dl = downloadsMap.get(downloadId);
|
|
118
|
+
if (dl) {
|
|
119
|
+
dl.status = 'downloading';
|
|
120
|
+
dl.percent = progress.percent;
|
|
121
|
+
dl.speed = progress.speed;
|
|
122
|
+
dl.eta = progress.eta;
|
|
123
|
+
markDirty(downloadId);
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const onComplete = async () => {
|
|
128
|
+
const dl = downloadsMap.get(downloadId);
|
|
129
|
+
if (dl) {
|
|
130
|
+
const sanitizedTitle = dl.title.replace(/[\\/:*?"<>|]/g, '_');
|
|
131
|
+
const finalPath = path.join(outputDir, `${sanitizedTitle}.m4a`);
|
|
132
|
+
|
|
133
|
+
if (downloadLyrics) {
|
|
134
|
+
try {
|
|
135
|
+
const lyricsText = await getLyricsText(videoId);
|
|
136
|
+
if (lyricsText) {
|
|
137
|
+
await embedLyricsToM4A(finalPath, lyricsText);
|
|
138
|
+
}
|
|
139
|
+
} catch (err) {
|
|
140
|
+
console.error(`[Lyrics] Failed to download or embed lyrics for track: ${dl.title}`, err);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
dl.status = 'completed';
|
|
145
|
+
dl.percent = '100%';
|
|
146
|
+
dl.filePath = finalPath;
|
|
147
|
+
markDirty(downloadId);
|
|
148
|
+
}
|
|
149
|
+
activeDownloads--;
|
|
150
|
+
processQueue();
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const onError = (err) => {
|
|
154
|
+
const dl = downloadsMap.get(downloadId);
|
|
155
|
+
if (dl) {
|
|
156
|
+
dl.status = 'error';
|
|
157
|
+
dl.error = err.message;
|
|
158
|
+
markDirty(downloadId);
|
|
159
|
+
}
|
|
160
|
+
activeDownloads--;
|
|
161
|
+
|
|
162
|
+
// Chain-Reaction Guards
|
|
163
|
+
const msg = err.message || '';
|
|
164
|
+
if (msg.includes('429') || msg.includes('403') || msg.includes('Sign in to confirm')) {
|
|
165
|
+
console.warn(`[Queue] YouTube Rate Limit/Ban detected! Pausing queue for 5 minutes.`);
|
|
166
|
+
isQueuePaused = true;
|
|
167
|
+
setTimeout(() => {
|
|
168
|
+
console.log(`[Queue] Resuming queue after 5 minute pause.`);
|
|
169
|
+
isQueuePaused = false;
|
|
170
|
+
processQueue();
|
|
171
|
+
}, 300000);
|
|
172
|
+
} else if (msg.includes('ENOSPC') || msg.includes('No space left')) {
|
|
173
|
+
console.error(`[Queue] Disk Full (ENOSPC)! Pausing queue indefinitely.`);
|
|
174
|
+
isQueuePaused = true;
|
|
175
|
+
} else {
|
|
176
|
+
processQueue();
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
// Pre-flight directory check
|
|
181
|
+
if (!fs.existsSync(outputDir)) {
|
|
182
|
+
try {
|
|
183
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
184
|
+
} catch (err) {
|
|
185
|
+
onError(new Error(`Failed to create output directory: ${err.message}`));
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
downloadTrack(videoId, title, outputDir, onProgress, onComplete, onError);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ---------------------------------------------------------
|
|
194
|
+
// Endpoints
|
|
195
|
+
// ---------------------------------------------------------
|
|
196
|
+
router.post('/', (req, res) => {
|
|
197
|
+
const { videoId, title, artist, album, thumbnail, outputDir, downloadLyrics } = req.body;
|
|
198
|
+
if (!videoId || !outputDir) return res.status(400).json({ error: 'Missing videoId or outputDir' });
|
|
199
|
+
|
|
200
|
+
// Duplicate Check
|
|
201
|
+
for (const dl of downloadsMap.values()) {
|
|
202
|
+
if (dl.videoId === videoId && (dl.status === 'queued' || dl.status === 'downloading')) {
|
|
203
|
+
return res.json({ downloadId: dl.downloadId, message: 'Already queued or downloading' });
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const downloadId = crypto.randomUUID();
|
|
208
|
+
downloadsMap.set(downloadId, {
|
|
209
|
+
downloadId, videoId, title: title || 'Unknown Title', artist: artist || 'Unknown Artist',
|
|
210
|
+
album: album || null, thumbnail: thumbnail || null, status: 'queued', percent: '0%', speed: '', eta: ''
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
markDirty(downloadId);
|
|
214
|
+
downloadQueue.push({ downloadId, videoId, title: title || 'Unknown Title', outputDir, downloadLyrics });
|
|
215
|
+
res.json({ downloadId, message: 'Download queued' });
|
|
216
|
+
|
|
217
|
+
processQueue();
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
router.post('/bulk', (req, res) => {
|
|
221
|
+
const { songs, outputDir, downloadLyrics } = req.body;
|
|
222
|
+
if (!Array.isArray(songs) || !outputDir) return res.status(400).json({ error: 'Missing songs array or outputDir' });
|
|
223
|
+
|
|
224
|
+
// Pre-flight check
|
|
225
|
+
if (!fs.existsSync(outputDir)) {
|
|
226
|
+
try { fs.mkdirSync(outputDir, { recursive: true }); }
|
|
227
|
+
catch (err) { return res.status(400).json({ error: `Output directory invalid: ${err.message}` }); }
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const queuedIds = [];
|
|
231
|
+
for (const song of songs) {
|
|
232
|
+
const { videoId, title, artist, album, thumbnail } = song;
|
|
233
|
+
if (!videoId) continue;
|
|
234
|
+
|
|
235
|
+
// Duplicate Check
|
|
236
|
+
let isDupe = false;
|
|
237
|
+
for (const dl of downloadsMap.values()) {
|
|
238
|
+
if (dl.videoId === videoId && (dl.status === 'queued' || dl.status === 'downloading')) {
|
|
239
|
+
isDupe = true; break;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
if (isDupe) continue;
|
|
243
|
+
|
|
244
|
+
const downloadId = crypto.randomUUID();
|
|
245
|
+
downloadsMap.set(downloadId, {
|
|
246
|
+
downloadId, videoId, title: title || 'Unknown Title', artist: artist || 'Unknown Artist',
|
|
247
|
+
album: album || null, thumbnail: thumbnail || null, status: 'queued', percent: '0%', speed: '', eta: ''
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
markDirty(downloadId);
|
|
251
|
+
downloadQueue.push({ downloadId, videoId, title: title || 'Unknown Title', outputDir, downloadLyrics });
|
|
252
|
+
queuedIds.push(downloadId);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
res.json({ queuedIds, message: `${queuedIds.length} downloads queued` });
|
|
256
|
+
processQueue();
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
router.post('/clear-queue', (req, res) => {
|
|
260
|
+
// Empty pending queue (does not stop actively downloading items)
|
|
261
|
+
downloadQueue = [];
|
|
262
|
+
// Update map state for ones that were purely queued
|
|
263
|
+
for (const dl of downloadsMap.values()) {
|
|
264
|
+
if (dl.status === 'queued') {
|
|
265
|
+
dl.status = 'error';
|
|
266
|
+
dl.error = 'Cancelled by user';
|
|
267
|
+
markDirty(dl.downloadId);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
scheduleSave();
|
|
271
|
+
res.json({ message: 'Pending queue cleared' });
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
router.post('/clear-cache', (req, res) => {
|
|
275
|
+
for (const [id, dl] of downloadsMap.entries()) {
|
|
276
|
+
if (dl.status === 'completed' || dl.status === 'error') {
|
|
277
|
+
downloadsMap.delete(id);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
scheduleSave();
|
|
281
|
+
|
|
282
|
+
import('child_process').then(({ exec }) => {
|
|
283
|
+
exec(`"${YTDLP_PATH}" --rm-cache-dir`, (err, stdout, stderr) => {
|
|
284
|
+
if (err) console.error('Failed to clear yt-dlp cache:', err);
|
|
285
|
+
else console.log('yt-dlp cache cleared successfully:', stdout.trim());
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
// Force broadcast initial state to sync UI deletion
|
|
290
|
+
const payload = JSON.stringify({ type: 'INITIAL', data: Array.from(downloadsMap.entries()) });
|
|
291
|
+
for (const client of sseClients) {
|
|
292
|
+
client.write(`data: ${payload}\n\n`);
|
|
293
|
+
}
|
|
294
|
+
res.json({ message: 'Cache cleared successfully' });
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
router.get('/progress', (req, res) => {
|
|
298
|
+
res.writeHead(200, {
|
|
299
|
+
'Content-Type': 'text/event-stream',
|
|
300
|
+
'Cache-Control': 'no-cache',
|
|
301
|
+
'Connection': 'keep-alive'
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
sseClients.add(res);
|
|
305
|
+
|
|
306
|
+
// Send INITIAL payload
|
|
307
|
+
res.write(`data: ${JSON.stringify({ type: 'INITIAL', data: Array.from(downloadsMap.entries()) })}\n\n`);
|
|
308
|
+
|
|
309
|
+
req.on('close', () => {
|
|
310
|
+
sseClients.delete(res);
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
// Helper at end
|
|
315
|
+
function embedLyricsToM4A(filePath, lyricsText) {
|
|
316
|
+
return new Promise((resolve, reject) => {
|
|
317
|
+
const tempPath = filePath.replace(/\.m4a$/, '.temp.m4a');
|
|
318
|
+
const args = ['-y', '-i', filePath, '-metadata', `lyrics=${lyricsText}`, '-c', 'copy', tempPath];
|
|
319
|
+
const proc = spawn(FFMPEG_PATH, args);
|
|
320
|
+
proc.on('close', (code) => {
|
|
321
|
+
if (code === 0) {
|
|
322
|
+
try {
|
|
323
|
+
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
|
324
|
+
fs.renameSync(tempPath, filePath);
|
|
325
|
+
resolve();
|
|
326
|
+
} catch (e) { reject(e); }
|
|
327
|
+
} else {
|
|
328
|
+
if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath);
|
|
329
|
+
reject(new Error(`FFmpeg exited with code ${code}`));
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
proc.on('error', (err) => {
|
|
333
|
+
if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath);
|
|
334
|
+
reject(err);
|
|
335
|
+
});
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export default router;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
|
|
4
|
+
const router = express.Router();
|
|
5
|
+
|
|
6
|
+
router.get('/', (req, res) => {
|
|
7
|
+
const { path: filePath } = req.query;
|
|
8
|
+
|
|
9
|
+
if (!filePath) {
|
|
10
|
+
return res.status(400).json({ error: 'Missing file path' });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Very basic check to ensure it's playing an m4a file
|
|
14
|
+
if (!filePath.endsWith('.m4a')) {
|
|
15
|
+
return res.status(400).json({ error: 'Only .m4a files are supported' });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (!fs.existsSync(filePath)) {
|
|
19
|
+
return res.status(404).json({ error: 'File not found' });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const stat = fs.statSync(filePath);
|
|
23
|
+
const fileSize = stat.size;
|
|
24
|
+
const range = req.headers.range;
|
|
25
|
+
|
|
26
|
+
if (range) {
|
|
27
|
+
const parts = range.replace(/bytes=/, "").split("-");
|
|
28
|
+
const start = parseInt(parts[0], 10);
|
|
29
|
+
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
|
|
30
|
+
const chunksize = (end - start) + 1;
|
|
31
|
+
const file = fs.createReadStream(filePath, { start, end });
|
|
32
|
+
|
|
33
|
+
res.writeHead(206, {
|
|
34
|
+
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
|
|
35
|
+
'Accept-Ranges': 'bytes',
|
|
36
|
+
'Content-Length': chunksize,
|
|
37
|
+
'Content-Type': 'audio/mp4',
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
file.pipe(res);
|
|
41
|
+
} else {
|
|
42
|
+
res.writeHead(200, {
|
|
43
|
+
'Content-Length': fileSize,
|
|
44
|
+
'Content-Type': 'audio/mp4',
|
|
45
|
+
});
|
|
46
|
+
fs.createReadStream(filePath).pipe(res);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
export default router;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import { getPlaylist } from '../services/ytmusicService.js';
|
|
3
|
+
|
|
4
|
+
const router = express.Router();
|
|
5
|
+
|
|
6
|
+
router.post('/', async (req, res) => {
|
|
7
|
+
const urlStr = req.body.url;
|
|
8
|
+
if (!urlStr) {
|
|
9
|
+
return res.status(400).json({ error: 'Missing playlist URL' });
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
const parsedUrl = new URL(urlStr);
|
|
14
|
+
const playlistId = parsedUrl.searchParams.get('list');
|
|
15
|
+
|
|
16
|
+
if (!playlistId) {
|
|
17
|
+
return res.status(400).json({ error: 'Invalid playlist URL. Must contain a "list=" parameter.' });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const playlistData = await getPlaylist(playlistId);
|
|
21
|
+
res.json(playlistData);
|
|
22
|
+
} catch (error) {
|
|
23
|
+
console.error('Playlist route error:', error);
|
|
24
|
+
res.status(500).json({ error: 'Failed to fetch playlist', details: error.message });
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
export default router;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import { searchSongs } from '../services/ytmusicService.js';
|
|
3
|
+
|
|
4
|
+
const router = express.Router();
|
|
5
|
+
|
|
6
|
+
router.get('/', async (req, res) => {
|
|
7
|
+
const query = req.query.q;
|
|
8
|
+
if (!query) {
|
|
9
|
+
return res.status(400).json({ error: 'Missing search query' });
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
const results = await searchSongs(query);
|
|
14
|
+
res.json({ results });
|
|
15
|
+
} catch (error) {
|
|
16
|
+
console.error('Search route error:', error);
|
|
17
|
+
res.status(500).json({ error: 'Search failed', details: error.message });
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export default router;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import { spawn } from 'child_process';
|
|
3
|
+
import { YTDLP_PATH } from '../utils/dependencyChecker.js';
|
|
4
|
+
|
|
5
|
+
const router = express.Router();
|
|
6
|
+
|
|
7
|
+
router.get('/:videoId', (req, res) => {
|
|
8
|
+
const { videoId } = req.params;
|
|
9
|
+
|
|
10
|
+
if (!videoId) {
|
|
11
|
+
return res.status(400).json({ error: 'Missing videoId' });
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
res.setHeader('Content-Type', 'audio/mp4');
|
|
15
|
+
res.setHeader('Transfer-Encoding', 'chunked');
|
|
16
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
17
|
+
|
|
18
|
+
const args = [
|
|
19
|
+
'--no-cache-dir',
|
|
20
|
+
'--extractor-args', 'youtube:player_client=android_vr,web,mweb',
|
|
21
|
+
'-f', 'bestaudio[ext=m4a]/bestaudio',
|
|
22
|
+
'--no-playlist',
|
|
23
|
+
'-o', '-',
|
|
24
|
+
`https://music.youtube.com/watch?v=${videoId}`
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
const proc = spawn(YTDLP_PATH, args);
|
|
28
|
+
|
|
29
|
+
proc.stdout.pipe(res);
|
|
30
|
+
|
|
31
|
+
proc.stderr.on('data', (data) => {
|
|
32
|
+
// Only log errors, ignore warnings
|
|
33
|
+
const msg = data.toString();
|
|
34
|
+
if (msg.includes('ERROR:')) {
|
|
35
|
+
console.error(`[yt-dlp stream error] ${msg}`);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
req.on('close', () => {
|
|
40
|
+
if (proc && !proc.killed) {
|
|
41
|
+
proc.kill('SIGINT');
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
proc.on('error', (err) => {
|
|
46
|
+
console.error('Failed to start yt-dlp process:', err);
|
|
47
|
+
if (!res.headersSent) {
|
|
48
|
+
res.status(500).end('Internal Server Error');
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
export default router;
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
import { sanitizeFilename } from '../utils/sanitize.js';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import { YTDLP_PATH } from '../utils/dependencyChecker.js';
|
|
6
|
+
|
|
7
|
+
const activeProcesses = new Set();
|
|
8
|
+
|
|
9
|
+
const killActiveProcesses = () => {
|
|
10
|
+
for (const proc of activeProcesses) {
|
|
11
|
+
if (!proc.killed) {
|
|
12
|
+
console.log(`[Shutdown] Killing orphaned yt-dlp process PID: ${proc.pid}`);
|
|
13
|
+
try { proc.kill('SIGKILL'); } catch (e) {}
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
process.on('exit', killActiveProcesses);
|
|
19
|
+
process.on('SIGINT', () => {
|
|
20
|
+
killActiveProcesses();
|
|
21
|
+
process.exit(0);
|
|
22
|
+
});
|
|
23
|
+
process.on('SIGTERM', () => {
|
|
24
|
+
killActiveProcesses();
|
|
25
|
+
process.exit(0);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
function cleanupTempFiles(outputDir, title) {
|
|
29
|
+
try {
|
|
30
|
+
if (!fs.existsSync(outputDir) || !title) return;
|
|
31
|
+
|
|
32
|
+
// Sanitize title matching yt-dlp filename cleaning rules
|
|
33
|
+
const sanitizedTitle = title.replace(/[\\/:*?"<>|]/g, '_');
|
|
34
|
+
|
|
35
|
+
const files = fs.readdirSync(outputDir);
|
|
36
|
+
// Temporary formats generated during downloading and post-processing
|
|
37
|
+
const tempExtensions = ['.webp', '.jpg', '.jpeg', '.png', '.part', '.ytdl', '.temp'];
|
|
38
|
+
|
|
39
|
+
files.forEach(file => {
|
|
40
|
+
// Check if file prefix matches the sanitized title
|
|
41
|
+
if (file.startsWith(sanitizedTitle)) {
|
|
42
|
+
const ext = path.extname(file).toLowerCase();
|
|
43
|
+
if (tempExtensions.includes(ext) || file.endsWith('.temp.webp') || file.endsWith('.m4a.part')) {
|
|
44
|
+
const fullPath = path.join(outputDir, file);
|
|
45
|
+
if (fs.existsSync(fullPath)) {
|
|
46
|
+
fs.unlinkSync(fullPath);
|
|
47
|
+
console.log(`[Clean-up] Cleaned stray failure file: ${file}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
} catch (err) {
|
|
53
|
+
console.error('[Clean-up] Failed to delete temp files:', err);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function downloadTrack(videoId, title, outputDir, onProgress, onComplete, onError) {
|
|
58
|
+
// Ensure output directory exists
|
|
59
|
+
if (!fs.existsSync(outputDir)) {
|
|
60
|
+
try {
|
|
61
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
62
|
+
} catch (err) {
|
|
63
|
+
onError(new Error(`Failed to create output directory: ${err.message}`));
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const args = [
|
|
69
|
+
'--no-cache-dir',
|
|
70
|
+
'--extractor-args', 'youtube:player_client=android_vr,web,mweb',
|
|
71
|
+
'-f', 'bestaudio[ext=m4a]/bestaudio',
|
|
72
|
+
'-x',
|
|
73
|
+
'--audio-format', 'm4a',
|
|
74
|
+
'--embed-metadata',
|
|
75
|
+
'--embed-thumbnail',
|
|
76
|
+
'--progress-template', '%(progress)j',
|
|
77
|
+
'-o', path.join(outputDir, '%(title)s.%(ext)s'),
|
|
78
|
+
'--no-playlist',
|
|
79
|
+
`https://music.youtube.com/watch?v=${videoId}`
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
const proc = spawn(YTDLP_PATH, args, { timeout: 300000 }); // 5 minutes hard timeout
|
|
83
|
+
activeProcesses.add(proc);
|
|
84
|
+
|
|
85
|
+
let stderrData = '';
|
|
86
|
+
|
|
87
|
+
proc.stdout.on('data', (data) => {
|
|
88
|
+
const lines = data.toString().split('\n');
|
|
89
|
+
lines.forEach(line => {
|
|
90
|
+
const trimmed = line.trim();
|
|
91
|
+
if (trimmed && trimmed.startsWith('{') && trimmed.endsWith('}')) {
|
|
92
|
+
try {
|
|
93
|
+
const progressInfo = JSON.parse(trimmed);
|
|
94
|
+
onProgress({
|
|
95
|
+
percent: progressInfo._percent_str || '0%',
|
|
96
|
+
speed: progressInfo._speed_str || 'Unknown',
|
|
97
|
+
eta: progressInfo._eta_str || 'Unknown'
|
|
98
|
+
});
|
|
99
|
+
} catch (e) {
|
|
100
|
+
// Ignore non-JSON stdout
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
proc.stderr.on('data', (data) => {
|
|
107
|
+
const msg = data.toString();
|
|
108
|
+
stderrData += msg;
|
|
109
|
+
console.error(`[yt-dlp stderr] ${msg}`);
|
|
110
|
+
if (msg.toLowerCase().includes('ffmpeg is not installed') || msg.toLowerCase().includes('ffprobe is not installed')) {
|
|
111
|
+
onError(new Error('FFmpeg is not installed'));
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
proc.on('close', (code, signal) => {
|
|
116
|
+
activeProcesses.delete(proc);
|
|
117
|
+
if (signal === 'SIGTERM') {
|
|
118
|
+
onError(new Error('Process timeout (5 minutes exceeded)'));
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (code === 0) {
|
|
122
|
+
onComplete();
|
|
123
|
+
} else {
|
|
124
|
+
cleanupTempFiles(outputDir, title);
|
|
125
|
+
const lines = stderrData.split('\n');
|
|
126
|
+
const errorLine = lines.find(l => l.trim().startsWith('ERROR:')) || '';
|
|
127
|
+
const cleanError = errorLine ? errorLine.replace(/ERROR:\s*/i, '').trim() : `yt-dlp exited with code ${code}`;
|
|
128
|
+
onError(new Error(cleanError));
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
proc.on('error', (err) => {
|
|
133
|
+
activeProcesses.delete(proc);
|
|
134
|
+
cleanupTempFiles(outputDir, title);
|
|
135
|
+
if (err.code === 'ENOENT') {
|
|
136
|
+
onError(new Error('yt-dlp is not installed.'));
|
|
137
|
+
} else {
|
|
138
|
+
onError(err);
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
return proc;
|
|
143
|
+
}
|