@involvex/youtube-music-cli 0.0.47 → 0.0.48

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 (111) hide show
  1. package/CHANGELOG.md +2 -0
  2. package/dist/cli.js.map +3 -3
  3. package/dist/youtube-music-cli +0 -0
  4. package/package.json +1 -1
  5. package/dist/eslint.config.js +0 -55
  6. package/dist/package.json +0 -120
  7. package/dist/scripts/build-cli.js +0 -46
  8. package/dist/source/app.js +0 -17
  9. package/dist/source/cli.js +0 -504
  10. package/dist/source/components/common/ErrorBoundary.js +0 -22
  11. package/dist/source/components/common/Help.js +0 -18
  12. package/dist/source/components/common/ShortcutsBar.js +0 -89
  13. package/dist/source/components/config/ConfigLayout.js +0 -84
  14. package/dist/source/components/config/KeybindingsLayout.js +0 -107
  15. package/dist/source/components/export/ExportLayout.js +0 -111
  16. package/dist/source/components/import/ImportLayout.js +0 -119
  17. package/dist/source/components/import/ImportProgress.js +0 -73
  18. package/dist/source/components/layouts/ExploreLayout.js +0 -72
  19. package/dist/source/components/layouts/HistoryLayout.js +0 -37
  20. package/dist/source/components/layouts/LyricsLayout.js +0 -89
  21. package/dist/source/components/layouts/MainLayout.js +0 -190
  22. package/dist/source/components/layouts/MiniPlayerLayout.js +0 -20
  23. package/dist/source/components/layouts/PlayerLayout.js +0 -9
  24. package/dist/source/components/layouts/PluginsLayout.js +0 -77
  25. package/dist/source/components/layouts/SearchLayout.js +0 -193
  26. package/dist/source/components/layouts/TrendingLayout.js +0 -59
  27. package/dist/source/components/player/NowPlaying.js +0 -45
  28. package/dist/source/components/player/PlayerControls.js +0 -83
  29. package/dist/source/components/player/ProgressBar.js +0 -19
  30. package/dist/source/components/player/QueueList.js +0 -36
  31. package/dist/source/components/player/Suggestions.js +0 -50
  32. package/dist/source/components/playlist/PlaylistList.js +0 -138
  33. package/dist/source/components/plugins/PluginInstallDialog.js +0 -41
  34. package/dist/source/components/plugins/PluginsAvailable.js +0 -55
  35. package/dist/source/components/plugins/PluginsList.js +0 -18
  36. package/dist/source/components/search/SearchBar.js +0 -55
  37. package/dist/source/components/search/SearchHistory.js +0 -35
  38. package/dist/source/components/search/SearchResults.js +0 -280
  39. package/dist/source/components/settings/Settings.js +0 -211
  40. package/dist/source/components/theme/ThemeSwitcher.js +0 -11
  41. package/dist/source/config/themes.config.js +0 -123
  42. package/dist/source/contexts/theme.context.js +0 -29
  43. package/dist/source/hooks/useKeyboard.js +0 -188
  44. package/dist/source/hooks/useKeyboardBlocker.js +0 -45
  45. package/dist/source/hooks/useNavigation.js +0 -5
  46. package/dist/source/hooks/usePlayer.js +0 -43
  47. package/dist/source/hooks/usePlaylist.js +0 -65
  48. package/dist/source/hooks/useSearch.js +0 -76
  49. package/dist/source/hooks/useSleepTimer.js +0 -48
  50. package/dist/source/hooks/useTerminalSize.js +0 -24
  51. package/dist/source/hooks/useTheme.js +0 -5
  52. package/dist/source/hooks/useYouTubeMusic.js +0 -112
  53. package/dist/source/main.js +0 -127
  54. package/dist/source/services/cache/cache.service.js +0 -67
  55. package/dist/source/services/completions/completions.service.js +0 -313
  56. package/dist/source/services/config/config.service.js +0 -191
  57. package/dist/source/services/discord/discord-rpc.service.js +0 -95
  58. package/dist/source/services/download/download.service.js +0 -350
  59. package/dist/source/services/export/export.service.js +0 -131
  60. package/dist/source/services/history/history.service.js +0 -83
  61. package/dist/source/services/import/import.service.js +0 -272
  62. package/dist/source/services/import/spotify.service.js +0 -171
  63. package/dist/source/services/import/track-matcher.service.js +0 -271
  64. package/dist/source/services/import/youtube-import.service.js +0 -84
  65. package/dist/source/services/logger/logger.service.js +0 -52
  66. package/dist/source/services/lyrics/lyrics.service.js +0 -93
  67. package/dist/source/services/mpris/mpris.service.js +0 -78
  68. package/dist/source/services/notification/notification.service.js +0 -57
  69. package/dist/source/services/player/dependency-check.service.js +0 -140
  70. package/dist/source/services/player/player.service.js +0 -478
  71. package/dist/source/services/player-state/player-state.service.js +0 -123
  72. package/dist/source/services/plugin/plugin-audio-api.js +0 -36
  73. package/dist/source/services/plugin/plugin-context.js +0 -256
  74. package/dist/source/services/plugin/plugin-hooks.service.js +0 -135
  75. package/dist/source/services/plugin/plugin-installer.service.js +0 -248
  76. package/dist/source/services/plugin/plugin-loader.service.js +0 -161
  77. package/dist/source/services/plugin/plugin-permissions.service.js +0 -194
  78. package/dist/source/services/plugin/plugin-registry.service.js +0 -215
  79. package/dist/source/services/plugin/plugin-ui-api.js +0 -46
  80. package/dist/source/services/plugin/plugin-updater.service.js +0 -206
  81. package/dist/source/services/scrobbling/scrobbling.service.js +0 -115
  82. package/dist/source/services/sleep-timer/sleep-timer.service.js +0 -45
  83. package/dist/source/services/version-check/version-check.service.js +0 -121
  84. package/dist/source/services/web/static-file.service.js +0 -185
  85. package/dist/source/services/web/web-server-manager.js +0 -507
  86. package/dist/source/services/web/web-streaming.service.js +0 -292
  87. package/dist/source/services/web/websocket.server.js +0 -267
  88. package/dist/source/services/youtube-music/api.js +0 -649
  89. package/dist/source/services/youtube-music/search.service.js +0 -38
  90. package/dist/source/stores/history.store.js +0 -64
  91. package/dist/source/stores/navigation.store.js +0 -90
  92. package/dist/source/stores/player.store.js +0 -789
  93. package/dist/source/stores/plugins.store.js +0 -177
  94. package/dist/source/types/actions.js +0 -1
  95. package/dist/source/types/cli.types.js +0 -1
  96. package/dist/source/types/config.types.js +0 -1
  97. package/dist/source/types/history.types.js +0 -1
  98. package/dist/source/types/import.types.js +0 -2
  99. package/dist/source/types/keyboard.types.js +0 -1
  100. package/dist/source/types/navigation.types.js +0 -1
  101. package/dist/source/types/player.types.js +0 -1
  102. package/dist/source/types/playlist.types.js +0 -1
  103. package/dist/source/types/plugin.types.js +0 -1
  104. package/dist/source/types/theme.types.js +0 -1
  105. package/dist/source/types/web.types.js +0 -2
  106. package/dist/source/types/youtube-music.types.js +0 -1
  107. package/dist/source/types/youtubei.types.js +0 -3
  108. package/dist/source/utils/constants.js +0 -135
  109. package/dist/source/utils/format.js +0 -24
  110. package/dist/source/utils/icons.js +0 -28
  111. package/dist/source/utils/search-filters.js +0 -100
