@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.
Files changed (60) hide show
  1. package/bin/cli.js +44 -0
  2. package/client/index.html +13 -0
  3. package/client/package-lock.json +1003 -0
  4. package/client/package.json +22 -0
  5. package/client/public/favicon.svg +1 -0
  6. package/client/public/icons.svg +24 -0
  7. package/client/src/App.jsx +210 -0
  8. package/client/src/assets/hero.png +0 -0
  9. package/client/src/assets/typescript.svg +1 -0
  10. package/client/src/assets/vite.svg +1 -0
  11. package/client/src/components/Download/DownloadItem.css +246 -0
  12. package/client/src/components/Download/DownloadItem.jsx +108 -0
  13. package/client/src/components/Download/FlyingThumbnail.css +33 -0
  14. package/client/src/components/Download/FlyingThumbnail.jsx +48 -0
  15. package/client/src/components/Layout/Header.css +91 -0
  16. package/client/src/components/Layout/Header.jsx +49 -0
  17. package/client/src/components/Layout/SystemCheckScreen.css +250 -0
  18. package/client/src/components/Layout/SystemCheckScreen.jsx +110 -0
  19. package/client/src/components/Player/AudioPlayer.css +280 -0
  20. package/client/src/components/Player/AudioPlayer.jsx +154 -0
  21. package/client/src/components/Playlist/PlaylistInput.css +71 -0
  22. package/client/src/components/Playlist/PlaylistInput.jsx +44 -0
  23. package/client/src/components/Playlist/PlaylistView.css +150 -0
  24. package/client/src/components/Playlist/PlaylistView.jsx +104 -0
  25. package/client/src/components/Search/SearchBar.css +163 -0
  26. package/client/src/components/Search/SearchBar.jsx +127 -0
  27. package/client/src/components/Search/SearchResults.css +42 -0
  28. package/client/src/components/Search/SearchResults.jsx +64 -0
  29. package/client/src/components/Search/SongCard.css +296 -0
  30. package/client/src/components/Search/SongCard.jsx +204 -0
  31. package/client/src/components/Settings/SettingsPanel.css +345 -0
  32. package/client/src/components/Settings/SettingsPanel.jsx +174 -0
  33. package/client/src/hooks/useDownload.js +107 -0
  34. package/client/src/hooks/usePlayer.js +188 -0
  35. package/client/src/hooks/usePlaylist.js +52 -0
  36. package/client/src/hooks/useSearch.js +34 -0
  37. package/client/src/index.css +245 -0
  38. package/client/src/main.jsx +10 -0
  39. package/client/src/pages/DownloadsPage.css +37 -0
  40. package/client/src/pages/DownloadsPage.jsx +80 -0
  41. package/client/src/pages/PlaylistPage.jsx +46 -0
  42. package/client/src/pages/SearchPage.jsx +63 -0
  43. package/client/tsconfig.json +24 -0
  44. package/client/vite.config.js +12 -0
  45. package/main.js +79 -0
  46. package/package.json +48 -0
  47. package/server/index.js +54 -0
  48. package/server/package-lock.json +1428 -0
  49. package/server/package.json +14 -0
  50. package/server/routes/browse.js +23 -0
  51. package/server/routes/download.js +339 -0
  52. package/server/routes/playLocal.js +50 -0
  53. package/server/routes/playlist.js +28 -0
  54. package/server/routes/search.js +21 -0
  55. package/server/routes/stream.js +53 -0
  56. package/server/services/downloadService.js +143 -0
  57. package/server/services/ytmusicService.js +62 -0
  58. package/server/utils/dependencyChecker.js +174 -0
  59. package/server/utils/paths.js +11 -0
  60. package/server/utils/sanitize.js +9 -0
