@involvex/youtube-music-cli 0.0.18 → 0.0.19

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 (36) hide show
  1. package/CHANGELOG.md +2 -0
  2. package/dist/source/cli.js +123 -8
  3. package/dist/source/components/import/ImportLayout.d.ts +1 -0
  4. package/dist/source/components/import/ImportLayout.js +119 -0
  5. package/dist/source/components/import/ImportProgress.d.ts +6 -0
  6. package/dist/source/components/import/ImportProgress.js +73 -0
  7. package/dist/source/components/layouts/MainLayout.js +7 -0
  8. package/dist/source/components/settings/Settings.js +6 -2
  9. package/dist/source/services/config/config.service.js +10 -0
  10. package/dist/source/services/import/import.service.d.ts +44 -0
  11. package/dist/source/services/import/import.service.js +272 -0
  12. package/dist/source/services/import/spotify.service.d.ts +40 -0
  13. package/dist/source/services/import/spotify.service.js +171 -0
  14. package/dist/source/services/import/track-matcher.service.d.ts +60 -0
  15. package/dist/source/services/import/track-matcher.service.js +271 -0
  16. package/dist/source/services/import/youtube-import.service.d.ts +17 -0
  17. package/dist/source/services/import/youtube-import.service.js +84 -0
  18. package/dist/source/services/web/static-file.service.d.ts +31 -0
  19. package/dist/source/services/web/static-file.service.js +174 -0
  20. package/dist/source/services/web/web-server-manager.d.ts +66 -0
  21. package/dist/source/services/web/web-server-manager.js +219 -0
  22. package/dist/source/services/web/web-streaming.service.d.ts +88 -0
  23. package/dist/source/services/web/web-streaming.service.js +290 -0
  24. package/dist/source/services/web/websocket.server.d.ts +58 -0
  25. package/dist/source/services/web/websocket.server.js +253 -0
  26. package/dist/source/stores/player.store.js +24 -0
  27. package/dist/source/types/cli.types.d.ts +8 -0
  28. package/dist/source/types/config.types.d.ts +2 -0
  29. package/dist/source/types/import.types.d.ts +72 -0
  30. package/dist/source/types/import.types.js +2 -0
  31. package/dist/source/types/web.types.d.ts +89 -0
  32. package/dist/source/types/web.types.js +2 -0
  33. package/dist/source/utils/constants.d.ts +1 -0
  34. package/dist/source/utils/constants.js +1 -0
  35. package/dist/youtube-music-cli.exe +0 -0
  36. package/package.json +8 -3
