@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.
- package/CHANGELOG.md +4 -0
- package/dist/cli.js.map +1004 -0
- package/dist/source/hooks/usePlayer.d.ts +1 -0
- package/dist/source/services/player-state/player-state.service.d.ts +1 -0
- package/dist/source/stores/player.store.d.ts +1 -0
- package/dist/source/types/actions.d.ts +4 -0
- package/dist/source/types/player.types.d.ts +3 -2
- package/dist/source/utils/constants.d.ts +1 -0
- package/dist/source/utils/icons.d.ts +1 -0
- 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 -80
- 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 -122
- 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 -506
- package/dist/source/services/web/web-streaming.service.js +0 -290
- 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 -724
- 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 -134
- package/dist/source/utils/format.js +0 -24
- package/dist/source/utils/icons.js +0 -26
- package/dist/source/utils/search-filters.js +0 -100
|
@@ -1,271 +0,0 @@
|
|
|
1
|
-
import { getMusicService } from "../youtube-music/api.js";
|
|
2
|
-
import { logger } from "../logger/logger.service.js";
|
|
3
|
-
class TrackMatcherService {
|
|
4
|
-
musicService = getMusicService();
|
|
5
|
-
searchCache = new Map();
|
|
6
|
-
matchCache = new Map();
|
|
7
|
-
/**
|
|
8
|
-
* Build a search query from track metadata
|
|
9
|
-
*/
|
|
10
|
-
buildSearchQuery(track) {
|
|
11
|
-
// Combine artist and title for best results
|
|
12
|
-
const artists = track.artists.slice(0, 2).join(', '); // Use up to 2 artists
|
|
13
|
-
const name = track.name || track.title;
|
|
14
|
-
return `${artists} ${name}`.trim();
|
|
15
|
-
}
|
|
16
|
-
/**
|
|
17
|
-
* Get track name from either type
|
|
18
|
-
*/
|
|
19
|
-
getTrackName(track) {
|
|
20
|
-
return track.name || track.title;
|
|
21
|
-
}
|
|
22
|
-
/**
|
|
23
|
-
* Calculate string similarity using Levenshtein distance
|
|
24
|
-
*/
|
|
25
|
-
calculateSimilarity(str1, str2) {
|
|
26
|
-
const s1 = str1.toLowerCase().normalize();
|
|
27
|
-
const s2 = str2.toLowerCase().normalize();
|
|
28
|
-
// Exact match
|
|
29
|
-
if (s1 === s2)
|
|
30
|
-
return 1;
|
|
31
|
-
// Check if one contains the other
|
|
32
|
-
if (s1.includes(s2) || s2.includes(s1))
|
|
33
|
-
return 0.9;
|
|
34
|
-
// Simple Levenshtein-based similarity
|
|
35
|
-
const longer = s1.length > s2.length ? s1 : s2;
|
|
36
|
-
const shorter = s1.length > s2.length ? s2 : s1;
|
|
37
|
-
if (longer.length === 0)
|
|
38
|
-
return 1;
|
|
39
|
-
const editDistance = this.levenshteinDistance(longer, shorter);
|
|
40
|
-
return (longer.length - editDistance) / longer.length;
|
|
41
|
-
}
|
|
42
|
-
/**
|
|
43
|
-
* Calculate Levenshtein distance between two strings
|
|
44
|
-
*/
|
|
45
|
-
levenshteinDistance(str1, str2) {
|
|
46
|
-
const matrix = [];
|
|
47
|
-
for (let i = 0; i <= str2.length; i++) {
|
|
48
|
-
matrix[i] = [i];
|
|
49
|
-
}
|
|
50
|
-
for (let j = 0; j <= str1.length; j++) {
|
|
51
|
-
if (matrix[0]) {
|
|
52
|
-
matrix[0][j] = j;
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
for (let i = 1; i <= str2.length; i++) {
|
|
56
|
-
const row = matrix[i];
|
|
57
|
-
if (!row)
|
|
58
|
-
continue;
|
|
59
|
-
const prevRow = matrix[i - 1];
|
|
60
|
-
if (!prevRow)
|
|
61
|
-
continue;
|
|
62
|
-
for (let j = 1; j <= str1.length; j++) {
|
|
63
|
-
if (str2.charAt(i - 1) === str1.charAt(j - 1)) {
|
|
64
|
-
row[j] = prevRow[j - 1] ?? 0;
|
|
65
|
-
}
|
|
66
|
-
else {
|
|
67
|
-
row[j] = Math.min((prevRow[j - 1] ?? 0) + 1, // substitution
|
|
68
|
-
(row[j - 1] ?? 0) + 1, // insertion
|
|
69
|
-
(prevRow[j] ?? 0) + 1);
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
const lastRow = matrix[str2.length];
|
|
74
|
-
return lastRow?.[str1.length] ?? 0;
|
|
75
|
-
}
|
|
76
|
-
/**
|
|
77
|
-
* Check if artists match (any overlap in artist lists)
|
|
78
|
-
*/
|
|
79
|
-
artistsMatch(trackArtists, candidateArtists) {
|
|
80
|
-
const normalizedTrackArtists = trackArtists
|
|
81
|
-
.map(a => a.toLowerCase().trim())
|
|
82
|
-
.filter(a => a.length > 0);
|
|
83
|
-
// candidateArtists are strings directly from the track
|
|
84
|
-
const normalizedCandidateArtists = candidateArtists
|
|
85
|
-
.map(a => a.toLowerCase().trim())
|
|
86
|
-
.filter(a => a.length > 0);
|
|
87
|
-
// Check for any artist name overlap
|
|
88
|
-
return normalizedTrackArtists.some(trackArtist => normalizedCandidateArtists.some(candidateArtist => candidateArtist.includes(trackArtist) ||
|
|
89
|
-
trackArtist.includes(candidateArtist)));
|
|
90
|
-
}
|
|
91
|
-
/**
|
|
92
|
-
* Calculate duration proximity score (0-1)
|
|
93
|
-
*/
|
|
94
|
-
durationScore(originalDuration, candidateDuration) {
|
|
95
|
-
if (!originalDuration || !candidateDuration)
|
|
96
|
-
return 0.5; // Neutral if either is missing
|
|
97
|
-
const diff = Math.abs(originalDuration - candidateDuration);
|
|
98
|
-
const maxDiff = Math.max(originalDuration, candidateDuration) * 0.3; // 30% tolerance
|
|
99
|
-
if (diff === 0)
|
|
100
|
-
return 1;
|
|
101
|
-
if (diff <= maxDiff)
|
|
102
|
-
return 1 - diff / maxDiff;
|
|
103
|
-
return 0;
|
|
104
|
-
}
|
|
105
|
-
/**
|
|
106
|
-
* Score a track match based on multiple factors
|
|
107
|
-
*/
|
|
108
|
-
scoreMatch(original, candidate) {
|
|
109
|
-
let score = 0;
|
|
110
|
-
const weights = {
|
|
111
|
-
title: 0.5,
|
|
112
|
-
artist: 0.3,
|
|
113
|
-
duration: 0.2,
|
|
114
|
-
};
|
|
115
|
-
// Title similarity
|
|
116
|
-
const titleSimilarity = this.calculateSimilarity(this.getTrackName(original), candidate.title);
|
|
117
|
-
score += titleSimilarity * weights.title;
|
|
118
|
-
// Artist match
|
|
119
|
-
const artistMatch = this.artistsMatch(original.artists, candidate.artists.map(a => a.name))
|
|
120
|
-
? 1
|
|
121
|
-
: 0;
|
|
122
|
-
score += artistMatch * weights.artist;
|
|
123
|
-
// Duration proximity
|
|
124
|
-
const durationScore = this.durationScore(original.duration, candidate.duration ?? 0);
|
|
125
|
-
score += durationScore * weights.duration;
|
|
126
|
-
return score;
|
|
127
|
-
}
|
|
128
|
-
/**
|
|
129
|
-
* Determine confidence level from score
|
|
130
|
-
*/
|
|
131
|
-
getConfidence(score) {
|
|
132
|
-
if (score >= 0.85)
|
|
133
|
-
return 'high';
|
|
134
|
-
if (score >= 0.7)
|
|
135
|
-
return 'medium';
|
|
136
|
-
if (score >= 0.5)
|
|
137
|
-
return 'low';
|
|
138
|
-
return 'none';
|
|
139
|
-
}
|
|
140
|
-
/**
|
|
141
|
-
* Search YouTube Music for a track
|
|
142
|
-
*/
|
|
143
|
-
async searchTrack(track) {
|
|
144
|
-
const query = this.buildSearchQuery(track);
|
|
145
|
-
const cacheKey = `track:${query}`;
|
|
146
|
-
// Check cache first
|
|
147
|
-
const cached = this.searchCache.get(cacheKey);
|
|
148
|
-
if (cached) {
|
|
149
|
-
logger.debug('TrackMatcherService', 'Using cached search results', {
|
|
150
|
-
query,
|
|
151
|
-
});
|
|
152
|
-
return cached;
|
|
153
|
-
}
|
|
154
|
-
try {
|
|
155
|
-
logger.debug('TrackMatcherService', 'Searching for track', { query });
|
|
156
|
-
const response = await this.musicService.search(query, {
|
|
157
|
-
type: 'songs',
|
|
158
|
-
limit: 10,
|
|
159
|
-
});
|
|
160
|
-
const results = response.results
|
|
161
|
-
.filter(r => r.type === 'song')
|
|
162
|
-
.map(r => r.data);
|
|
163
|
-
// Cache results
|
|
164
|
-
this.searchCache.set(cacheKey, results);
|
|
165
|
-
return results;
|
|
166
|
-
}
|
|
167
|
-
catch (error) {
|
|
168
|
-
logger.error('TrackMatcherService', 'Track search failed', {
|
|
169
|
-
query,
|
|
170
|
-
error: error instanceof Error ? error.message : String(error),
|
|
171
|
-
});
|
|
172
|
-
return [];
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
/**
|
|
176
|
-
* Find the best matching track on YouTube Music
|
|
177
|
-
*/
|
|
178
|
-
async findMatch(original) {
|
|
179
|
-
// Create a unique key for this track
|
|
180
|
-
const trackKey = `${original.artists[0] ?? ''}-${this.getTrackName(original)}-${original.duration}`;
|
|
181
|
-
const cached = this.matchCache.get(trackKey);
|
|
182
|
-
if (cached) {
|
|
183
|
-
logger.debug('TrackMatcherService', 'Using cached match', {
|
|
184
|
-
track: this.getTrackName(original),
|
|
185
|
-
});
|
|
186
|
-
return cached;
|
|
187
|
-
}
|
|
188
|
-
try {
|
|
189
|
-
const candidates = await this.searchTrack(original);
|
|
190
|
-
if (candidates.length === 0) {
|
|
191
|
-
const noMatch = {
|
|
192
|
-
originalTrack: original,
|
|
193
|
-
matchedTrack: null,
|
|
194
|
-
confidence: 'none',
|
|
195
|
-
};
|
|
196
|
-
this.matchCache.set(trackKey, noMatch);
|
|
197
|
-
return noMatch;
|
|
198
|
-
}
|
|
199
|
-
// Score all candidates
|
|
200
|
-
const scoredCandidates = candidates.map(candidate => ({
|
|
201
|
-
...candidate,
|
|
202
|
-
score: this.scoreMatch(original, candidate),
|
|
203
|
-
}));
|
|
204
|
-
// Sort by score descending
|
|
205
|
-
scoredCandidates.sort((a, b) => b.score - a.score);
|
|
206
|
-
const best = scoredCandidates[0];
|
|
207
|
-
if (!best) {
|
|
208
|
-
const noMatch = {
|
|
209
|
-
originalTrack: original,
|
|
210
|
-
matchedTrack: null,
|
|
211
|
-
confidence: 'none',
|
|
212
|
-
};
|
|
213
|
-
this.matchCache.set(trackKey, noMatch);
|
|
214
|
-
return noMatch;
|
|
215
|
-
}
|
|
216
|
-
const { score, ...matchedTrack } = best;
|
|
217
|
-
const confidence = this.getConfidence(score);
|
|
218
|
-
const match = {
|
|
219
|
-
originalTrack: original,
|
|
220
|
-
matchedTrack: matchedTrack,
|
|
221
|
-
confidence,
|
|
222
|
-
};
|
|
223
|
-
logger.debug('TrackMatcherService', 'Track matched', {
|
|
224
|
-
original: this.getTrackName(original),
|
|
225
|
-
matched: matchedTrack.title,
|
|
226
|
-
confidence,
|
|
227
|
-
score,
|
|
228
|
-
});
|
|
229
|
-
this.matchCache.set(trackKey, match);
|
|
230
|
-
return match;
|
|
231
|
-
}
|
|
232
|
-
catch (error) {
|
|
233
|
-
logger.error('TrackMatcherService', 'Match finding failed', {
|
|
234
|
-
track: this.getTrackName(original),
|
|
235
|
-
error: error instanceof Error ? error.message : String(error),
|
|
236
|
-
});
|
|
237
|
-
const errorMatch = {
|
|
238
|
-
originalTrack: original,
|
|
239
|
-
matchedTrack: null,
|
|
240
|
-
confidence: 'none',
|
|
241
|
-
error: error instanceof Error ? error.message : String(error),
|
|
242
|
-
};
|
|
243
|
-
return errorMatch;
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
/**
|
|
247
|
-
* Clear all caches
|
|
248
|
-
*/
|
|
249
|
-
clearCache() {
|
|
250
|
-
this.searchCache.clear();
|
|
251
|
-
this.matchCache.clear();
|
|
252
|
-
logger.debug('TrackMatcherService', 'Caches cleared');
|
|
253
|
-
}
|
|
254
|
-
/**
|
|
255
|
-
* Get cache statistics
|
|
256
|
-
*/
|
|
257
|
-
getCacheStats() {
|
|
258
|
-
return {
|
|
259
|
-
searchCache: this.searchCache.size,
|
|
260
|
-
matchCache: this.matchCache.size,
|
|
261
|
-
};
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
// Singleton instance
|
|
265
|
-
let trackMatcherServiceInstance = null;
|
|
266
|
-
export function getTrackMatcherService() {
|
|
267
|
-
if (!trackMatcherServiceInstance) {
|
|
268
|
-
trackMatcherServiceInstance = new TrackMatcherService();
|
|
269
|
-
}
|
|
270
|
-
return trackMatcherServiceInstance;
|
|
271
|
-
}
|
|
@@ -1,84 +0,0 @@
|
|
|
1
|
-
import { getMusicService } from "../youtube-music/api.js";
|
|
2
|
-
import { logger } from "../logger/logger.service.js";
|
|
3
|
-
class YouTubeImportService {
|
|
4
|
-
/**
|
|
5
|
-
* Extract playlist ID from various YouTube URL formats
|
|
6
|
-
*/
|
|
7
|
-
extractPlaylistId(input) {
|
|
8
|
-
// Direct playlist ID
|
|
9
|
-
if (/^[-_A-Za-z0-9]{10,}$/.test(input)) {
|
|
10
|
-
return input;
|
|
11
|
-
}
|
|
12
|
-
// youtube.com/watch?v=...&list=...
|
|
13
|
-
const watchMatch = input.match(/[?&]list=([-_A-Za-z0-9]+)/);
|
|
14
|
-
if (watchMatch) {
|
|
15
|
-
return watchMatch[1] ?? null;
|
|
16
|
-
}
|
|
17
|
-
// youtube.com/playlist?list=...
|
|
18
|
-
const playlistMatch = input.match(/[?&]list=([-_A-Za-z0-9]+)/);
|
|
19
|
-
if (playlistMatch) {
|
|
20
|
-
return playlistMatch[1] ?? null;
|
|
21
|
-
}
|
|
22
|
-
return null;
|
|
23
|
-
}
|
|
24
|
-
/**
|
|
25
|
-
* Fetch a YouTube playlist and normalize to import format
|
|
26
|
-
*/
|
|
27
|
-
async fetchPlaylist(urlOrId) {
|
|
28
|
-
const playlistId = this.extractPlaylistId(urlOrId);
|
|
29
|
-
if (!playlistId) {
|
|
30
|
-
logger.warn('YouTubeImportService', 'Invalid YouTube playlist URL or ID', {
|
|
31
|
-
input: urlOrId,
|
|
32
|
-
});
|
|
33
|
-
return null;
|
|
34
|
-
}
|
|
35
|
-
try {
|
|
36
|
-
logger.info('YouTubeImportService', 'Fetching YouTube playlist', {
|
|
37
|
-
playlistId,
|
|
38
|
-
});
|
|
39
|
-
const musicService = getMusicService();
|
|
40
|
-
const playlist = await musicService.getPlaylist(playlistId);
|
|
41
|
-
// Normalize to YouTubePlaylist import format
|
|
42
|
-
const normalized = {
|
|
43
|
-
id: playlist.playlistId,
|
|
44
|
-
name: playlist.name,
|
|
45
|
-
tracks: playlist.tracks.map(track => ({
|
|
46
|
-
id: track.videoId,
|
|
47
|
-
title: track.title,
|
|
48
|
-
name: track.title,
|
|
49
|
-
artists: track.artists.map(a => a.name),
|
|
50
|
-
album: track.album?.name,
|
|
51
|
-
duration: track.duration ?? 0,
|
|
52
|
-
})),
|
|
53
|
-
url: `https://www.youtube.com/playlist?list=${playlist.playlistId}`,
|
|
54
|
-
};
|
|
55
|
-
logger.info('YouTubeImportService', 'Successfully fetched playlist', {
|
|
56
|
-
playlistId,
|
|
57
|
-
trackCount: normalized.tracks.length,
|
|
58
|
-
});
|
|
59
|
-
return normalized;
|
|
60
|
-
}
|
|
61
|
-
catch (error) {
|
|
62
|
-
logger.error('YouTubeImportService', 'Failed to fetch playlist', {
|
|
63
|
-
playlistId,
|
|
64
|
-
error: error instanceof Error ? error.message : String(error),
|
|
65
|
-
});
|
|
66
|
-
return null;
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
/**
|
|
70
|
-
* Validate if a playlist is accessible (not private/unavailable)
|
|
71
|
-
*/
|
|
72
|
-
async validatePlaylist(urlOrId) {
|
|
73
|
-
const playlist = await this.fetchPlaylist(urlOrId);
|
|
74
|
-
return playlist !== null && playlist.tracks.length > 0;
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
// Singleton instance
|
|
78
|
-
let youtubeImportServiceInstance = null;
|
|
79
|
-
export function getYouTubeImportService() {
|
|
80
|
-
if (!youtubeImportServiceInstance) {
|
|
81
|
-
youtubeImportServiceInstance = new YouTubeImportService();
|
|
82
|
-
}
|
|
83
|
-
return youtubeImportServiceInstance;
|
|
84
|
-
}
|
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
// Debug logging service
|
|
2
|
-
import * as fs from 'node:fs';
|
|
3
|
-
import * as path from 'node:path';
|
|
4
|
-
import * as os from 'node:os';
|
|
5
|
-
const DEBUG_DIR = path.join(os.homedir(), '.youtube-music-cli');
|
|
6
|
-
const DEBUG_FILE = path.join(DEBUG_DIR, 'debug.log');
|
|
7
|
-
const MAX_LOG_SIZE = 5 * 1024 * 1024; // 5MB
|
|
8
|
-
// Ensure debug directory exists
|
|
9
|
-
if (!fs.existsSync(DEBUG_DIR)) {
|
|
10
|
-
fs.mkdirSync(DEBUG_DIR, { recursive: true });
|
|
11
|
-
}
|
|
12
|
-
// Rotate log if too large
|
|
13
|
-
if (fs.existsSync(DEBUG_FILE)) {
|
|
14
|
-
const stats = fs.statSync(DEBUG_FILE);
|
|
15
|
-
if (stats.size > MAX_LOG_SIZE) {
|
|
16
|
-
const backupFile = path.join(DEBUG_DIR, 'debug.log.old');
|
|
17
|
-
if (fs.existsSync(backupFile)) {
|
|
18
|
-
fs.unlinkSync(backupFile);
|
|
19
|
-
}
|
|
20
|
-
fs.renameSync(DEBUG_FILE, backupFile);
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
class Logger {
|
|
24
|
-
writeToFile(level, category, message, data) {
|
|
25
|
-
const timestamp = new Date().toISOString();
|
|
26
|
-
const dataStr = data ? `\n${JSON.stringify(data, null, 2)}` : '';
|
|
27
|
-
const logLine = `[${timestamp}] [${level}] [${category}] ${message}${dataStr}\n`;
|
|
28
|
-
fs.appendFileSync(DEBUG_FILE, logLine);
|
|
29
|
-
}
|
|
30
|
-
debug(category, message, data) {
|
|
31
|
-
this.writeToFile('DEBUG', category, message, data);
|
|
32
|
-
}
|
|
33
|
-
info(category, message, data) {
|
|
34
|
-
this.writeToFile('INFO', category, message, data);
|
|
35
|
-
// Disabled: console.log causes Ink to re-render constantly
|
|
36
|
-
// console.log(`[${category}] ${message}`);
|
|
37
|
-
}
|
|
38
|
-
warn(category, message, data) {
|
|
39
|
-
this.writeToFile('WARN', category, message, data);
|
|
40
|
-
// Disabled: console.warn causes Ink to re-render
|
|
41
|
-
// console.warn(`[${category}] ${message}`);
|
|
42
|
-
}
|
|
43
|
-
error(category, message, data) {
|
|
44
|
-
this.writeToFile('ERROR', category, message, data);
|
|
45
|
-
// Keep console.error for critical errors, but this should be rare
|
|
46
|
-
console.error(`[${category}] ${message}`);
|
|
47
|
-
}
|
|
48
|
-
getLogPath() {
|
|
49
|
-
return DEBUG_FILE;
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
export const logger = new Logger();
|
|
@@ -1,93 +0,0 @@
|
|
|
1
|
-
// Lyrics service using LRCLIB API (https://lrclib.net)
|
|
2
|
-
// Free, no authentication required
|
|
3
|
-
import { logger } from "../logger/logger.service.js";
|
|
4
|
-
const LRCLIB_BASE = 'https://lrclib.net/api';
|
|
5
|
-
class LyricsService {
|
|
6
|
-
static instance;
|
|
7
|
-
cache = new Map();
|
|
8
|
-
constructor() { }
|
|
9
|
-
static getInstance() {
|
|
10
|
-
if (!LyricsService.instance) {
|
|
11
|
-
LyricsService.instance = new LyricsService();
|
|
12
|
-
}
|
|
13
|
-
return LyricsService.instance;
|
|
14
|
-
}
|
|
15
|
-
/** Parse LRC format into timed lines */
|
|
16
|
-
parseLrc(lrc) {
|
|
17
|
-
const lines = [];
|
|
18
|
-
const lineRegex = /\[(\d{2}):(\d{2})\.(\d{2,3})\](.*)/;
|
|
19
|
-
for (const rawLine of lrc.split('\n')) {
|
|
20
|
-
const match = lineRegex.exec(rawLine.trim());
|
|
21
|
-
if (match) {
|
|
22
|
-
const minutes = Number.parseInt(match[1], 10);
|
|
23
|
-
const seconds = Number.parseInt(match[2], 10);
|
|
24
|
-
const centiseconds = Number.parseInt(match[3].padEnd(3, '0'), 10);
|
|
25
|
-
const time = minutes * 60 + seconds + centiseconds / 1000;
|
|
26
|
-
const text = match[4].trim();
|
|
27
|
-
lines.push({ time, text });
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
return lines.sort((a, b) => a.time - b.time);
|
|
31
|
-
}
|
|
32
|
-
async getLyrics(trackName, artistName, duration) {
|
|
33
|
-
const cacheKey = `${trackName}::${artistName}`;
|
|
34
|
-
if (this.cache.has(cacheKey)) {
|
|
35
|
-
return this.cache.get(cacheKey) ?? null;
|
|
36
|
-
}
|
|
37
|
-
try {
|
|
38
|
-
const params = new URLSearchParams({
|
|
39
|
-
track_name: trackName,
|
|
40
|
-
artist_name: artistName,
|
|
41
|
-
...(duration ? { duration: String(Math.round(duration)) } : {}),
|
|
42
|
-
});
|
|
43
|
-
const response = await fetch(`${LRCLIB_BASE}/get?${params.toString()}`);
|
|
44
|
-
if (!response.ok) {
|
|
45
|
-
if (response.status === 404) {
|
|
46
|
-
logger.debug('LyricsService', 'No lyrics found', {
|
|
47
|
-
trackName,
|
|
48
|
-
artistName,
|
|
49
|
-
});
|
|
50
|
-
this.cache.set(cacheKey, null);
|
|
51
|
-
return null;
|
|
52
|
-
}
|
|
53
|
-
throw new Error(`LRCLIB API error: ${response.status}`);
|
|
54
|
-
}
|
|
55
|
-
const data = (await response.json());
|
|
56
|
-
const lyrics = {
|
|
57
|
-
synced: data.syncedLyrics ? this.parseLrc(data.syncedLyrics) : null,
|
|
58
|
-
plain: data.plainLyrics ?? null,
|
|
59
|
-
};
|
|
60
|
-
this.cache.set(cacheKey, lyrics);
|
|
61
|
-
logger.info('LyricsService', 'Lyrics loaded', {
|
|
62
|
-
trackName,
|
|
63
|
-
hasSynced: !!lyrics.synced,
|
|
64
|
-
hasPlain: !!lyrics.plain,
|
|
65
|
-
});
|
|
66
|
-
return lyrics;
|
|
67
|
-
}
|
|
68
|
-
catch (error) {
|
|
69
|
-
logger.warn('LyricsService', 'Failed to fetch lyrics', {
|
|
70
|
-
error: error instanceof Error ? error.message : String(error),
|
|
71
|
-
});
|
|
72
|
-
this.cache.set(cacheKey, null);
|
|
73
|
-
return null;
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
/** Get the current lyric line index based on playback position */
|
|
77
|
-
getCurrentLineIndex(lines, currentTime) {
|
|
78
|
-
let index = 0;
|
|
79
|
-
for (let i = 0; i < lines.length; i++) {
|
|
80
|
-
if (lines[i].time <= currentTime) {
|
|
81
|
-
index = i;
|
|
82
|
-
}
|
|
83
|
-
else {
|
|
84
|
-
break;
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
return index;
|
|
88
|
-
}
|
|
89
|
-
clearCache() {
|
|
90
|
-
this.cache.clear();
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
export const getLyricsService = () => LyricsService.getInstance();
|
|
@@ -1,78 +0,0 @@
|
|
|
1
|
-
// MPRIS service — Linux only, enables playerctl / media key support
|
|
2
|
-
// No-ops on non-Linux platforms
|
|
3
|
-
import { logger } from "../logger/logger.service.js";
|
|
4
|
-
export class MprisService {
|
|
5
|
-
player = null;
|
|
6
|
-
get isSupported() {
|
|
7
|
-
return process.platform === 'linux';
|
|
8
|
-
}
|
|
9
|
-
async initialize(callbacks = {}) {
|
|
10
|
-
if (!this.isSupported) {
|
|
11
|
-
logger.debug('MprisService', 'MPRIS not supported on this platform', {
|
|
12
|
-
platform: process.platform,
|
|
13
|
-
});
|
|
14
|
-
return;
|
|
15
|
-
}
|
|
16
|
-
try {
|
|
17
|
-
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
18
|
-
// @ts-ignore
|
|
19
|
-
const mpris = (await import('mpris-service'));
|
|
20
|
-
this.player = mpris.createPlayer({
|
|
21
|
-
name: 'youtube-music-cli',
|
|
22
|
-
identity: 'YouTube Music CLI',
|
|
23
|
-
supportedInterfaces: ['player'],
|
|
24
|
-
});
|
|
25
|
-
// Wire up MPRIS events to player callbacks
|
|
26
|
-
if (callbacks.onPlay)
|
|
27
|
-
this.player.on('play', callbacks.onPlay);
|
|
28
|
-
if (callbacks.onPause)
|
|
29
|
-
this.player.on('pause', callbacks.onPause);
|
|
30
|
-
if (callbacks.onNext)
|
|
31
|
-
this.player.on('next', callbacks.onNext);
|
|
32
|
-
if (callbacks.onPrevious)
|
|
33
|
-
this.player.on('previous', callbacks.onPrevious);
|
|
34
|
-
this.player.canPlay = true;
|
|
35
|
-
this.player.canPause = true;
|
|
36
|
-
this.player.canGoNext = true;
|
|
37
|
-
this.player.canGoPrevious = true;
|
|
38
|
-
this.player.canSeek = false;
|
|
39
|
-
logger.info('MprisService', 'MPRIS player initialized');
|
|
40
|
-
}
|
|
41
|
-
catch (error) {
|
|
42
|
-
logger.warn('MprisService', 'Could not initialize MPRIS', {
|
|
43
|
-
error: error instanceof Error ? error.message : String(error),
|
|
44
|
-
});
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
updateTrack(track, isPlaying) {
|
|
48
|
-
if (!this.player)
|
|
49
|
-
return;
|
|
50
|
-
try {
|
|
51
|
-
this.player.metadata = {
|
|
52
|
-
'mpris:length': track.duration,
|
|
53
|
-
'xesam:title': track.title,
|
|
54
|
-
'xesam:artist': [track.artist],
|
|
55
|
-
};
|
|
56
|
-
this.player.playbackStatus = isPlaying ? 'Playing' : 'Paused';
|
|
57
|
-
}
|
|
58
|
-
catch {
|
|
59
|
-
// Ignore MPRIS update errors
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
setPlaying(playing) {
|
|
63
|
-
if (!this.player)
|
|
64
|
-
return;
|
|
65
|
-
try {
|
|
66
|
-
this.player.playbackStatus = playing ? 'Playing' : 'Paused';
|
|
67
|
-
}
|
|
68
|
-
catch {
|
|
69
|
-
// Ignore
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
let instance = null;
|
|
74
|
-
export const getMprisService = () => {
|
|
75
|
-
if (!instance)
|
|
76
|
-
instance = new MprisService();
|
|
77
|
-
return instance;
|
|
78
|
-
};
|
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
// Desktop notification service
|
|
2
|
-
import { logger } from "../logger/logger.service.js";
|
|
3
|
-
class NotificationService {
|
|
4
|
-
static instance;
|
|
5
|
-
enabled = false;
|
|
6
|
-
notifier = null;
|
|
7
|
-
constructor() { }
|
|
8
|
-
static getInstance() {
|
|
9
|
-
if (!NotificationService.instance) {
|
|
10
|
-
NotificationService.instance = new NotificationService();
|
|
11
|
-
}
|
|
12
|
-
return NotificationService.instance;
|
|
13
|
-
}
|
|
14
|
-
setEnabled(enabled) {
|
|
15
|
-
this.enabled = enabled;
|
|
16
|
-
}
|
|
17
|
-
isEnabled() {
|
|
18
|
-
return this.enabled;
|
|
19
|
-
}
|
|
20
|
-
async getNotifier() {
|
|
21
|
-
if (!this.notifier) {
|
|
22
|
-
// Lazy-load to avoid startup cost when disabled
|
|
23
|
-
const mod = await import('node-notifier');
|
|
24
|
-
this.notifier = mod.default;
|
|
25
|
-
}
|
|
26
|
-
return this.notifier;
|
|
27
|
-
}
|
|
28
|
-
async notify(title, message) {
|
|
29
|
-
if (!this.enabled)
|
|
30
|
-
return;
|
|
31
|
-
try {
|
|
32
|
-
const notifier = await this.getNotifier();
|
|
33
|
-
notifier.notify({
|
|
34
|
-
title,
|
|
35
|
-
message,
|
|
36
|
-
sound: false,
|
|
37
|
-
wait: false,
|
|
38
|
-
}, (error) => {
|
|
39
|
-
if (error) {
|
|
40
|
-
logger.warn('NotificationService', 'Notification failed', {
|
|
41
|
-
error: error.message,
|
|
42
|
-
});
|
|
43
|
-
}
|
|
44
|
-
});
|
|
45
|
-
}
|
|
46
|
-
catch (error) {
|
|
47
|
-
// Gracefully handle if notifications aren't supported
|
|
48
|
-
logger.warn('NotificationService', 'Failed to send notification', {
|
|
49
|
-
error: error instanceof Error ? error.message : String(error),
|
|
50
|
-
});
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
async notifyTrackChange(title, artist) {
|
|
54
|
-
await this.notify('Now Playing', `${title} — ${artist}`);
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
export const getNotificationService = () => NotificationService.getInstance();
|