@@ -0,0 +1,345 @@
1
+ .settings-tab-container {
2
+ display: flex;
3
+ justify-content: center;
4
+ padding: 2rem 0;
5
+ animation: fadeIn 0.3s ease;
6
+ }
7
+
8
+ .settings-card {
9
+ width: 100%;
10
+ max-width: 600px;
11
+ background: var(--bg-secondary);
12
+ border: 1px solid var(--border);
13
+ box-shadow: var(--shadow-lg);
14
+ }
15
+
16
+ .settings-header {
17
+ padding: 1.5rem;
18
+ border-bottom: 1px solid var(--border);
19
+ display: flex;
20
+ align-items: center;
21
+ justify-content: space-between;
22
+ }
23
+
24
+ .settings-title-container {
25
+ display: flex;
26
+ align-items: center;
27
+ gap: 0.75rem;
28
+ }
29
+
30
+ .settings-title-icon {
31
+ color: var(--accent-primary);
32
+ }
33
+
34
+ .settings-header h2 {
35
+ margin: 0;
36
+ font-size: 1.25rem;
37
+ color: var(--text-primary);
38
+ }
39
+
40
+ .settings-form {
41
+ padding: 2rem 1.5rem;
42
+ }
43
+
44
+ .form-group {
45
+ display: flex;
46
+ flex-direction: column;
47
+ gap: 0.75rem;
48
+ margin-bottom: 2rem;
49
+ }
50
+
51
+ .form-group label {
52
+ font-size: 0.95rem;
53
+ font-weight: 500;
54
+ color: var(--text-primary);
55
+ }
56
+
57
+ .toggle-group {
58
+ margin-top: 1rem;
59
+ margin-bottom: 2rem;
60
+ }
61
+
62
+ .toggle-row {
63
+ display: flex;
64
+ justify-content: space-between;
65
+ align-items: center;
66
+ }
67
+
68
+ .toggle-label-text {
69
+ font-size: 0.95rem;
70
+ font-weight: 500;
71
+ color: var(--text-primary);
72
+ }
73
+
74
+ .toggle-switch {
75
+ position: relative;
76
+ display: inline-block;
77
+ width: 46px;
78
+ height: 24px;
79
+ }
80
+
81
+ .toggle-switch input {
82
+ opacity: 0;
83
+ width: 0;
84
+ height: 0;
85
+ }
86
+
87
+ .toggle-slider {
88
+ position: absolute;
89
+ cursor: pointer;
90
+ top: 0;
91
+ left: 0;
92
+ right: 0;
93
+ bottom: 0;
94
+ background-color: var(--bg-tertiary);
95
+ border: 1px solid var(--border);
96
+ transition: .3s;
97
+ border-radius: 24px;
98
+ }
99
+
100
+ .toggle-slider:before {
101
+ position: absolute;
102
+ content: "";
103
+ height: 16px;
104
+ width: 16px;
105
+ left: 3px;
106
+ bottom: 3px;
107
+ background-color: var(--text-secondary);
108
+ transition: .3s;
109
+ border-radius: 50%;
110
+ }
111
+
112
+ .toggle-switch input:checked + .toggle-slider {
113
+ background: var(--accent-gradient);
114
+ border-color: transparent;
115
+ }
116
+
117
+ .toggle-switch input:checked + .toggle-slider:before {
118
+ transform: translateX(22px);
119
+ background-color: #fff;
120
+ }
121
+
122
+ .toggle-switch input:focus + .toggle-slider {
123
+ box-shadow: 0 0 1px var(--accent-primary);
124
+ }
125
+
126
+ .form-group input {
127
+ flex: 1;
128
+ background: var(--bg-tertiary);
129
+ border: 1px solid var(--border);
130
+ padding: 0.75rem 1rem;
131
+ border-radius: var(--radius-sm);
132
+ color: var(--text-primary);
133
+ font-size: 1rem;
134
+ outline: none;
135
+ transition: border-color var(--transition-fast);
136
+ }
137
+
138
+ .form-group input:focus {
139
+ border-color: var(--accent-primary);
140
+ }
141
+
142
+ .input-with-button {
143
+ display: flex;
144
+ gap: 0.5rem;
145
+ }
146
+
147
+ .btn-browse {
148
+ background: var(--bg-tertiary);
149
+ color: var(--text-secondary);
150
+ border: 1px solid var(--border);
151
+ padding: 0 1.25rem;
152
+ border-radius: var(--radius-sm);
153
+ display: flex;
154
+ align-items: center;
155
+ justify-content: center;
156
+ transition: all var(--transition-fast);
157
+ }
158
+
159
+ .btn-browse:hover:not(:disabled) {
160
+ background: var(--accent-primary);
161
+ border-color: var(--accent-primary);
162
+ color: white;
163
+ }
164
+
165
+ .btn-browse:disabled {
166
+ opacity: 0.5;
167
+ cursor: not-allowed;
168
+ }
169
+
170
+ .help-text {
171
+ margin: 0;
172
+ font-size: 0.8rem;
173
+ color: var(--text-muted);
174
+ }
175
+
176
+ .settings-actions {
177
+ display: flex;
178
+ align-items: center;
179
+ justify-content: flex-end;
180
+ gap: 1.5rem;
181
+ }
182
+
183
+ .btn-primary {
184
+ padding: 0.75rem 1.75rem;
185
+ border-radius: var(--radius-sm);
186
+ font-weight: 500;
187
+ font-size: 0.95rem;
188
+ background: var(--accent-gradient);
189
+ color: white;
190
+ transition: all var(--transition-fast);
191
+ }
192
+
193
+ .btn-primary:hover {
194
+ transform: translateY(-1px);
195
+ box-shadow: var(--shadow-glow);
196
+ }
197
+
198
+ .settings-divider {
199
+ height: 1px;
200
+ background: var(--glass-border);
201
+ margin: 2rem 0;
202
+ }
203
+
204
+ .cache-action-row {
205
+ display: flex;
206
+ align-items: center;
207
+ gap: 1.5rem;
208
+ }
209
+
210
+ .btn-danger {
211
+ display: flex;
212
+ align-items: center;
213
+ gap: 0.5rem;
214
+ padding: 0.65rem 1.25rem;
215
+ border-radius: var(--radius-sm);
216
+ font-weight: 500;
217
+ font-size: 0.9rem;
218
+ background: rgba(239, 68, 68, 0.1);
219
+ border: 1px solid rgba(239, 68, 68, 0.4);
220
+ color: #ef4444;
221
+ transition: all var(--transition-fast);
222
+ cursor: pointer;
223
+ }
224
+
225
+ .btn-danger:hover:not(:disabled) {
226
+ background: rgba(239, 68, 68, 0.2);
227
+ border-color: #ef4444;
228
+ color: #ef4444;
229
+ box-shadow: 0 0 12px rgba(239, 68, 68, 0.3);
230
+ transform: translateY(-1px);
231
+ }
232
+
233
+ .btn-danger:active {
234
+ transform: translateY(0);
235
+ }
236
+
237
+ .btn-danger:disabled {
238
+ opacity: 0.5;
239
+ cursor: not-allowed;
240
+ }
241
+
242
+ .cleared-success-msg {
243
+ color: #10b981;
244
+ font-size: 0.9rem;
245
+ display: flex;
246
+ align-items: center;
247
+ gap: 0.25rem;
248
+ font-weight: 500;
249
+ animation: checkPop 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
250
+ }
251
+
252
+ .saved-success-msg {
253
+ position: absolute;
254
+ top: 1.5rem;
255
+ right: 1.5rem;
256
+ color: #10b981;
257
+ font-size: 0.9rem;
258
+ display: flex;
259
+ align-items: center;
260
+ gap: 0.25rem;
261
+ font-weight: 500;
262
+ opacity: 0;
263
+ transform: translateY(-5px);
264
+ transition: opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1), transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
265
+ pointer-events: none;
266
+ z-index: 10;
267
+ }
268
+
269
+ .saved-success-msg.visible {
270
+ opacity: 1;
271
+ transform: translateY(0);
272
+ }
273
+
274
+ @keyframes fadeIn {
275
+ from { opacity: 0; transform: translateY(10px); }
276
+ to { opacity: 1; transform: translateY(0); }
277
+ }
278
+
279
+ .label-with-info {
280
+ display: flex;
281
+ align-items: center;
282
+ gap: 0.5rem;
283
+ }
284
+
285
+ .tooltip-container {
286
+ position: relative;
287
+ display: inline-flex;
288
+ align-items: center;
289
+ justify-content: center;
290
+ cursor: help;
291
+ }
292
+
293
+ .info-icon {
294
+ color: var(--text-muted);
295
+ opacity: 0.6;
296
+ transition: opacity 0.2s ease, color 0.2s ease;
297
+ }
298
+
299
+ .tooltip-container:hover .info-icon {
300
+ opacity: 1;
301
+ color: var(--accent-primary);
302
+ }
303
+
304
+ .tooltip-text {
305
+ visibility: hidden;
306
+ width: 250px;
307
+ background-color: var(--bg-tertiary);
308
+ color: var(--text-primary);
309
+ text-align: left;
310
+ border: 1px solid var(--border);
311
+ border-radius: var(--radius-sm);
312
+ padding: 0.75rem;
313
+ font-size: 0.8rem;
314
+ font-weight: 400;
315
+ line-height: 1.4;
316
+ box-shadow: var(--shadow-lg);
317
+ backdrop-filter: blur(12px);
318
+ -webkit-backdrop-filter: blur(12px);
319
+
320
+ position: absolute;
321
+ z-index: 100;
322
+ bottom: 125%;
323
+ left: 50%;
324
+ transform: translateX(-50%) translateY(5px);
325
+ opacity: 0;
326
+ transition: opacity 0.2s ease, transform 0.2s ease;
327
+ pointer-events: none;
328
+ }
329
+
330
+ .tooltip-text::after {
331
+ content: "";
332
+ position: absolute;
333
+ top: 100%;
334
+ left: 50%;
335
+ margin-left: -5px;
336
+ border-width: 5px;
337
+ border-style: solid;
338
+ border-color: var(--border) transparent transparent transparent;
339
+ }
340
+
341
+ .tooltip-container:hover .tooltip-text {
342
+ visibility: visible;
343
+ opacity: 1;
344
+ transform: translateX(-50%) translateY(0);
345
+ }
@@ -0,0 +1,174 @@
1
+ import { useState, useRef } from 'react';
2
+ import { Settings, FolderOpen, Check, Trash2, Info } from 'lucide-react';
3
+ import './SettingsPanel.css';
4
+
5
+ export default function SettingsPanel({ outputDir, onSave }) {
6
+ const [dir, setDir] = useState(outputDir);
7
+ const [downloadLyrics, setDownloadLyrics] = useState(() => {
8
+ return localStorage.getItem('ytmd_download_lyrics') !== 'false';
9
+ });
10
+ const [isBrowsing, setIsBrowsing] = useState(false);
11
+ const [showSavedMsg, setShowSavedMsg] = useState(false);
12
+ const [isClearing, setIsClearing] = useState(false);
13
+ const [showClearedMsg, setShowClearedMsg] = useState(false);
14
+ const savedMsgTimeoutRef = useRef(null);
15
+
16
+ const triggerSavedIndicator = () => {
17
+ if (savedMsgTimeoutRef.current) {
18
+ clearTimeout(savedMsgTimeoutRef.current);
19
+ }
20
+ setShowSavedMsg(true);
21
+ savedMsgTimeoutRef.current = setTimeout(() => {
22
+ setShowSavedMsg(false);
23
+ }, 1500);
24
+ };
25
+
26
+ const handleBrowse = async () => {
27
+ setIsBrowsing(true);
28
+ try {
29
+ const res = await fetch('/api/browse');
30
+ if (res.ok) {
31
+ const data = await res.json();
32
+ if (data.path) {
33
+ setDir(data.path);
34
+ onSave(data.path);
35
+ triggerSavedIndicator();
36
+ }
37
+ }
38
+ } catch (err) {
39
+ console.error('Failed to browse for folder', err);
40
+ } finally {
41
+ setIsBrowsing(false);
42
+ }
43
+ };
44
+
45
+ const handleClearCache = async () => {
46
+ setIsClearing(true);
47
+ try {
48
+ const res = await fetch('/api/download/clear-cache', { method: 'POST' });
49
+ if (!res.ok) throw new Error('Failed to clear cache');
50
+ setShowClearedMsg(true);
51
+ setTimeout(() => {
52
+ setShowClearedMsg(false);
53
+ }, 3000);
54
+ } catch (err) {
55
+ alert(err.message);
56
+ } finally {
57
+ setIsClearing(false);
58
+ }
59
+ };
60
+
61
+ const handleInputChange = (e) => {
62
+ setDir(e.target.value);
63
+ };
64
+
65
+ const handleInputBlur = () => {
66
+ if (dir !== outputDir) {
67
+ onSave(dir);
68
+ triggerSavedIndicator();
69
+ }
70
+ };
71
+
72
+ const handleKeyDown = (e) => {
73
+ if (e.key === 'Enter') {
74
+ e.preventDefault();
75
+ e.target.blur();
76
+ }
77
+ };
78
+
79
+ return (
80
+ <div className="settings-tab-container">
81
+ <div className="settings-card liquid-glass" style={{ position: 'relative' }}>
82
+ <span className={`saved-success-msg ${showSavedMsg ? 'visible' : ''}`}>
83
+ <Check size={16} /> Auto-saved
84
+ </span>
85
+
86
+ <div className="settings-form">
87
+ <div className="form-group">
88
+ <label htmlFor="outputDir" className="label-with-info">
89
+ Download Directory
90
+ <div className="tooltip-container">
91
+ <Info size={14} className="info-icon" />
92
+ <span className="tooltip-text">Select where your downloaded FLAC / lossy files will be saved on your system.</span>
93
+ </div>
94
+ </label>
95
+ <div className="input-with-button">
96
+ <input
97
+ id="outputDir"
98
+ type="text"
99
+ value={dir}
100
+ onChange={handleInputChange}
101
+ onBlur={handleInputBlur}
102
+ onKeyDown={handleKeyDown}
103
+ placeholder="/Users/sayan/Desktop"
104
+ />
105
+ <button
106
+ type="button"
107
+ className="btn-browse"
108
+ onClick={handleBrowse}
109
+ disabled={isBrowsing}
110
+ title="Browse directories"
111
+ >
112
+ {isBrowsing ? '...' : <FolderOpen size={18} />}
113
+ </button>
114
+ </div>
115
+ </div>
116
+
117
+ <div className="form-group toggle-group">
118
+ <div className="toggle-row">
119
+ <span className="toggle-label-text label-with-info">
120
+ Download & Embed Lyrics
121
+ <div className="tooltip-container">
122
+ <Info size={14} className="info-icon" />
123
+ <span className="tooltip-text">Fetches lyrics from YouTube Music and embeds them into metadata tags inside the audio track.</span>
124
+ </div>
125
+ </span>
126
+ <label className="toggle-switch">
127
+ <input
128
+ id="downloadLyrics"
129
+ type="checkbox"
130
+ checked={downloadLyrics}
131
+ onChange={e => {
132
+ const val = e.target.checked;
133
+ setDownloadLyrics(val);
134
+ localStorage.setItem('ytmd_download_lyrics', val ? 'true' : 'false');
135
+ triggerSavedIndicator();
136
+ }}
137
+ />
138
+ <span className="toggle-slider"></span>
139
+ </label>
140
+ </div>
141
+ </div>
142
+
143
+ <div className="settings-divider" />
144
+
145
+ <div className="form-group cache-group">
146
+ <label className="label-with-info">
147
+ Cache & History
148
+ <div className="tooltip-container">
149
+ <Info size={14} className="info-icon" />
150
+ <span className="tooltip-text">Resets the local YouTube decipher cache (helps fix 403 Forbidden errors) and clears completed or failed items from download history.</span>
151
+ </div>
152
+ </label>
153
+ <div className="cache-action-row">
154
+ <button
155
+ type="button"
156
+ className="btn-danger"
157
+ onClick={handleClearCache}
158
+ disabled={isClearing}
159
+ title="Resets yt-dlp decipher caches and clears completed/failed items from memory"
160
+ >
161
+ <Trash2 size={16} /> {isClearing ? 'Clearing Cache...' : 'Clear Cache & History'}
162
+ </button>
163
+ {showClearedMsg && (
164
+ <span className="cleared-success-msg">
165
+ <Check size={16} /> Cache cleared!
166
+ </span>
167
+ )}
168
+ </div>
169
+ </div>
170
+ </div>
171
+ </div>
172
+ </div>
173
+ );
174
+ }
@@ -0,0 +1,107 @@
1
+ import { useState, useEffect, useCallback } from 'react';
2
+
3
+ export function useDownload() {
4
+ const [downloads, setDownloads] = useState(new Map());
5
+
6
+ // Connect to SSE stream
7
+ useEffect(() => {
8
+ const eventSource = new EventSource('/api/download/progress');
9
+
10
+ eventSource.onmessage = (event) => {
11
+ try {
12
+ const payload = JSON.parse(event.data);
13
+ if (payload.type === 'INITIAL') {
14
+ setDownloads(new Map(payload.data));
15
+ } else if (payload.type === 'DELTA') {
16
+ setDownloads(prev => {
17
+ const next = new Map(prev);
18
+ for (const [id, update] of payload.data) {
19
+ const existing = next.get(id) || {};
20
+ next.set(id, { ...existing, ...update });
21
+ }
22
+ return next;
23
+ });
24
+ }
25
+ } catch (err) {
26
+ console.error('Failed to parse SSE data', err);
27
+ }
28
+ };
29
+
30
+ eventSource.onerror = (err) => {
31
+ console.error('SSE Error:', err);
32
+ };
33
+
34
+ return () => {
35
+ eventSource.close();
36
+ };
37
+ }, []);
38
+
39
+ const startDownload = useCallback(async (song, outputDir) => {
40
+ try {
41
+ const downloadLyrics = localStorage.getItem('ytmd_download_lyrics') !== 'false';
42
+ const res = await fetch('/api/download', {
43
+ method: 'POST',
44
+ headers: {
45
+ 'Content-Type': 'application/json',
46
+ },
47
+ body: JSON.stringify({
48
+ videoId: song.videoId,
49
+ title: song.title,
50
+ artist: song.artist,
51
+ album: song.album,
52
+ thumbnail: song.thumbnail,
53
+ outputDir,
54
+ downloadLyrics
55
+ })
56
+ });
57
+
58
+ if (!res.ok) {
59
+ throw new Error('Failed to start download');
60
+ }
61
+
62
+ const data = await res.json();
63
+ return data.downloadId;
64
+ } catch (err) {
65
+ console.error('Download trigger error:', err);
66
+ alert(`Failed to start download: ${err.message}`);
67
+ }
68
+ }, []);
69
+
70
+ const startBulkDownload = useCallback(async (songs, outputDir) => {
71
+ try {
72
+ const downloadLyrics = localStorage.getItem('ytmd_download_lyrics') !== 'false';
73
+ const res = await fetch('/api/download/bulk', {
74
+ method: 'POST',
75
+ headers: { 'Content-Type': 'application/json' },
76
+ body: JSON.stringify({
77
+ songs: songs.map(s => ({
78
+ videoId: s.videoId,
79
+ title: s.title,
80
+ artist: s.artist,
81
+ album: s.album,
82
+ thumbnail: s.thumbnail
83
+ })),
84
+ outputDir,
85
+ downloadLyrics
86
+ })
87
+ });
88
+ if (!res.ok) {
89
+ const errorData = await res.json();
90
+ throw new Error(errorData.error || 'Failed to start bulk download');
91
+ }
92
+ } catch (err) {
93
+ console.error('Bulk download error:', err);
94
+ alert(`Failed to start bulk download: ${err.message}`);
95
+ }
96
+ }, []);
97
+
98
+ const clearQueue = useCallback(async () => {
99
+ try {
100
+ await fetch('/api/download/clear-queue', { method: 'POST' });
101
+ } catch (err) {
102
+ console.error('Failed to clear queue:', err);
103
+ }
104
+ }, []);
105
+
106
+ return { downloads, startDownload, startBulkDownload, clearQueue };
107
+ }