@involvex/youtube-music-cli 0.0.46 → 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 (118) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/dist/cli.js.map +1004 -0
  3. package/dist/source/hooks/usePlayer.d.ts +1 -0
  4. package/dist/source/services/player-state/player-state.service.d.ts +1 -0
  5. package/dist/source/stores/player.store.d.ts +1 -0
  6. package/dist/source/types/actions.d.ts +4 -0
  7. package/dist/source/types/player.types.d.ts +3 -2
  8. package/dist/source/utils/constants.d.ts +1 -0
  9. package/dist/source/utils/icons.d.ts +1 -0
  10. package/dist/youtube-music-cli +0 -0
  11. package/package.json +1 -1
  12. package/dist/eslint.config.js +0 -55
  13. package/dist/package.json +0 -120
  14. package/dist/scripts/build-cli.js +0 -46
  15. package/dist/source/app.js +0 -17
  16. package/dist/source/cli.js +0 -504
  17. package/dist/source/components/common/ErrorBoundary.js +0 -22
  18. package/dist/source/components/common/Help.js +0 -18
  19. package/dist/source/components/common/ShortcutsBar.js +0 -80
  20. package/dist/source/components/config/ConfigLayout.js +0 -84
  21. package/dist/source/components/config/KeybindingsLayout.js +0 -107
  22. package/dist/source/components/export/ExportLayout.js +0 -111
  23. package/dist/source/components/import/ImportLayout.js +0 -119
  24. package/dist/source/components/import/ImportProgress.js +0 -73
  25. package/dist/source/components/layouts/ExploreLayout.js +0 -72
  26. package/dist/source/components/layouts/HistoryLayout.js +0 -37
  27. package/dist/source/components/layouts/LyricsLayout.js +0 -89
  28. package/dist/source/components/layouts/MainLayout.js +0 -190
  29. package/dist/source/components/layouts/MiniPlayerLayout.js +0 -20
  30. package/dist/source/components/layouts/PlayerLayout.js +0 -9
  31. package/dist/source/components/layouts/PluginsLayout.js +0 -77
  32. package/dist/source/components/layouts/SearchLayout.js +0 -193
  33. package/dist/source/components/layouts/TrendingLayout.js +0 -59
  34. package/dist/source/components/player/NowPlaying.js +0 -45
  35. package/dist/source/components/player/PlayerControls.js +0 -83
  36. package/dist/source/components/player/ProgressBar.js +0 -19
  37. package/dist/source/components/player/QueueList.js +0 -36
  38. package/dist/source/components/player/Suggestions.js +0 -50
  39. package/dist/source/components/playlist/PlaylistList.js +0 -138
  40. package/dist/source/components/plugins/PluginInstallDialog.js +0 -41
  41. package/dist/source/components/plugins/PluginsAvailable.js +0 -55
  42. package/dist/source/components/plugins/PluginsList.js +0 -18
  43. package/dist/source/components/search/SearchBar.js +0 -55
  44. package/dist/source/components/search/SearchHistory.js +0 -35
  45. package/dist/source/components/search/SearchResults.js +0 -280
  46. package/dist/source/components/settings/Settings.js +0 -211
  47. package/dist/source/components/theme/ThemeSwitcher.js +0 -11
  48. package/dist/source/config/themes.config.js +0 -123
  49. package/dist/source/contexts/theme.context.js +0 -29
  50. package/dist/source/hooks/useKeyboard.js +0 -188
  51. package/dist/source/hooks/useKeyboardBlocker.js +0 -45
  52. package/dist/source/hooks/useNavigation.js +0 -5
  53. package/dist/source/hooks/usePlayer.js +0 -43
  54. package/dist/source/hooks/usePlaylist.js +0 -65
  55. package/dist/source/hooks/useSearch.js +0 -76
  56. package/dist/source/hooks/useSleepTimer.js +0 -48
  57. package/dist/source/hooks/useTerminalSize.js +0 -24
  58. package/dist/source/hooks/useTheme.js +0 -5
  59. package/dist/source/hooks/useYouTubeMusic.js +0 -112
  60. package/dist/source/main.js +0 -127
  61. package/dist/source/services/cache/cache.service.js +0 -67
  62. package/dist/source/services/completions/completions.service.js +0 -313
  63. package/dist/source/services/config/config.service.js +0 -191
  64. package/dist/source/services/discord/discord-rpc.service.js +0 -95
  65. package/dist/source/services/download/download.service.js +0 -350
  66. package/dist/source/services/export/export.service.js +0 -131
  67. package/dist/source/services/history/history.service.js +0 -83
  68. package/dist/source/services/import/import.service.js +0 -272
  69. package/dist/source/services/import/spotify.service.js +0 -171
  70. package/dist/source/services/import/track-matcher.service.js +0 -271
  71. package/dist/source/services/import/youtube-import.service.js +0 -84
  72. package/dist/source/services/logger/logger.service.js +0 -52
  73. package/dist/source/services/lyrics/lyrics.service.js +0 -93
  74. package/dist/source/services/mpris/mpris.service.js +0 -78
  75. package/dist/source/services/notification/notification.service.js +0 -57
  76. package/dist/source/services/player/dependency-check.service.js +0 -140
  77. package/dist/source/services/player/player.service.js +0 -478
  78. package/dist/source/services/player-state/player-state.service.js +0 -122
  79. package/dist/source/services/plugin/plugin-audio-api.js +0 -36
  80. package/dist/source/services/plugin/plugin-context.js +0 -256
  81. package/dist/source/services/plugin/plugin-hooks.service.js +0 -135
  82. package/dist/source/services/plugin/plugin-installer.service.js +0 -248
  83. package/dist/source/services/plugin/plugin-loader.service.js +0 -161
  84. package/dist/source/services/plugin/plugin-permissions.service.js +0 -194
  85. package/dist/source/services/plugin/plugin-registry.service.js +0 -215
  86. package/dist/source/services/plugin/plugin-ui-api.js +0 -46
  87. package/dist/source/services/plugin/plugin-updater.service.js +0 -206
  88. package/dist/source/services/scrobbling/scrobbling.service.js +0 -115
  89. package/dist/source/services/sleep-timer/sleep-timer.service.js +0 -45
  90. package/dist/source/services/version-check/version-check.service.js +0 -121
  91. package/dist/source/services/web/static-file.service.js +0 -185
  92. package/dist/source/services/web/web-server-manager.js +0 -506
  93. package/dist/source/services/web/web-streaming.service.js +0 -290
  94. package/dist/source/services/web/websocket.server.js +0 -267
  95. package/dist/source/services/youtube-music/api.js +0 -649
  96. package/dist/source/services/youtube-music/search.service.js +0 -38
  97. package/dist/source/stores/history.store.js +0 -64
  98. package/dist/source/stores/navigation.store.js +0 -90
  99. package/dist/source/stores/player.store.js +0 -724
  100. package/dist/source/stores/plugins.store.js +0 -177
  101. package/dist/source/types/actions.js +0 -1
  102. package/dist/source/types/cli.types.js +0 -1
  103. package/dist/source/types/config.types.js +0 -1
  104. package/dist/source/types/history.types.js +0 -1
  105. package/dist/source/types/import.types.js +0 -2
  106. package/dist/source/types/keyboard.types.js +0 -1
  107. package/dist/source/types/navigation.types.js +0 -1
  108. package/dist/source/types/player.types.js +0 -1
  109. package/dist/source/types/playlist.types.js +0 -1
  110. package/dist/source/types/plugin.types.js +0 -1
  111. package/dist/source/types/theme.types.js +0 -1
  112. package/dist/source/types/web.types.js +0 -2
  113. package/dist/source/types/youtube-music.types.js +0 -1
  114. package/dist/source/types/youtubei.types.js +0 -3
  115. package/dist/source/utils/constants.js +0 -134
  116. package/dist/source/utils/format.js +0 -24
  117. package/dist/source/utils/icons.js +0 -26
  118. 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
- }