@@ -1,95 +0,0 @@
1
- // Discord Rich Presence service
2
- // Uses discord-rpc package if available; gracefully no-ops if Discord is not running
3
- import { logger } from "../logger/logger.service.js";
4
- export class DiscordRpcService {
5
- client = null;
6
- connected = false;
7
- enabled = false;
8
- setEnabled(enabled) {
9
- this.enabled = enabled;
10
- if (!enabled) {
11
- void this.disconnect();
12
- }
13
- }
14
- async connect() {
15
- if (!this.enabled || this.connected)
16
- return;
17
- try {
18
- // Dynamic import so missing package doesn't crash startup
19
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
20
- // @ts-ignore
21
- const rpc = await import('discord-rpc');
22
- const client = new rpc.Client({ transport: 'ipc' });
23
- await new Promise((resolve, reject) => {
24
- const timeout = setTimeout(() => {
25
- reject(new Error('Discord RPC connection timeout'));
26
- }, 5000);
27
- client.on('ready', () => {
28
- clearTimeout(timeout);
29
- this.connected = true;
30
- logger.info('DiscordRpcService', 'Connected to Discord');
31
- resolve();
32
- });
33
- client
34
- .login({ clientId: '1473580336964177960' }) // Public client ID for music players
35
- .catch(reject);
36
- });
37
- this.client = client;
38
- }
39
- catch (error) {
40
- logger.warn('DiscordRpcService', 'Could not connect to Discord', {
41
- error: error instanceof Error ? error.message : String(error),
42
- });
43
- }
44
- }
45
- async updateActivity(track) {
46
- if (!this.enabled || !this.connected || !this.client)
47
- return;
48
- try {
49
- const c = this.client;
50
- await c.setActivity({
51
- details: track.title,
52
- state: `by ${track.artist}`,
53
- startTimestamp: track.startTimestamp ?? Date.now(),
54
- largeImageKey: 'logo',
55
- largeImageText: 'YouTube Music CLI',
56
- instance: false,
57
- });
58
- }
59
- catch (error) {
60
- logger.warn('DiscordRpcService', 'Failed to update Discord activity', {
61
- error: error instanceof Error ? error.message : String(error),
62
- });
63
- }
64
- }
65
- async clearActivity() {
66
- if (!this.connected || !this.client)
67
- return;
68
- try {
69
- const c = this.client;
70
- await c.clearActivity();
71
- }
72
- catch {
73
- // Ignore
74
- }
75
- }
76
- async disconnect() {
77
- if (!this.client)
78
- return;
79
- try {
80
- const c = this.client;
81
- await c.destroy();
82
- }
83
- catch {
84
- // Ignore
85
- }
86
- this.client = null;
87
- this.connected = false;
88
- }
89
- }
90
- let instance = null;
91
- export const getDiscordRpcService = () => {
92
- if (!instance)
93
- instance = new DiscordRpcService();
94
- return instance;
95
- };
@@ -1,350 +0,0 @@
1
- import { existsSync, mkdirSync, unlinkSync, writeFileSync } from 'node:fs';
2
- import path from 'node:path';
3
- import { spawn } from 'node:child_process';
4
- import { getConfigService } from "../config/config.service.js";
5
- import { logger } from "../logger/logger.service.js";
6
- import { getMusicService } from "../youtube-music/api.js";
7
- class DownloadService {
8
- ffmpegChecked = false;
9
- ffmpegAvailable = false;
10
- activeDownload = false;
11
- config = getConfigService();
12
- musicService = getMusicService();
13
- getConfig() {
14
- return {
15
- enabled: this.config.get('downloadsEnabled') ?? false,
16
- directory: this.config.get('downloadDirectory') ?? '',
17
- format: (this.config.get('downloadFormat') ?? 'mp3'),
18
- };
19
- }
20
- async resolveSearchTarget(result) {
21
- if (result.type === 'song') {
22
- const track = result.data;
23
- return { name: track.title, tracks: [track] };
24
- }
25
- if (result.type === 'artist') {
26
- const artistName = 'name' in result.data ? result.data.name : '';
27
- if (!artistName) {
28
- throw new Error('Artist name is missing.');
29
- }
30
- const response = await this.musicService.search(artistName, {
31
- type: 'songs',
32
- limit: 25,
33
- });
34
- const tracks = response.results
35
- .filter(row => row.type === 'song')
36
- .map(row => row.data);
37
- return { name: artistName, tracks: this.uniqueTracks(tracks) };
38
- }
39
- if (result.type === 'playlist') {
40
- const playlistInfo = result.data;
41
- if (!playlistInfo.playlistId) {
42
- throw new Error('Playlist id is missing.');
43
- }
44
- const playlist = await this.musicService.getPlaylist(playlistInfo.playlistId);
45
- return {
46
- name: playlist.name || playlistInfo.name || 'playlist',
47
- tracks: this.uniqueTracks(playlist.tracks),
48
- };
49
- }
50
- throw new Error('Downloads are supported for songs, artists, and playlists.');
51
- }
52
- resolvePlaylistTarget(playlist) {
53
- return {
54
- name: playlist.name,
55
- tracks: this.uniqueTracks(playlist.tracks),
56
- };
57
- }
58
- async downloadTracks(tracks) {
59
- if (this.activeDownload) {
60
- throw new Error('A download is already in progress. Please wait for it to finish.');
61
- }
62
- const { directory, format } = this.getConfig();
63
- if (!directory) {
64
- throw new Error('No download directory configured.');
65
- }
66
- mkdirSync(directory, { recursive: true });
67
- await this.ensureFfmpeg();
68
- this.activeDownload = true;
69
- const result = {
70
- downloaded: 0,
71
- skipped: 0,
72
- failed: 0,
73
- errors: [],
74
- };
75
- try {
76
- for (const track of tracks) {
77
- const destination = this.getDestinationPath(track, directory, format);
78
- const tempSource = `${destination}.source`;
79
- const tempCover = `${destination}.cover.jpg`;
80
- try {
81
- logger.info('DownloadService', 'Starting track download', {
82
- videoId: track.videoId,
83
- title: track.title,
84
- });
85
- mkdirSync(path.dirname(destination), { recursive: true });
86
- if (existsSync(destination)) {
87
- result.skipped++;
88
- logger.debug('DownloadService', 'Skipping existing file', {
89
- destination,
90
- });
91
- continue;
92
- }
93
- try {
94
- const streamUrl = await this.musicService.getStreamUrl(track.videoId);
95
- const audioBuffer = await this.fetchAudio(streamUrl);
96
- writeFileSync(tempSource, audioBuffer);
97
- }
98
- catch (streamError) {
99
- logger.warn('DownloadService', 'Stream URL extraction failed, falling back to yt-dlp', {
100
- videoId: track.videoId,
101
- error: streamError instanceof Error
102
- ? streamError.message
103
- : String(streamError),
104
- });
105
- try {
106
- await this.recordViaYtDlp(track.videoId, tempSource);
107
- }
108
- catch (ytdlpError) {
109
- logger.warn('DownloadService', 'yt-dlp fallback failed, falling back to mpv recording', {
110
- videoId: track.videoId,
111
- error: ytdlpError instanceof Error
112
- ? ytdlpError.message
113
- : String(ytdlpError),
114
- });
115
- await this.recordViaMpv(track.videoId, tempSource);
116
- }
117
- }
118
- const hasCover = await this.downloadCoverArt(track.videoId, tempCover);
119
- await this.convertAudio(tempSource, destination, format, track, hasCover ? tempCover : undefined);
120
- result.downloaded++;
121
- logger.info('DownloadService', 'Track download complete', {
122
- videoId: track.videoId,
123
- destination,
124
- });
125
- }
126
- catch (error) {
127
- result.failed++;
128
- const message = error instanceof Error ? error.message : 'Unknown download failure';
129
- result.errors.push(message);
130
- logger.error('DownloadService', 'Track download failed', {
131
- videoId: track.videoId,
132
- title: track.title,
133
- error: message,
134
- });
135
- }
136
- finally {
137
- if (existsSync(tempSource)) {
138
- unlinkSync(tempSource);
139
- }
140
- if (existsSync(tempCover)) {
141
- unlinkSync(tempCover);
142
- }
143
- }
144
- }
145
- return result;
146
- }
147
- finally {
148
- this.activeDownload = false;
149
- }
150
- }
151
- uniqueTracks(tracks) {
152
- const seen = new Set();
153
- const unique = [];
154
- for (const track of tracks) {
155
- if (!track?.videoId || seen.has(track.videoId))
156
- continue;
157
- seen.add(track.videoId);
158
- unique.push(track);
159
- }
160
- return unique;
161
- }
162
- getDestinationPath(track, directory, format) {
163
- const artist = track.artists[0]?.name ?? 'Unknown Artist';
164
- const album = track.album?.name ?? 'Singles';
165
- const artistDir = this.sanitizeFilename(artist) || 'Unknown Artist';
166
- const albumDir = this.sanitizeFilename(album) || 'Singles';
167
- const fileName = this.sanitizeFilename(track.title) || track.videoId;
168
- return path.join(directory, artistDir, albumDir, `${fileName}.${format}`);
169
- }
170
- sanitizeFilename(value) {
171
- return value.replace(/[<>:"/\\|?*\u0000-\u001F]/g, '_').trim();
172
- }
173
- async fetchAudio(url) {
174
- const response = await fetch(url);
175
- if (!response.ok) {
176
- throw new Error(`Failed to fetch audio stream (${response.status}).`);
177
- }
178
- const audio = await response.arrayBuffer();
179
- return Buffer.from(audio);
180
- }
181
- async ensureFfmpeg() {
182
- if (this.ffmpegChecked) {
183
- if (!this.ffmpegAvailable) {
184
- throw new Error('ffmpeg is required for downloads. Install ffmpeg and ensure it is available in PATH.');
185
- }
186
- return;
187
- }
188
- this.ffmpegChecked = true;
189
- try {
190
- await this.runFfmpeg(['-version']);
191
- this.ffmpegAvailable = true;
192
- }
193
- catch {
194
- this.ffmpegAvailable = false;
195
- throw new Error('ffmpeg is required for downloads. Install ffmpeg and ensure it is available in PATH.');
196
- }
197
- }
198
- async convertAudio(sourcePath, destinationPath, format, track, coverPath) {
199
- const metadataArgs = this.buildMetadataArgs(track);
200
- if (format === 'mp3') {
201
- const args = ['-y', '-i', sourcePath];
202
- if (coverPath) {
203
- args.push('-i', coverPath, '-map', '0:a:0', '-map', '1:v:0');
204
- }
205
- else {
206
- args.push('-map', '0:a:0', '-vn');
207
- }
208
- args.push('-codec:a', 'libmp3lame', '-q:a', '2', ...metadataArgs);
209
- if (coverPath) {
210
- args.push('-codec:v', 'mjpeg', '-disposition:v:0', 'attached_pic', '-metadata:s:v', 'title=Album cover', '-metadata:s:v', 'comment=Cover (front)');
211
- }
212
- args.push(destinationPath);
213
- await this.runFfmpeg(args);
214
- return;
215
- }
216
- const args = ['-y', '-i', sourcePath];
217
- if (coverPath) {
218
- args.push('-i', coverPath, '-map', '0:a:0', '-map', '1:v:0');
219
- }
220
- else {
221
- args.push('-map', '0:a:0', '-vn');
222
- }
223
- args.push('-codec:a', 'aac', '-b:a', '192k', ...metadataArgs);
224
- if (coverPath) {
225
- args.push('-codec:v', 'mjpeg', '-disposition:v:0', 'attached_pic');
226
- }
227
- args.push(destinationPath);
228
- await this.runFfmpeg(args);
229
- }
230
- buildMetadataArgs(track) {
231
- const artist = track.artists
232
- .map(row => row.name)
233
- .filter(Boolean)
234
- .join(', ') || 'Unknown Artist';
235
- const album = track.album?.name || 'Singles';
236
- return [
237
- '-metadata',
238
- `title=${track.title}`,
239
- '-metadata',
240
- `artist=${artist}`,
241
- '-metadata',
242
- `album=${album}`,
243
- ];
244
- }
245
- async runFfmpeg(args) {
246
- await new Promise((resolve, reject) => {
247
- const process = spawn('ffmpeg', args, { windowsHide: true });
248
- let stderr = '';
249
- process.stderr.on('data', chunk => {
250
- stderr += String(chunk);
251
- });
252
- process.on('error', reject);
253
- process.on('exit', code => {
254
- if (code === 0) {
255
- resolve();
256
- return;
257
- }
258
- reject(new Error(stderr.trim() || `ffmpeg exited with code ${code}`));
259
- });
260
- });
261
- }
262
- async recordViaYtDlp(videoId, outputPath) {
263
- const watchUrl = `https://www.youtube.com/watch?v=${videoId}`;
264
- await new Promise((resolve, reject) => {
265
- const process = spawn('yt-dlp', [
266
- '--no-playlist',
267
- '--quiet',
268
- '--no-warnings',
269
- '--js-runtimes',
270
- 'node',
271
- '-f',
272
- 'bestaudio',
273
- '--output',
274
- outputPath,
275
- watchUrl,
276
- ], { windowsHide: true });
277
- let stderr = '';
278
- let stdout = '';
279
- process.stderr.on('data', chunk => {
280
- stderr += String(chunk);
281
- });
282
- process.stdout.on('data', chunk => {
283
- stdout += String(chunk);
284
- });
285
- process.on('error', reject);
286
- process.on('exit', code => {
287
- if (code === 0 && existsSync(outputPath)) {
288
- resolve();
289
- return;
290
- }
291
- reject(new Error((stderr || stdout).trim() ||
292
- `yt-dlp exited with code ${code} and no output file`));
293
- });
294
- });
295
- }
296
- async downloadCoverArt(videoId, outputPath) {
297
- const candidates = [
298
- `https://i.ytimg.com/vi/${videoId}/maxresdefault.jpg`,
299
- `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`,
300
- ];
301
- for (const url of candidates) {
302
- try {
303
- const response = await fetch(url);
304
- if (!response.ok)
305
- continue;
306
- const image = Buffer.from(await response.arrayBuffer());
307
- if (image.length === 0)
308
- continue;
309
- writeFileSync(outputPath, image);
310
- return true;
311
- }
312
- catch {
313
- continue;
314
- }
315
- }
316
- return false;
317
- }
318
- async recordViaMpv(videoId, outputPath) {
319
- const watchUrl = `https://www.youtube.com/watch?v=${videoId}`;
320
- await new Promise((resolve, reject) => {
321
- const process = spawn('mpv', [
322
- watchUrl,
323
- '--no-video',
324
- '--ao=null',
325
- '--ytdl=yes',
326
- '--really-quiet',
327
- `--stream-record=${outputPath}`,
328
- ], { windowsHide: true });
329
- let stderr = '';
330
- process.stderr.on('data', chunk => {
331
- stderr += String(chunk);
332
- });
333
- process.on('error', reject);
334
- process.on('exit', code => {
335
- if (code === 0 && existsSync(outputPath)) {
336
- resolve();
337
- return;
338
- }
339
- reject(new Error(stderr.trim() || `mpv exited with code ${code} and no output file`));
340
- });
341
- });
342
- }
343
- }
344
- let downloadServiceInstance = null;
345
- export function getDownloadService() {
346
- if (!downloadServiceInstance) {
347
- downloadServiceInstance = new DownloadService();
348
- }
349
- return downloadServiceInstance;
350
- }
@@ -1,131 +0,0 @@
1
- // Playlist export service for JSON and M3U8 formats
2
- import { mkdirSync, writeFileSync, existsSync } from 'node:fs';
3
- import { CONFIG_DIR } from "../../utils/constants.js";
4
- import { logger } from "../logger/logger.service.js";
5
- class ExportService {
6
- static instance;
7
- DEFAULT_EXPORT_DIR = `${CONFIG_DIR}/exports`;
8
- constructor() { }
9
- static getInstance() {
10
- if (!ExportService.instance) {
11
- ExportService.instance = new ExportService();
12
- }
13
- return ExportService.instance;
14
- }
15
- /**
16
- * Get the export directory, create if it doesn't exist
17
- */
18
- getExportDir(customDir) {
19
- const exportDir = customDir || this.DEFAULT_EXPORT_DIR;
20
- if (!existsSync(exportDir)) {
21
- try {
22
- mkdirSync(exportDir, { recursive: true });
23
- logger.info('ExportService', 'Created export directory', { exportDir });
24
- }
25
- catch (error) {
26
- logger.error('ExportService', 'Failed to create export directory', {
27
- error: error instanceof Error ? error.message : String(error),
28
- });
29
- throw new Error(`Failed to create export directory: ${exportDir}`);
30
- }
31
- }
32
- return exportDir;
33
- }
34
- /**
35
- * Sanitize filename for safe file system usage
36
- */
37
- sanitizeFilename(name) {
38
- // Remove or replace characters that are unsafe for filenames
39
- return name
40
- .replace(/[<>:"/\\|?*]/g, '') // Remove unsafe chars
41
- .replace(/\s+/g, '_') // Replace spaces with underscores
42
- .substring(0, 200); // Limit length
43
- }
44
- /**
45
- * Generate M3U8 format content for a playlist
46
- */
47
- generateM3U8(playlist) {
48
- const lines = ['#EXTM3U', ''];
49
- for (const track of playlist.tracks) {
50
- if (track.artists && track.artists.length > 0) {
51
- const artistNames = track.artists.map(a => a.name).join(', ');
52
- const duration = track.duration
53
- ? Math.round(track.duration / 1000)
54
- : -1;
55
- lines.push(`#EXTINF:${duration},${artistNames} - ${track.title}`);
56
- }
57
- else {
58
- lines.push(`#EXTINF:-1,${track.title}`);
59
- }
60
- // Use the videoId to generate YouTube URL
61
- if (track.videoId) {
62
- lines.push(`https://www.youtube.com/watch?v=${track.videoId}`);
63
- }
64
- }
65
- return lines.join('\n');
66
- }
67
- /**
68
- * Export a single playlist to the specified format(s)
69
- */
70
- async exportPlaylist(playlist, options) {
71
- try {
72
- logger.info('ExportService', 'Exporting playlist', {
73
- playlist: playlist.name,
74
- format: options.format,
75
- });
76
- const exportDir = this.getExportDir(options.outputDir);
77
- const sanitizedName = this.sanitizeFilename(playlist.name);
78
- const files = [];
79
- // Export to JSON
80
- if (options.format === 'json' || options.format === 'both') {
81
- const jsonPath = `${exportDir}/${sanitizedName}.json`;
82
- const jsonContent = JSON.stringify(playlist, null, 2);
83
- writeFileSync(jsonPath, jsonContent, 'utf-8');
84
- files.push(jsonPath);
85
- logger.info('ExportService', 'Exported to JSON', { path: jsonPath });
86
- }
87
- // Export to M3U8
88
- if (options.format === 'm3u8' || options.format === 'both') {
89
- const m3u8Path = `${exportDir}/${sanitizedName}.m3u8`;
90
- const m3u8Content = this.generateM3U8(playlist);
91
- writeFileSync(m3u8Path, m3u8Content, 'utf-8');
92
- files.push(m3u8Path);
93
- logger.info('ExportService', 'Exported to M3U8', { path: m3u8Path });
94
- }
95
- return {
96
- playlistName: playlist.name,
97
- format: options.format,
98
- files,
99
- success: true,
100
- };
101
- }
102
- catch (error) {
103
- logger.error('ExportService', 'Failed to export playlist', {
104
- error: error instanceof Error ? error.message : String(error),
105
- });
106
- return {
107
- playlistName: playlist.name,
108
- format: options.format,
109
- files: [],
110
- success: false,
111
- error: error instanceof Error ? error.message : String(error),
112
- };
113
- }
114
- }
115
- /**
116
- * Export multiple playlists to the specified format(s)
117
- */
118
- async exportAllPlaylists(playlists, options) {
119
- logger.info('ExportService', 'Exporting all playlists', {
120
- count: playlists.length,
121
- format: options.format,
122
- });
123
- const results = [];
124
- for (const playlist of playlists) {
125
- const result = await this.exportPlaylist(playlist, options);
126
- results.push(result);
127
- }
128
- return results;
129
- }
130
- }
131
- export const getExportService = () => ExportService.getInstance();
@@ -1,83 +0,0 @@
1
- import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
- import { existsSync } from 'node:fs';
3
- import { join } from 'node:path';
4
- import { CONFIG_DIR } from "../../utils/constants.js";
5
- import { logger } from "../logger/logger.service.js";
6
- const HISTORY_FILE = join(CONFIG_DIR, 'history.json');
7
- const SCHEMA_VERSION = 1;
8
- const defaultHistory = {
9
- schemaVersion: SCHEMA_VERSION,
10
- entries: [],
11
- lastUpdated: new Date().toISOString(),
12
- };
13
- export async function saveHistory(entries) {
14
- try {
15
- if (!existsSync(CONFIG_DIR)) {
16
- await mkdir(CONFIG_DIR, { recursive: true });
17
- }
18
- const stateToSave = {
19
- ...defaultHistory,
20
- entries,
21
- lastUpdated: new Date().toISOString(),
22
- };
23
- const tempFile = `${HISTORY_FILE}.tmp`;
24
- await writeFile(tempFile, JSON.stringify(stateToSave, null, 2), 'utf8');
25
- if (process.platform === 'win32' && existsSync(HISTORY_FILE)) {
26
- await import('node:fs/promises').then(fs => fs.unlink(HISTORY_FILE));
27
- }
28
- await import('node:fs/promises').then(fs => fs.rename(tempFile, HISTORY_FILE));
29
- logger.debug('HistoryService', 'Saved listening history', {
30
- count: entries.length,
31
- });
32
- }
33
- catch (error) {
34
- logger.error('HistoryService', 'Failed to save listening history', {
35
- error: error instanceof Error ? error.message : String(error),
36
- });
37
- }
38
- }
39
- export async function loadHistory() {
40
- try {
41
- if (!existsSync(HISTORY_FILE)) {
42
- logger.debug('HistoryService', 'No history file found');
43
- return [];
44
- }
45
- const data = await readFile(HISTORY_FILE, 'utf8');
46
- const persisted = JSON.parse(data);
47
- if (persisted.schemaVersion !== SCHEMA_VERSION) {
48
- logger.warn('HistoryService', 'Schema version mismatch', {
49
- expected: SCHEMA_VERSION,
50
- found: persisted.schemaVersion,
51
- });
52
- return [];
53
- }
54
- if (!Array.isArray(persisted.entries)) {
55
- logger.warn('HistoryService', 'Invalid history format, resetting');
56
- return [];
57
- }
58
- logger.info('HistoryService', 'Loaded listening history', {
59
- count: persisted.entries.length,
60
- lastUpdated: persisted.lastUpdated,
61
- });
62
- return persisted.entries;
63
- }
64
- catch (error) {
65
- logger.error('HistoryService', 'Failed to load listening history', {
66
- error: error instanceof Error ? error.message : String(error),
67
- });
68
- return [];
69
- }
70
- }
71
- export async function clearHistory() {
72
- try {
73
- if (existsSync(HISTORY_FILE)) {
74
- await import('node:fs/promises').then(fs => fs.unlink(HISTORY_FILE));
75
- logger.info('HistoryService', 'Cleared listening history');
76
- }
77
- }
78
- catch (error) {
79
- logger.error('HistoryService', 'Failed to clear listening history', {
80
- error: error instanceof Error ? error.message : String(error),
81
- });
82
- }
83
- }