@involvex/youtube-music-cli 0.0.17 → 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.
- package/CHANGELOG.md +8 -0
- package/dist/source/cli.js +123 -8
- package/dist/source/components/common/ShortcutsBar.js +2 -2
- package/dist/source/components/import/ImportLayout.d.ts +1 -0
- package/dist/source/components/import/ImportLayout.js +119 -0
- package/dist/source/components/import/ImportProgress.d.ts +6 -0
- package/dist/source/components/import/ImportProgress.js +73 -0
- package/dist/source/components/layouts/MainLayout.js +7 -0
- package/dist/source/components/settings/Settings.js +6 -2
- package/dist/source/services/config/config.service.js +10 -0
- package/dist/source/services/import/import.service.d.ts +44 -0
- package/dist/source/services/import/import.service.js +272 -0
- package/dist/source/services/import/spotify.service.d.ts +40 -0
- package/dist/source/services/import/spotify.service.js +171 -0
- package/dist/source/services/import/track-matcher.service.d.ts +60 -0
- package/dist/source/services/import/track-matcher.service.js +271 -0
- package/dist/source/services/import/youtube-import.service.d.ts +17 -0
- package/dist/source/services/import/youtube-import.service.js +84 -0
- package/dist/source/services/web/static-file.service.d.ts +31 -0
- package/dist/source/services/web/static-file.service.js +174 -0
- package/dist/source/services/web/web-server-manager.d.ts +66 -0
- package/dist/source/services/web/web-server-manager.js +219 -0
- package/dist/source/services/web/web-streaming.service.d.ts +88 -0
- package/dist/source/services/web/web-streaming.service.js +290 -0
- package/dist/source/services/web/websocket.server.d.ts +58 -0
- package/dist/source/services/web/websocket.server.js +253 -0
- package/dist/source/stores/player.store.js +27 -0
- package/dist/source/types/cli.types.d.ts +8 -0
- package/dist/source/types/config.types.d.ts +2 -0
- package/dist/source/types/import.types.d.ts +72 -0
- package/dist/source/types/import.types.js +2 -0
- package/dist/source/types/web.types.d.ts +89 -0
- package/dist/source/types/web.types.js +2 -0
- package/dist/source/utils/constants.d.ts +1 -0
- package/dist/source/utils/constants.js +1 -0
- package/dist/youtube-music-cli.exe +0 -0
- package/package.json +8 -3
|
@@ -0,0 +1,271 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { YouTubePlaylist } from '../../types/import.types.ts';
|
|
2
|
+
declare class YouTubeImportService {
|
|
3
|
+
/**
|
|
4
|
+
* Extract playlist ID from various YouTube URL formats
|
|
5
|
+
*/
|
|
6
|
+
extractPlaylistId(input: string): string | null;
|
|
7
|
+
/**
|
|
8
|
+
* Fetch a YouTube playlist and normalize to import format
|
|
9
|
+
*/
|
|
10
|
+
fetchPlaylist(urlOrId: string): Promise<YouTubePlaylist | null>;
|
|
11
|
+
/**
|
|
12
|
+
* Validate if a playlist is accessible (not private/unavailable)
|
|
13
|
+
*/
|
|
14
|
+
validatePlaylist(urlOrId: string): Promise<boolean>;
|
|
15
|
+
}
|
|
16
|
+
export declare function getYouTubeImportService(): YouTubeImportService;
|
|
17
|
+
export {};
|
|
@@ -0,0 +1,84 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
declare class StaticFileService {
|
|
2
|
+
private webDistDir;
|
|
3
|
+
private indexHtml;
|
|
4
|
+
private indexHtmlLoaded;
|
|
5
|
+
constructor();
|
|
6
|
+
/**
|
|
7
|
+
* Get MIME type for a file extension
|
|
8
|
+
*/
|
|
9
|
+
private getMimeType;
|
|
10
|
+
/**
|
|
11
|
+
* Load index.html into memory
|
|
12
|
+
*/
|
|
13
|
+
private loadIndexHtml;
|
|
14
|
+
/**
|
|
15
|
+
* Serve a static file
|
|
16
|
+
*/
|
|
17
|
+
serve(url: string, _req: unknown, res: {
|
|
18
|
+
writeHead: (statusCode: number, headers?: Record<string, string>) => void;
|
|
19
|
+
end: (data?: string | Buffer) => void;
|
|
20
|
+
}): Promise<void>;
|
|
21
|
+
/**
|
|
22
|
+
* Check if web UI is built
|
|
23
|
+
*/
|
|
24
|
+
isWebUiBuilt(): boolean;
|
|
25
|
+
/**
|
|
26
|
+
* Clear cached index.html (useful for development)
|
|
27
|
+
*/
|
|
28
|
+
clearCache(): void;
|
|
29
|
+
}
|
|
30
|
+
export declare function getStaticFileService(): StaticFileService;
|
|
31
|
+
export {};
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// Static file serving service for web UI
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
4
|
+
import { extname, join, dirname } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { logger } from "../logger/logger.service.js";
|
|
7
|
+
const MIME_TYPES = {
|
|
8
|
+
'.html': 'text/html',
|
|
9
|
+
'.css': 'text/css',
|
|
10
|
+
'.js': 'text/javascript',
|
|
11
|
+
'.json': 'application/json',
|
|
12
|
+
'.png': 'image/png',
|
|
13
|
+
'.jpg': 'image/jpeg',
|
|
14
|
+
'.jpeg': 'image/jpeg',
|
|
15
|
+
'.gif': 'image/gif',
|
|
16
|
+
'.svg': 'image/svg+xml',
|
|
17
|
+
'.ico': 'image/x-icon',
|
|
18
|
+
'.woff': 'font/woff',
|
|
19
|
+
'.woff2': 'font/woff2',
|
|
20
|
+
'.ttf': 'font/ttf',
|
|
21
|
+
'.eot': 'application/vnd.ms-fontobject',
|
|
22
|
+
};
|
|
23
|
+
class StaticFileService {
|
|
24
|
+
webDistDir;
|
|
25
|
+
indexHtml = null;
|
|
26
|
+
indexHtmlLoaded = false;
|
|
27
|
+
constructor() {
|
|
28
|
+
// Web UI is built to dist/web/ relative to the project root
|
|
29
|
+
// Get the directory of the current file
|
|
30
|
+
const currentFile = fileURLToPath(import.meta.url);
|
|
31
|
+
const currentDir = dirname(currentFile);
|
|
32
|
+
// Detect if running from dist/ or source/
|
|
33
|
+
// dist/source/services/web -> need to go up 4 levels to reach project root
|
|
34
|
+
// source/services/web -> need to go up 3 levels to reach project root
|
|
35
|
+
const isDist = currentFile.includes('/dist/') || currentFile.includes('\\dist\\');
|
|
36
|
+
let projectRoot;
|
|
37
|
+
if (isDist) {
|
|
38
|
+
// dist/source/services/web -> services/web -> services -> source -> dist -> project root
|
|
39
|
+
projectRoot = join(currentDir, '..', '..', '..', '..');
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
// source/services/web -> services/web -> services -> source -> project root
|
|
43
|
+
projectRoot = join(currentDir, '..', '..', '..');
|
|
44
|
+
}
|
|
45
|
+
this.webDistDir = join(projectRoot, 'dist', 'web');
|
|
46
|
+
console.log('[StaticFileService] Path resolution:', {
|
|
47
|
+
currentFile,
|
|
48
|
+
currentDir,
|
|
49
|
+
isDist,
|
|
50
|
+
projectRoot,
|
|
51
|
+
webDistDir: this.webDistDir,
|
|
52
|
+
exists: existsSync(this.webDistDir),
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Get MIME type for a file extension
|
|
57
|
+
*/
|
|
58
|
+
getMimeType(filePath) {
|
|
59
|
+
const ext = extname(filePath).toLowerCase();
|
|
60
|
+
return MIME_TYPES[ext] || 'application/octet-stream';
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Load index.html into memory
|
|
64
|
+
*/
|
|
65
|
+
async loadIndexHtml() {
|
|
66
|
+
if (this.indexHtmlLoaded)
|
|
67
|
+
return;
|
|
68
|
+
const indexPath = join(this.webDistDir, 'index.html');
|
|
69
|
+
console.log('[StaticFileService] Loading index.html:', {
|
|
70
|
+
webDistDir: this.webDistDir,
|
|
71
|
+
indexPath,
|
|
72
|
+
exists: existsSync(indexPath),
|
|
73
|
+
});
|
|
74
|
+
try {
|
|
75
|
+
const buffer = await readFile(indexPath);
|
|
76
|
+
this.indexHtml = buffer.toString('utf-8');
|
|
77
|
+
this.indexHtmlLoaded = true;
|
|
78
|
+
logger.debug('StaticFileService', 'index.html loaded', { indexPath });
|
|
79
|
+
console.log('[StaticFileService] index.html loaded successfully');
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
console.error('[StaticFileService] Failed to load index.html:', error);
|
|
83
|
+
logger.error('StaticFileService', 'Failed to load index.html', {
|
|
84
|
+
indexPath,
|
|
85
|
+
error: error instanceof Error ? error.message : String(error),
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Serve a static file
|
|
91
|
+
*/
|
|
92
|
+
async serve(url, _req, res) {
|
|
93
|
+
// Remove query string
|
|
94
|
+
const urlPath = url.split('?')[0] ?? '/';
|
|
95
|
+
// Serve index.html for SPA routes
|
|
96
|
+
if (urlPath === '/' || !urlPath.includes('.')) {
|
|
97
|
+
// Ensure index.html is loaded
|
|
98
|
+
if (!this.indexHtmlLoaded) {
|
|
99
|
+
await this.loadIndexHtml();
|
|
100
|
+
}
|
|
101
|
+
if (this.indexHtml) {
|
|
102
|
+
res.writeHead(200, {
|
|
103
|
+
'Content-Type': 'text/html',
|
|
104
|
+
'Cache-Control': 'public, max-age=3600',
|
|
105
|
+
});
|
|
106
|
+
res.end(this.indexHtml);
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
// Web UI not built, serve a simple message
|
|
110
|
+
res.writeHead(503, { 'Content-Type': 'text/html' });
|
|
111
|
+
res.end(`
|
|
112
|
+
<!DOCTYPE html>
|
|
113
|
+
<html>
|
|
114
|
+
<head><title>Web UI Not Built</title></head>
|
|
115
|
+
<body>
|
|
116
|
+
<h1>Web UI Not Built</h1>
|
|
117
|
+
<p>Run <code>bun run build:web</code> to build the web UI.</p>
|
|
118
|
+
</body>
|
|
119
|
+
</html>
|
|
120
|
+
`);
|
|
121
|
+
}
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
// Serve static files
|
|
125
|
+
const filePath = join(this.webDistDir, urlPath);
|
|
126
|
+
try {
|
|
127
|
+
// Check if file exists
|
|
128
|
+
if (!existsSync(filePath)) {
|
|
129
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
130
|
+
res.end('Not Found');
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
// Read and serve file
|
|
134
|
+
const content = await readFile(filePath);
|
|
135
|
+
const mimeType = this.getMimeType(filePath);
|
|
136
|
+
res.writeHead(200, {
|
|
137
|
+
'Content-Type': mimeType,
|
|
138
|
+
'Cache-Control': 'public, max-age=86400', // 1 day
|
|
139
|
+
});
|
|
140
|
+
res.end(content);
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
logger.error('StaticFileService', 'Failed to serve file', {
|
|
144
|
+
filePath,
|
|
145
|
+
error: error instanceof Error ? error.message : String(error),
|
|
146
|
+
});
|
|
147
|
+
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
148
|
+
res.end('Internal Server Error');
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Check if web UI is built
|
|
153
|
+
*/
|
|
154
|
+
isWebUiBuilt() {
|
|
155
|
+
const indexPath = join(this.webDistDir, 'index.html');
|
|
156
|
+
return existsSync(indexPath);
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Clear cached index.html (useful for development)
|
|
160
|
+
*/
|
|
161
|
+
clearCache() {
|
|
162
|
+
this.indexHtml = null;
|
|
163
|
+
this.indexHtmlLoaded = false;
|
|
164
|
+
logger.debug('StaticFileService', 'Cache cleared');
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
// Singleton instance
|
|
168
|
+
let staticFileServiceInstance = null;
|
|
169
|
+
export function getStaticFileService() {
|
|
170
|
+
if (!staticFileServiceInstance) {
|
|
171
|
+
staticFileServiceInstance = new StaticFileService();
|
|
172
|
+
}
|
|
173
|
+
return staticFileServiceInstance;
|
|
174
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { WebServerConfig, WebServerOptions } from '../../types/web.types.ts';
|
|
2
|
+
import type { PlayerState } from '../../types/player.types.ts';
|
|
3
|
+
declare class WebServerManager {
|
|
4
|
+
private config;
|
|
5
|
+
private isRunning;
|
|
6
|
+
private cleanupHooks;
|
|
7
|
+
constructor();
|
|
8
|
+
/**
|
|
9
|
+
* Start the web server
|
|
10
|
+
*/
|
|
11
|
+
start(options?: WebServerOptions): Promise<void>;
|
|
12
|
+
/**
|
|
13
|
+
* Stop the web server
|
|
14
|
+
*/
|
|
15
|
+
stop(): Promise<void>;
|
|
16
|
+
/**
|
|
17
|
+
* Set up player command handler
|
|
18
|
+
*/
|
|
19
|
+
private setupCommandHandler;
|
|
20
|
+
/**
|
|
21
|
+
* Set up import progress handler
|
|
22
|
+
*/
|
|
23
|
+
private setupImportHandler;
|
|
24
|
+
/**
|
|
25
|
+
* Handle command from web client
|
|
26
|
+
*/
|
|
27
|
+
private handleCommand;
|
|
28
|
+
/**
|
|
29
|
+
* Handle import request from web client
|
|
30
|
+
*/
|
|
31
|
+
private handleImportRequest;
|
|
32
|
+
/**
|
|
33
|
+
* Update player state (call this when player state changes)
|
|
34
|
+
*/
|
|
35
|
+
updateState(state: PlayerState): void;
|
|
36
|
+
/**
|
|
37
|
+
* Set up graceful shutdown hooks
|
|
38
|
+
*/
|
|
39
|
+
private setupShutdownHooks;
|
|
40
|
+
/**
|
|
41
|
+
* Check if server is running
|
|
42
|
+
*/
|
|
43
|
+
isServerRunning(): boolean;
|
|
44
|
+
/**
|
|
45
|
+
* Get server URL
|
|
46
|
+
*/
|
|
47
|
+
getServerUrl(): string;
|
|
48
|
+
/**
|
|
49
|
+
* Get server statistics
|
|
50
|
+
*/
|
|
51
|
+
getStats(): {
|
|
52
|
+
running: boolean;
|
|
53
|
+
url?: string;
|
|
54
|
+
clients?: number;
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* Update configuration
|
|
58
|
+
*/
|
|
59
|
+
updateConfig(config: Partial<WebServerConfig>): void;
|
|
60
|
+
/**
|
|
61
|
+
* Get current configuration
|
|
62
|
+
*/
|
|
63
|
+
getConfig(): WebServerConfig;
|
|
64
|
+
}
|
|
65
|
+
export declare function getWebServerManager(): WebServerManager;
|
|
66
|
+
export {};
|