@@ -0,0 +1,272 @@
1
+ import { getYouTubeImportService } from "./youtube-import.service.js";
2
+ import { getSpotifyImportService } from "./spotify.service.js";
3
+ import { getTrackMatcherService } from "./track-matcher.service.js";
4
+ import { getConfigService } from "../config/config.service.js";
5
+ import { logger } from "../logger/logger.service.js";
6
+ // Helper to get track name from either Spotify or YouTube track
7
+ function getTrackName(track) {
8
+ return track.name || track.title;
9
+ }
10
+ class ImportService {
11
+ progressCallbacks = new Set();
12
+ currentImport = null;
13
+ /**
14
+ * Subscribe to import progress updates
15
+ */
16
+ onProgress(callback) {
17
+ this.progressCallbacks.add(callback);
18
+ return () => {
19
+ this.progressCallbacks.delete(callback);
20
+ };
21
+ }
22
+ /**
23
+ * Emit progress to all subscribers
24
+ */
25
+ emitProgress(progress) {
26
+ for (const callback of this.progressCallbacks) {
27
+ callback(progress);
28
+ }
29
+ }
30
+ /**
31
+ * Create a unique playlist ID
32
+ */
33
+ generatePlaylistId() {
34
+ return `import_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
35
+ }
36
+ /**
37
+ * Save imported playlist to config
38
+ */
39
+ savePlaylist(name, tracks) {
40
+ const configService = getConfigService();
41
+ const playlist = {
42
+ playlistId: this.generatePlaylistId(),
43
+ name,
44
+ tracks,
45
+ };
46
+ // Get existing playlists and add the new one
47
+ const existingPlaylists = configService.get('playlists') ?? [];
48
+ const updatedPlaylists = [...existingPlaylists, playlist];
49
+ configService.set('playlists', updatedPlaylists);
50
+ logger.info('ImportService', 'Playlist saved to config', {
51
+ playlistId: playlist.playlistId,
52
+ trackCount: tracks.length,
53
+ });
54
+ return playlist.playlistId;
55
+ }
56
+ /**
57
+ * Import a playlist from Spotify or YouTube
58
+ */
59
+ async importPlaylist(source, urlOrId, customName, signal) {
60
+ const startTime = Date.now();
61
+ this.currentImport = { source, url: urlOrId, startTime };
62
+ // Initial progress
63
+ this.emitProgress({
64
+ status: 'fetching',
65
+ current: 0,
66
+ total: 0,
67
+ message: `Fetching ${source} playlist...`,
68
+ });
69
+ let originalTracks = [];
70
+ let playlistName = customName ?? `Imported ${source} playlist`;
71
+ try {
72
+ // Step 1: Fetch playlist
73
+ if (source === 'youtube') {
74
+ const youtubeService = getYouTubeImportService();
75
+ const playlist = await youtubeService.fetchPlaylist(urlOrId);
76
+ if (!playlist) {
77
+ throw new Error('Failed to fetch YouTube playlist. Please check the URL/ID.');
78
+ }
79
+ originalTracks = playlist.tracks;
80
+ playlistName = customName ?? playlist.name;
81
+ }
82
+ else {
83
+ const spotifyService = getSpotifyImportService();
84
+ const playlist = await spotifyService.fetchPlaylist(urlOrId);
85
+ if (!playlist) {
86
+ throw new Error('Failed to fetch Spotify playlist. It may be private or invalid.');
87
+ }
88
+ if (playlist.tracks.length === 0) {
89
+ throw new Error('No tracks found. The playlist may be private or require authentication.');
90
+ }
91
+ originalTracks = playlist.tracks;
92
+ playlistName = customName ?? playlist.name;
93
+ }
94
+ const total = originalTracks.length;
95
+ // Check for abort
96
+ if (signal?.aborted) {
97
+ this.emitProgress({
98
+ status: 'cancelled',
99
+ current: 0,
100
+ total,
101
+ message: 'Import cancelled',
102
+ });
103
+ throw new Error('Import cancelled');
104
+ }
105
+ // Step 2: Match tracks
106
+ this.emitProgress({
107
+ status: 'matching',
108
+ current: 0,
109
+ total,
110
+ message: 'Matching tracks...',
111
+ });
112
+ const trackMatcher = getTrackMatcherService();
113
+ const matchedTracks = [];
114
+ const errors = [];
115
+ let matched = 0;
116
+ let failed = 0;
117
+ for (let i = 0; i < originalTracks.length; i++) {
118
+ // Check for abort
119
+ if (signal?.aborted) {
120
+ this.emitProgress({
121
+ status: 'cancelled',
122
+ current: i,
123
+ total,
124
+ currentTrack: originalTracks[i]
125
+ ? getTrackName(originalTracks[i])
126
+ : undefined,
127
+ message: 'Import cancelled',
128
+ });
129
+ throw new Error('Import cancelled');
130
+ }
131
+ const originalTrack = originalTracks[i];
132
+ const trackName = getTrackName(originalTrack);
133
+ this.emitProgress({
134
+ status: 'matching',
135
+ current: i,
136
+ total,
137
+ currentTrack: trackName,
138
+ message: `Matching "${trackName}"...`,
139
+ });
140
+ const match = await trackMatcher.findMatch(originalTrack);
141
+ if (match.matchedTrack) {
142
+ matchedTracks.push(match.matchedTrack);
143
+ matched++;
144
+ }
145
+ else {
146
+ failed++;
147
+ const errorMsg = match.error
148
+ ? `${trackName}: ${match.error}`
149
+ : `No match found for "${trackName}"`;
150
+ errors.push(errorMsg);
151
+ }
152
+ // Throttle progress updates (every 5 tracks or at the end)
153
+ if (i % 5 === 0 || i === total - 1) {
154
+ this.emitProgress({
155
+ status: 'matching',
156
+ current: i + 1,
157
+ total,
158
+ currentTrack: trackName,
159
+ message: `Matched ${matched}/${total} tracks`,
160
+ });
161
+ }
162
+ }
163
+ // Step 3: Create playlist
164
+ this.emitProgress({
165
+ status: 'creating',
166
+ current: total,
167
+ total,
168
+ message: 'Creating playlist...',
169
+ });
170
+ const playlistId = this.savePlaylist(playlistName, matchedTracks);
171
+ // Final progress
172
+ this.emitProgress({
173
+ status: 'completed',
174
+ current: total,
175
+ total,
176
+ message: `Import completed: ${matched} tracks matched`,
177
+ });
178
+ const result = {
179
+ playlistId,
180
+ playlistName,
181
+ source,
182
+ total,
183
+ matched,
184
+ failed,
185
+ matches: [], // Could be populated if needed for detailed results
186
+ errors,
187
+ duration: Date.now() - startTime,
188
+ };
189
+ logger.info('ImportService', 'Import completed', {
190
+ playlistId,
191
+ playlistName,
192
+ source,
193
+ total,
194
+ matched,
195
+ failed,
196
+ duration: result.duration,
197
+ });
198
+ return result;
199
+ }
200
+ catch (error) {
201
+ const message = error instanceof Error ? error.message : String(error);
202
+ logger.error('ImportService', 'Import failed', {
203
+ source,
204
+ url: urlOrId,
205
+ error: message,
206
+ });
207
+ if (message === 'Import cancelled') {
208
+ throw error;
209
+ }
210
+ this.emitProgress({
211
+ status: 'failed',
212
+ current: 0,
213
+ total: originalTracks.length,
214
+ message: `Import failed: ${message}`,
215
+ });
216
+ throw error;
217
+ }
218
+ finally {
219
+ this.currentImport = null;
220
+ }
221
+ }
222
+ /**
223
+ * Validate a playlist URL/ID before importing
224
+ */
225
+ async validatePlaylist(source, urlOrId) {
226
+ try {
227
+ if (source === 'youtube') {
228
+ const service = getYouTubeImportService();
229
+ return await service.validatePlaylist(urlOrId);
230
+ }
231
+ else {
232
+ const service = getSpotifyImportService();
233
+ return await service.validatePlaylist(urlOrId);
234
+ }
235
+ }
236
+ catch {
237
+ return false;
238
+ }
239
+ }
240
+ /**
241
+ * Get current import status
242
+ */
243
+ getCurrentImport() {
244
+ if (!this.currentImport)
245
+ return null;
246
+ return {
247
+ source: this.currentImport.source,
248
+ url: this.currentImport.url,
249
+ elapsed: Date.now() - this.currentImport.startTime,
250
+ };
251
+ }
252
+ /**
253
+ * Cancel the current import
254
+ */
255
+ cancelImport() {
256
+ this.currentImport = null;
257
+ this.emitProgress({
258
+ status: 'cancelled',
259
+ current: 0,
260
+ total: 0,
261
+ message: 'Import cancelled',
262
+ });
263
+ }
264
+ }
265
+ // Singleton instance
266
+ let importServiceInstance = null;
267
+ export function getImportService() {
268
+ if (!importServiceInstance) {
269
+ importServiceInstance = new ImportService();
270
+ }
271
+ return importServiceInstance;
272
+ }
@@ -0,0 +1,40 @@
1
+ import type { SpotifyPlaylist } from '../../types/import.types.ts';
2
+ declare class SpotifyImportService {
3
+ private readonly OEMBED_URL;
4
+ /**
5
+ * Extract playlist ID from various Spotify URL formats
6
+ */
7
+ extractPlaylistId(input: string): string | null;
8
+ /**
9
+ * Build a Spotify playlist URL from ID
10
+ */
11
+ buildPlaylistUrl(playlistId: string): string;
12
+ /**
13
+ * Fetch playlist metadata using Spotify oEmbed API
14
+ * This works for public playlists without authentication
15
+ */
16
+ fetchPlaylistMetadata(urlOrId: string): Promise<{
17
+ title: string;
18
+ url: string;
19
+ } | null>;
20
+ /**
21
+ * Fetch playlist tracks by scraping the Spotify Web API
22
+ * Note: This method has limitations and may not work for private playlists
23
+ */
24
+ fetchPlaylistTracks(playlistId: string): Promise<SpotifyPlaylist | null>;
25
+ /**
26
+ * Create a partial playlist when only metadata is available
27
+ * This is used when authentication is required but we have basic info
28
+ */
29
+ private createPartialPlaylist;
30
+ /**
31
+ * Fetch a Spotify playlist (with graceful degradation)
32
+ */
33
+ fetchPlaylist(urlOrId: string): Promise<SpotifyPlaylist | null>;
34
+ /**
35
+ * Validate if a playlist is accessible
36
+ */
37
+ validatePlaylist(urlOrId: string): Promise<boolean>;
38
+ }
39
+ export declare function getSpotifyImportService(): SpotifyImportService;
40
+ export {};
@@ -0,0 +1,171 @@
1
+ import { logger } from "../logger/logger.service.js";
2
+ class SpotifyImportService {
3
+ OEMBED_URL = 'https://open.spotify.com/oembed';
4
+ /**
5
+ * Extract playlist ID from various Spotify URL formats
6
+ */
7
+ extractPlaylistId(input) {
8
+ // Direct playlist ID (base62 string, typically 22 characters)
9
+ if (/^[A-Za-z0-9]{22}$/.test(input)) {
10
+ return input;
11
+ }
12
+ // spotify:playlist:ID format
13
+ const uriMatch = input.match(/spotify:playlist:([A-Za-z0-9]+)/);
14
+ if (uriMatch) {
15
+ return uriMatch[1] ?? null;
16
+ }
17
+ // open.spotify.com/playlist/ID format
18
+ const urlMatch = input.match(/open\.spotify\.com\/playlist\/([A-Za-z0-9]+)/);
19
+ if (urlMatch) {
20
+ return urlMatch[1] ?? null;
21
+ }
22
+ return null;
23
+ }
24
+ /**
25
+ * Build a Spotify playlist URL from ID
26
+ */
27
+ buildPlaylistUrl(playlistId) {
28
+ return `https://open.spotify.com/playlist/${playlistId}`;
29
+ }
30
+ /**
31
+ * Fetch playlist metadata using Spotify oEmbed API
32
+ * This works for public playlists without authentication
33
+ */
34
+ async fetchPlaylistMetadata(urlOrId) {
35
+ const playlistId = this.extractPlaylistId(urlOrId);
36
+ if (!playlistId) {
37
+ return null;
38
+ }
39
+ const playlistUrl = this.buildPlaylistUrl(playlistId);
40
+ const oembedUrl = `${this.OEMBED_URL}?url=${encodeURIComponent(playlistUrl)}`;
41
+ try {
42
+ logger.debug('SpotifyImportService', 'Fetching oEmbed metadata', {
43
+ playlistId,
44
+ });
45
+ const response = await fetch(oembedUrl);
46
+ if (!response.ok) {
47
+ logger.warn('SpotifyImportService', 'oEmbed request failed', {
48
+ status: response.status,
49
+ playlistId,
50
+ });
51
+ return null;
52
+ }
53
+ const data = (await response.json());
54
+ return {
55
+ title: data.title || 'Unknown Playlist',
56
+ url: playlistUrl,
57
+ };
58
+ }
59
+ catch (error) {
60
+ logger.error('SpotifyImportService', 'oEmbed fetch failed', {
61
+ playlistId,
62
+ error: error instanceof Error ? error.message : String(error),
63
+ });
64
+ return null;
65
+ }
66
+ }
67
+ /**
68
+ * Fetch playlist tracks by scraping the Spotify Web API
69
+ * Note: This method has limitations and may not work for private playlists
70
+ */
71
+ async fetchPlaylistTracks(playlistId) {
72
+ const metadata = await this.fetchPlaylistMetadata(playlistId);
73
+ if (!metadata) {
74
+ return null;
75
+ }
76
+ try {
77
+ // Use Spotify's Web API endpoint for playlist tracks (no auth required for public playlists)
78
+ const apiUrl = `https://api.spotify.com/v1/playlists/${playlistId}/tracks?limit=100`;
79
+ logger.debug('SpotifyImportService', 'Fetching tracks from API', {
80
+ playlistId,
81
+ });
82
+ const response = await fetch(apiUrl, {
83
+ headers: {
84
+ Accept: 'application/json',
85
+ },
86
+ });
87
+ // API may require auth for some playlists
88
+ if (!response.ok) {
89
+ if (response.status === 401 || response.status === 403) {
90
+ logger.warn('SpotifyImportService', 'Playlist requires authentication', {
91
+ playlistId,
92
+ status: response.status,
93
+ });
94
+ return this.createPartialPlaylist(playlistId, metadata);
95
+ }
96
+ return null;
97
+ }
98
+ const data = (await response.json());
99
+ const tracks = data.items
100
+ ?.filter(item => item.track)
101
+ .map(item => ({
102
+ id: item.track.id ?? '',
103
+ name: item.track.name ?? 'Unknown Track',
104
+ artists: item.track.artists?.map(a => a.name ?? 'Unknown Artist') ?? [],
105
+ album: item.track.album?.name,
106
+ duration: Math.round((item.track.duration_ms ?? 0) / 1000),
107
+ })) ?? [];
108
+ return {
109
+ id: playlistId,
110
+ name: metadata.title,
111
+ tracks,
112
+ isPublic: true,
113
+ url: metadata.url,
114
+ };
115
+ }
116
+ catch (error) {
117
+ logger.error('SpotifyImportService', 'Failed to fetch playlist', {
118
+ playlistId,
119
+ error: error instanceof Error ? error.message : String(error),
120
+ });
121
+ return null;
122
+ }
123
+ }
124
+ /**
125
+ * Create a partial playlist when only metadata is available
126
+ * This is used when authentication is required but we have basic info
127
+ */
128
+ createPartialPlaylist(playlistId, metadata) {
129
+ logger.info('SpotifyImportService', 'Creating partial playlist (auth required)', {
130
+ playlistId,
131
+ });
132
+ return {
133
+ id: playlistId,
134
+ name: metadata.title,
135
+ tracks: [],
136
+ isPublic: false,
137
+ url: metadata.url,
138
+ };
139
+ }
140
+ /**
141
+ * Fetch a Spotify playlist (with graceful degradation)
142
+ */
143
+ async fetchPlaylist(urlOrId) {
144
+ const playlistId = this.extractPlaylistId(urlOrId);
145
+ if (!playlistId) {
146
+ logger.warn('SpotifyImportService', 'Invalid Spotify playlist URL or ID', {
147
+ input: urlOrId,
148
+ });
149
+ return null;
150
+ }
151
+ logger.info('SpotifyImportService', 'Fetching Spotify playlist', {
152
+ playlistId,
153
+ });
154
+ return this.fetchPlaylistTracks(playlistId);
155
+ }
156
+ /**
157
+ * Validate if a playlist is accessible
158
+ */
159
+ async validatePlaylist(urlOrId) {
160
+ const playlist = await this.fetchPlaylist(urlOrId);
161
+ return playlist !== null;
162
+ }
163
+ }
164
+ // Singleton instance
165
+ let spotifyImportServiceInstance = null;
166
+ export function getSpotifyImportService() {
167
+ if (!spotifyImportServiceInstance) {
168
+ spotifyImportServiceInstance = new SpotifyImportService();
169
+ }
170
+ return spotifyImportServiceInstance;
171
+ }
@@ -0,0 +1,60 @@
1
+ import type { Track } from '../../types/youtube-music.types.ts';
2
+ import type { SpotifyTrack, YouTubeTrack, TrackMatch, MatchConfidence } from '../../types/import.types.ts';
3
+ declare class TrackMatcherService {
4
+ private readonly musicService;
5
+ private searchCache;
6
+ private matchCache;
7
+ /**
8
+ * Build a search query from track metadata
9
+ */
10
+ buildSearchQuery(track: SpotifyTrack | YouTubeTrack): string;
11
+ /**
12
+ * Get track name from either type
13
+ */
14
+ getTrackName(track: SpotifyTrack | YouTubeTrack): string;
15
+ /**
16
+ * Calculate string similarity using Levenshtein distance
17
+ */
18
+ calculateSimilarity(str1: string, str2: string): number;
19
+ /**
20
+ * Calculate Levenshtein distance between two strings
21
+ */
22
+ levenshteinDistance(str1: string, str2: string): number;
23
+ /**
24
+ * Check if artists match (any overlap in artist lists)
25
+ */
26
+ artistsMatch(trackArtists: string[], candidateArtists: string[]): boolean;
27
+ /**
28
+ * Calculate duration proximity score (0-1)
29
+ */
30
+ durationScore(originalDuration: number, candidateDuration: number): number;
31
+ /**
32
+ * Score a track match based on multiple factors
33
+ */
34
+ scoreMatch(original: SpotifyTrack | YouTubeTrack, candidate: Track): number;
35
+ /**
36
+ * Determine confidence level from score
37
+ */
38
+ getConfidence(score: number): MatchConfidence;
39
+ /**
40
+ * Search YouTube Music for a track
41
+ */
42
+ searchTrack(track: SpotifyTrack | YouTubeTrack): Promise<Track[]>;
43
+ /**
44
+ * Find the best matching track on YouTube Music
45
+ */
46
+ findMatch(original: SpotifyTrack | YouTubeTrack): Promise<TrackMatch>;
47
+ /**
48
+ * Clear all caches
49
+ */
50
+ clearCache(): void;
51
+ /**
52
+ * Get cache statistics
53
+ */
54
+ getCacheStats(): {
55
+ searchCache: number;
56
+ matchCache: number;
57
+ };
58
+ }
59
+ export declare function getTrackMatcherService(): TrackMatcherService;
60
+ export {};