@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.
- package/CHANGELOG.md +2 -0
- package/dist/cli.js.map +3 -3
- package/dist/youtube-music-cli +0 -0
- package/package.json +1 -1
- package/dist/eslint.config.js +0 -55
- package/dist/package.json +0 -120
- package/dist/scripts/build-cli.js +0 -46
- package/dist/source/app.js +0 -17
- package/dist/source/cli.js +0 -504
- package/dist/source/components/common/ErrorBoundary.js +0 -22
- package/dist/source/components/common/Help.js +0 -18
- package/dist/source/components/common/ShortcutsBar.js +0 -89
- package/dist/source/components/config/ConfigLayout.js +0 -84
- package/dist/source/components/config/KeybindingsLayout.js +0 -107
- package/dist/source/components/export/ExportLayout.js +0 -111
- package/dist/source/components/import/ImportLayout.js +0 -119
- package/dist/source/components/import/ImportProgress.js +0 -73
- package/dist/source/components/layouts/ExploreLayout.js +0 -72
- package/dist/source/components/layouts/HistoryLayout.js +0 -37
- package/dist/source/components/layouts/LyricsLayout.js +0 -89
- package/dist/source/components/layouts/MainLayout.js +0 -190
- package/dist/source/components/layouts/MiniPlayerLayout.js +0 -20
- package/dist/source/components/layouts/PlayerLayout.js +0 -9
- package/dist/source/components/layouts/PluginsLayout.js +0 -77
- package/dist/source/components/layouts/SearchLayout.js +0 -193
- package/dist/source/components/layouts/TrendingLayout.js +0 -59
- package/dist/source/components/player/NowPlaying.js +0 -45
- package/dist/source/components/player/PlayerControls.js +0 -83
- package/dist/source/components/player/ProgressBar.js +0 -19
- package/dist/source/components/player/QueueList.js +0 -36
- package/dist/source/components/player/Suggestions.js +0 -50
- package/dist/source/components/playlist/PlaylistList.js +0 -138
- package/dist/source/components/plugins/PluginInstallDialog.js +0 -41
- package/dist/source/components/plugins/PluginsAvailable.js +0 -55
- package/dist/source/components/plugins/PluginsList.js +0 -18
- package/dist/source/components/search/SearchBar.js +0 -55
- package/dist/source/components/search/SearchHistory.js +0 -35
- package/dist/source/components/search/SearchResults.js +0 -280
- package/dist/source/components/settings/Settings.js +0 -211
- package/dist/source/components/theme/ThemeSwitcher.js +0 -11
- package/dist/source/config/themes.config.js +0 -123
- package/dist/source/contexts/theme.context.js +0 -29
- package/dist/source/hooks/useKeyboard.js +0 -188
- package/dist/source/hooks/useKeyboardBlocker.js +0 -45
- package/dist/source/hooks/useNavigation.js +0 -5
- package/dist/source/hooks/usePlayer.js +0 -43
- package/dist/source/hooks/usePlaylist.js +0 -65
- package/dist/source/hooks/useSearch.js +0 -76
- package/dist/source/hooks/useSleepTimer.js +0 -48
- package/dist/source/hooks/useTerminalSize.js +0 -24
- package/dist/source/hooks/useTheme.js +0 -5
- package/dist/source/hooks/useYouTubeMusic.js +0 -112
- package/dist/source/main.js +0 -127
- package/dist/source/services/cache/cache.service.js +0 -67
- package/dist/source/services/completions/completions.service.js +0 -313
- package/dist/source/services/config/config.service.js +0 -191
- package/dist/source/services/discord/discord-rpc.service.js +0 -95
- package/dist/source/services/download/download.service.js +0 -350
- package/dist/source/services/export/export.service.js +0 -131
- package/dist/source/services/history/history.service.js +0 -83
- package/dist/source/services/import/import.service.js +0 -272
- package/dist/source/services/import/spotify.service.js +0 -171
- package/dist/source/services/import/track-matcher.service.js +0 -271
- package/dist/source/services/import/youtube-import.service.js +0 -84
- package/dist/source/services/logger/logger.service.js +0 -52
- package/dist/source/services/lyrics/lyrics.service.js +0 -93
- package/dist/source/services/mpris/mpris.service.js +0 -78
- package/dist/source/services/notification/notification.service.js +0 -57
- package/dist/source/services/player/dependency-check.service.js +0 -140
- package/dist/source/services/player/player.service.js +0 -478
- package/dist/source/services/player-state/player-state.service.js +0 -123
- package/dist/source/services/plugin/plugin-audio-api.js +0 -36
- package/dist/source/services/plugin/plugin-context.js +0 -256
- package/dist/source/services/plugin/plugin-hooks.service.js +0 -135
- package/dist/source/services/plugin/plugin-installer.service.js +0 -248
- package/dist/source/services/plugin/plugin-loader.service.js +0 -161
- package/dist/source/services/plugin/plugin-permissions.service.js +0 -194
- package/dist/source/services/plugin/plugin-registry.service.js +0 -215
- package/dist/source/services/plugin/plugin-ui-api.js +0 -46
- package/dist/source/services/plugin/plugin-updater.service.js +0 -206
- package/dist/source/services/scrobbling/scrobbling.service.js +0 -115
- package/dist/source/services/sleep-timer/sleep-timer.service.js +0 -45
- package/dist/source/services/version-check/version-check.service.js +0 -121
- package/dist/source/services/web/static-file.service.js +0 -185
- package/dist/source/services/web/web-server-manager.js +0 -507
- package/dist/source/services/web/web-streaming.service.js +0 -292
- package/dist/source/services/web/websocket.server.js +0 -267
- package/dist/source/services/youtube-music/api.js +0 -649
- package/dist/source/services/youtube-music/search.service.js +0 -38
- package/dist/source/stores/history.store.js +0 -64
- package/dist/source/stores/navigation.store.js +0 -90
- package/dist/source/stores/player.store.js +0 -789
- package/dist/source/stores/plugins.store.js +0 -177
- package/dist/source/types/actions.js +0 -1
- package/dist/source/types/cli.types.js +0 -1
- package/dist/source/types/config.types.js +0 -1
- package/dist/source/types/history.types.js +0 -1
- package/dist/source/types/import.types.js +0 -2
- package/dist/source/types/keyboard.types.js +0 -1
- package/dist/source/types/navigation.types.js +0 -1
- package/dist/source/types/player.types.js +0 -1
- package/dist/source/types/playlist.types.js +0 -1
- package/dist/source/types/plugin.types.js +0 -1
- package/dist/source/types/theme.types.js +0 -1
- package/dist/source/types/web.types.js +0 -2
- package/dist/source/types/youtube-music.types.js +0 -1
- package/dist/source/types/youtubei.types.js +0 -3
- package/dist/source/utils/constants.js +0 -135
- package/dist/source/utils/format.js +0 -24
- package/dist/source/utils/icons.js +0 -28
- 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
|
-
}
|