Compare commits
25 Commits
50f7869a05
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6474cf8b4e | ||
|
|
54d97beb15 | ||
|
|
99d71f05cf | ||
|
|
87aa994365 | ||
|
|
e6a8f62bba | ||
|
|
83f385edb2 | ||
|
|
932826aeca | ||
|
|
9b737012e9 | ||
|
|
4bdb5c539d | ||
|
|
6b1cdb92ad | ||
|
|
a76ef33c4c | ||
|
|
c9fac4b7fe | ||
|
|
79595dc9ed | ||
|
|
58ac7ec198 | ||
|
|
ae5e34694e | ||
|
|
0ff4769eb0 | ||
|
|
ad972dd2a5 | ||
|
|
c07c4e42e6 | ||
|
|
ef44218198 | ||
|
|
1f8d392114 | ||
|
|
44ff76d58d | ||
|
|
b5b093e64b | ||
|
|
5561bf2400 | ||
|
|
c6fb745b85 | ||
|
|
a2a366d34a |
@@ -131,9 +131,19 @@ async function getSubsonicUrl(endpoint, params = {}) {
|
|||||||
...params
|
...params
|
||||||
};
|
};
|
||||||
|
|
||||||
const queryString = Object.entries(authParams)
|
const queryParts = [];
|
||||||
.map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
|
for (const [key, value] of Object.entries(authParams)) {
|
||||||
.join('&');
|
if (Array.isArray(value)) {
|
||||||
|
for (const item of value) {
|
||||||
|
if (item !== undefined && item !== null && item !== '') {
|
||||||
|
queryParts.push(`${key}=${encodeURIComponent(item)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (value !== undefined && value !== null && value !== '') {
|
||||||
|
queryParts.push(`${key}=${encodeURIComponent(value)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const queryString = queryParts.join('&');
|
||||||
|
|
||||||
return `${NAVIDROME_URL}/rest/${endpoint}?${queryString}`;
|
return `${NAVIDROME_URL}/rest/${endpoint}?${queryString}`;
|
||||||
}
|
}
|
||||||
@@ -222,13 +232,13 @@ function sanitizeFilename(str) {
|
|||||||
.substring(0, 200); // Limit length to avoid path too long errors
|
.substring(0, 200); // Limit length to avoid path too long errors
|
||||||
}
|
}
|
||||||
|
|
||||||
function downloadFile(url, dest) {
|
function downloadFile(url, dest, headers = {}) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const protocol = url.startsWith('https') ? https : http;
|
const protocol = url.startsWith('https') ? https : http;
|
||||||
protocol.get(url, (res) => {
|
protocol.get(url, { headers }, (res) => {
|
||||||
// Handle Redirects
|
// Handle Redirects
|
||||||
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||||||
downloadFile(res.headers.location, dest).then(resolve).catch(reject);
|
downloadFile(res.headers.location, dest, headers).then(resolve).catch(reject);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (res.statusCode !== 200) {
|
if (res.statusCode !== 200) {
|
||||||
@@ -251,9 +261,237 @@ function downloadFile(url, dest) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildDownloadHeaders(source) {
|
||||||
|
const headers = {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
||||||
|
};
|
||||||
|
if (source === 'netease') {
|
||||||
|
headers['Referer'] = 'https://music.163.com/';
|
||||||
|
}
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDownloadHeadersForUrl(url, source) {
|
||||||
|
const headers = buildDownloadHeaders(source);
|
||||||
|
if (!url) return headers;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
const host = parsed.host || '';
|
||||||
|
|
||||||
|
if (host.includes('qqmusic')) {
|
||||||
|
headers['Referer'] = 'https://y.qq.com/';
|
||||||
|
headers['Origin'] = 'https://y.qq.com';
|
||||||
|
headers['Accept'] = '*/*';
|
||||||
|
headers['Range'] = 'bytes=0-';
|
||||||
|
} else if (host.includes('music.163.com')) {
|
||||||
|
headers['Referer'] = 'https://music.163.com/';
|
||||||
|
headers['Origin'] = 'https://music.163.com';
|
||||||
|
} else if (host.includes('kuwo')) {
|
||||||
|
headers['Referer'] = 'https://www.kuwo.cn/';
|
||||||
|
headers['Origin'] = 'https://www.kuwo.cn';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPreferredQualities(types) {
|
||||||
|
const preference = ['flac24bit', 'flac', '320k', '128k'];
|
||||||
|
if (!Array.isArray(types) || types.length === 0) {
|
||||||
|
return ['320k', '128k'];
|
||||||
|
}
|
||||||
|
const normalized = types.map(t => t?.toString().toLowerCase()).filter(Boolean);
|
||||||
|
const available = preference.filter(q => normalized.includes(q));
|
||||||
|
return available.length > 0 ? available : ['320k', '128k'];
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeText(str) {
|
||||||
|
return (str || '')
|
||||||
|
.toString()
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[\s\-_[\]().·()、]+/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickBestSearchResult(results, songName, songArtist) {
|
||||||
|
if (!Array.isArray(results) || results.length === 0) return null;
|
||||||
|
|
||||||
|
const targetName = normalizeText(songName);
|
||||||
|
const targetArtist = normalizeText(songArtist);
|
||||||
|
|
||||||
|
let best = null;
|
||||||
|
let bestScore = -1;
|
||||||
|
|
||||||
|
for (const item of results) {
|
||||||
|
const name = normalizeText(item.name);
|
||||||
|
const artist = normalizeText(item.artist);
|
||||||
|
let score = 0;
|
||||||
|
if (name && targetName && name === targetName) score += 100;
|
||||||
|
if (artist && targetArtist && artist === targetArtist) score += 60;
|
||||||
|
if (name && targetName && name.includes(targetName)) score += 30;
|
||||||
|
if (artist && targetArtist && artist.includes(targetArtist)) score += 20;
|
||||||
|
if (score > bestScore) {
|
||||||
|
bestScore = score;
|
||||||
|
best = item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return best || results[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchSongOnTuneHub(songName, songArtist, source) {
|
||||||
|
try {
|
||||||
|
const keyword = songArtist ? `${songName} ${songArtist}` : songName;
|
||||||
|
if (!keyword) return null;
|
||||||
|
const url = `${TUNEHUB_API_URL}/api/?source=${source}&type=search&keyword=${encodeURIComponent(keyword)}&limit=20`;
|
||||||
|
const response = await apiClient.get(url, { retry: 2, retryDelay: 1000 });
|
||||||
|
if (response.data?.code !== 200) return null;
|
||||||
|
const data = response.data.data || {};
|
||||||
|
const results = data.results || [];
|
||||||
|
return pickBestSearchResult(results, songName, songArtist);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[Sync] Search fallback failed:', error.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDownloadCandidates(source, songId, songUrl, types) {
|
||||||
|
const candidates = [];
|
||||||
|
if (songUrl) {
|
||||||
|
candidates.push({ label: 'playlist_url', apiUrl: songUrl });
|
||||||
|
}
|
||||||
|
|
||||||
|
const qualities = getPreferredQualities(types);
|
||||||
|
for (const q of qualities) {
|
||||||
|
candidates.push({
|
||||||
|
label: `br=${q}`,
|
||||||
|
apiUrl: `${TUNEHUB_API_URL}/api/?source=${source}&id=${songId}&type=url&br=${q}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates.push({
|
||||||
|
label: 'br=default',
|
||||||
|
apiUrl: `${TUNEHUB_API_URL}/api/?source=${source}&id=${songId}&type=url`
|
||||||
|
});
|
||||||
|
|
||||||
|
return candidates;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSongLog(song, source) {
|
||||||
|
const name = song?.name || 'unknown';
|
||||||
|
const id = song?.id || 'unknown';
|
||||||
|
const src = source || song?.source || 'unknown';
|
||||||
|
return `${name} (id=${id}, source=${src})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tryDownloadWithCandidates(song, source, baseName, candidates, downloadHeaders) {
|
||||||
|
const tried = new Set();
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (!candidate.apiUrl || tried.has(candidate.apiUrl)) continue;
|
||||||
|
tried.add(candidate.apiUrl);
|
||||||
|
|
||||||
|
const directUrl = await resolveTuneHubAudioUrl(candidate.apiUrl);
|
||||||
|
if (!directUrl) {
|
||||||
|
console.warn(`[Sync] Failed to resolve audio url for ${formatSongLog(song, source)} (${candidate.label}) api=${candidate.apiUrl}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let ext = 'mp3';
|
||||||
|
if (directUrl.includes('.flac')) ext = 'flac';
|
||||||
|
else if (directUrl.includes('.m4a')) ext = 'm4a';
|
||||||
|
else if (directUrl.includes('.ogg')) ext = 'ogg';
|
||||||
|
else if (directUrl.includes('.wav')) ext = 'wav';
|
||||||
|
|
||||||
|
const tempFile = path.join(MUSIC_DIR, `temp_${Date.now()}_${song.id}.${ext}`);
|
||||||
|
const finalFile = path.join(MUSIC_DIR, `${baseName}.${ext}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resolvedHeaders = buildDownloadHeadersForUrl(directUrl, source);
|
||||||
|
await downloadFile(directUrl, tempFile, resolvedHeaders);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Try to download cover image
|
||||||
|
let coverPath = null;
|
||||||
|
try {
|
||||||
|
const coverUrl = `${TUNEHUB_API_URL}/api/?source=${source}&id=${song.id}&type=pic`;
|
||||||
|
coverPath = path.join(MUSIC_DIR, `temp_cover_${Date.now()}_${song.id}.jpg`);
|
||||||
|
await downloadFile(coverUrl, coverPath, downloadHeaders);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[Sync] Failed to download cover for ${song.name}:`, e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write Metadata (Title, Artist, Album, Cover)
|
||||||
|
await writeMetadata(tempFile, finalFile, {
|
||||||
|
title: song.name,
|
||||||
|
artist: song.artist,
|
||||||
|
album: song.album || song.name,
|
||||||
|
cover: coverPath
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[Sync] Downloaded & Tagged: ${baseName}.${ext}`);
|
||||||
|
|
||||||
|
// Cleanup temp files
|
||||||
|
if (fs.existsSync(tempFile)) fs.unlinkSync(tempFile);
|
||||||
|
if (coverPath && fs.existsSync(coverPath)) fs.unlinkSync(coverPath);
|
||||||
|
return true; // Downloaded
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[Sync] Metadata Error for ${song.name}:`, err.message);
|
||||||
|
// Fallback: Just rename temp to final
|
||||||
|
if (!fs.existsSync(finalFile)) {
|
||||||
|
fs.renameSync(tempFile, finalFile);
|
||||||
|
}
|
||||||
|
return true; // Downloaded (fallback)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[Sync] Download failed for ${formatSongLog(song, source)} (${candidate.label}) url=${directUrl}:`, err.message);
|
||||||
|
if (fs.existsSync(tempFile)) fs.unlinkSync(tempFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveTuneHubAudioUrl(apiUrl) {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(apiUrl, {
|
||||||
|
maxRedirects: 0,
|
||||||
|
validateStatus: (status) => status >= 200 && status < 400
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status >= 300 && response.status < 400 && response.headers?.location) {
|
||||||
|
return response.headers.location;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 200 && response.data) {
|
||||||
|
const data = response.data.data || response.data;
|
||||||
|
if (typeof data === 'object' && data.url) {
|
||||||
|
return data.url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function writeMetadata(inputPath, outputPath, metadata) {
|
function writeMetadata(inputPath, outputPath, metadata) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const args = ['-i', inputPath];
|
const args = ['-i', inputPath];
|
||||||
|
const sanitizeTag = (value) => {
|
||||||
|
if (!value) return '';
|
||||||
|
return value
|
||||||
|
.toString()
|
||||||
|
.normalize('NFC')
|
||||||
|
.replace(/[\x00-\x1f\x7f]/g, '')
|
||||||
|
.trim();
|
||||||
|
};
|
||||||
|
const title = sanitizeTag(metadata.title);
|
||||||
|
const artist = sanitizeTag(metadata.artist);
|
||||||
|
const album = sanitizeTag(metadata.album);
|
||||||
|
|
||||||
// Add cover input if available
|
// Add cover input if available
|
||||||
if (metadata.cover) {
|
if (metadata.cover) {
|
||||||
@@ -265,6 +503,7 @@ function writeMetadata(inputPath, outputPath, metadata) {
|
|||||||
// ID3v2 metadata for MP3 (cover art)
|
// ID3v2 metadata for MP3 (cover art)
|
||||||
if (outputPath.endsWith('.mp3')) {
|
if (outputPath.endsWith('.mp3')) {
|
||||||
args.push('-id3v2_version', '3');
|
args.push('-id3v2_version', '3');
|
||||||
|
args.push('-write_id3v1', '1');
|
||||||
args.push('-metadata:s:v', 'title="Album cover"');
|
args.push('-metadata:s:v', 'title="Album cover"');
|
||||||
args.push('-metadata:s:v', 'comment="Cover (front)"');
|
args.push('-metadata:s:v', 'comment="Cover (front)"');
|
||||||
} else if (outputPath.endsWith('.flac')) {
|
} else if (outputPath.endsWith('.flac')) {
|
||||||
@@ -274,11 +513,19 @@ function writeMetadata(inputPath, outputPath, metadata) {
|
|||||||
args.push('-c', 'copy');
|
args.push('-c', 'copy');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear existing tags to avoid garbled metadata inheritance
|
||||||
|
args.push('-map_metadata', '-1');
|
||||||
|
|
||||||
|
if (outputPath.endsWith('.mp3')) {
|
||||||
|
args.push('-id3v2_version', '3');
|
||||||
|
args.push('-write_id3v1', '1');
|
||||||
|
}
|
||||||
|
|
||||||
// Add metadata tags
|
// Add metadata tags
|
||||||
args.push(
|
args.push(
|
||||||
'-metadata', `title=${metadata.title}`,
|
'-metadata', `title=${title}`,
|
||||||
'-metadata', `artist=${metadata.artist}`,
|
'-metadata', `artist=${artist}`,
|
||||||
'-metadata', `album=${metadata.album}`
|
'-metadata', `album=${album}`
|
||||||
);
|
);
|
||||||
|
|
||||||
// Required for FLAC + Cover to work properly without re-encoding audio
|
// Required for FLAC + Cover to work properly without re-encoding audio
|
||||||
@@ -311,8 +558,7 @@ function writeMetadata(inputPath, outputPath, metadata) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function processSong(song) {
|
async function processSong(song) {
|
||||||
return new Promise((resolve) => {
|
|
||||||
const safeName = sanitizeFilename(song.name);
|
const safeName = sanitizeFilename(song.name);
|
||||||
const safeArtist = sanitizeFilename(song.artist);
|
const safeArtist = sanitizeFilename(song.artist);
|
||||||
const source = song.platform || song.source;
|
const source = song.platform || song.source;
|
||||||
@@ -322,141 +568,200 @@ function processSong(song) {
|
|||||||
// Check if file exists (fuzzy match for extension)
|
// Check if file exists (fuzzy match for extension)
|
||||||
try {
|
try {
|
||||||
const files = fs.readdirSync(MUSIC_DIR);
|
const files = fs.readdirSync(MUSIC_DIR);
|
||||||
|
let matchedFile = null;
|
||||||
const exists = files.some(f => {
|
const exists = files.some(f => {
|
||||||
// 1. Exact Match: Artist - Name [source_id].ext
|
// 1. Exact Match: Artist - Name [source_id].ext
|
||||||
if (f.startsWith(baseName)) return true;
|
if (f.startsWith(baseName)) {
|
||||||
|
matchedFile = f;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// 2. Simple Format: Artist - Name.ext
|
// 2. Simple Format: Artist - Name.ext
|
||||||
if (f.startsWith(`${safeArtist} - ${safeName}.`)) return true;
|
if (f.startsWith(`${safeArtist} - ${safeName}.`)) {
|
||||||
|
matchedFile = f;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// 3. Simple Format w/o Artist: Name.ext
|
// 3. Simple Format w/o Artist: Name.ext
|
||||||
if (f.startsWith(`${safeName}.`)) return true;
|
if (f.startsWith(`${safeName}.`)) {
|
||||||
|
matchedFile = f;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// 4. Match prefix ignoring ID: Artist - Name [
|
// 4. Match prefix ignoring ID: Artist - Name [
|
||||||
if (f.startsWith(`${safeArtist} - ${safeName} [`)) return true;
|
if (f.startsWith(`${safeArtist} - ${safeName} [`)) {
|
||||||
|
matchedFile = f;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (exists) {
|
if (exists) {
|
||||||
// console.log(`[Sync] Skipped (Exists): ${song.name}`);
|
// console.log(`[Sync] Skipped (Exists): ${song.name}`);
|
||||||
resolve(true); // Exists
|
return { ok: true, existed: true, filename: matchedFile || null }; // Exists
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error reading music dir:', e);
|
console.error('Error reading music dir:', e);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get URL (prefer FLAC)
|
const downloadHeaders = buildDownloadHeaders(source);
|
||||||
const apiUrl = `${TUNEHUB_API_URL}/api/?source=${source}&id=${song.id}&type=url&br=flac`;
|
const candidates = buildDownloadCandidates(source, song.id, song.url, song.types);
|
||||||
|
|
||||||
https.get(apiUrl, (res) => {
|
const primaryOk = await tryDownloadWithCandidates(song, source, baseName, candidates, downloadHeaders);
|
||||||
const handleDownload = (url) => {
|
if (primaryOk) return { ok: true, existed: false, filename: `${baseName}` };
|
||||||
let ext = 'mp3';
|
|
||||||
if (url.includes('.flac')) ext = 'flac';
|
|
||||||
else if (url.includes('.m4a')) ext = 'm4a';
|
|
||||||
else if (url.includes('.ogg')) ext = 'ogg';
|
|
||||||
else if (url.includes('.wav')) ext = 'wav';
|
|
||||||
|
|
||||||
const tempFile = path.join(MUSIC_DIR, `temp_${Date.now()}_${song.id}.${ext}`);
|
// 回退:搜索拿到可用歌曲 ID 再尝试下载
|
||||||
const finalFile = path.join(MUSIC_DIR, `${baseName}.${ext}`);
|
const searchHit = await searchSongOnTuneHub(song.name, song.artist, source);
|
||||||
|
if (searchHit?.id) {
|
||||||
downloadFile(url, tempFile)
|
const fallbackCandidates = buildDownloadCandidates(source, searchHit.id, searchHit.url, searchHit.types);
|
||||||
.then(async () => {
|
const fallbackOk = await tryDownloadWithCandidates(song, source, baseName, fallbackCandidates, downloadHeaders);
|
||||||
try {
|
if (fallbackOk) return { ok: true, existed: false, filename: `${baseName}` };
|
||||||
// Try to download cover image
|
|
||||||
let coverPath = null;
|
|
||||||
try {
|
|
||||||
const coverUrl = `${TUNEHUB_API_URL}/api/?source=${source}&id=${song.id}&type=pic`;
|
|
||||||
coverPath = path.join(MUSIC_DIR, `temp_cover_${Date.now()}_${song.id}.jpg`);
|
|
||||||
await downloadFile(coverUrl, coverPath);
|
|
||||||
} catch (e) {
|
|
||||||
console.warn(`[Sync] Failed to download cover for ${song.name}:`, e.message);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write Metadata (Title, Artist, Album, Cover)
|
// 换源回退:kuwo -> qq
|
||||||
await writeMetadata(tempFile, finalFile, {
|
const fallbackSources = ['kuwo', 'qq'].filter(s => s !== source);
|
||||||
title: song.name,
|
for (const fallbackSource of fallbackSources) {
|
||||||
artist: song.artist,
|
console.log(`[Sync] Trying fallback source ${fallbackSource} for ${song.name}`);
|
||||||
album: song.album || song.name,
|
const fallbackHit = await searchSongOnTuneHub(song.name, song.artist, fallbackSource);
|
||||||
cover: coverPath
|
if (!fallbackHit?.id) {
|
||||||
});
|
console.warn(`[Sync] No search hit from ${fallbackSource} for ${song.name}`);
|
||||||
|
continue;
|
||||||
console.log(`[Sync] Downloaded & Tagged: ${baseName}.${ext}`);
|
|
||||||
|
|
||||||
// Cleanup temp files
|
|
||||||
if (fs.existsSync(tempFile)) fs.unlinkSync(tempFile);
|
|
||||||
if (coverPath && fs.existsSync(coverPath)) fs.unlinkSync(coverPath);
|
|
||||||
resolve(true); // Downloaded
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`[Sync] Metadata Error for ${song.name}:`, err.message);
|
|
||||||
// Fallback: Just rename temp to final
|
|
||||||
if (!fs.existsSync(finalFile)) {
|
|
||||||
fs.renameSync(tempFile, finalFile);
|
|
||||||
}
|
|
||||||
resolve(true); // Downloaded (fallback)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
console.error(`[Sync] Download failed for ${song.name}:`, err.message);
|
|
||||||
if (fs.existsSync(tempFile)) fs.unlinkSync(tempFile);
|
|
||||||
resolve(false); // Failed
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle 302 Redirect (Standard API behavior)
|
|
||||||
if (res.statusCode === 302 && res.headers.location) {
|
|
||||||
res.resume();
|
|
||||||
handleDownload(res.headers.location);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (res.statusCode !== 200) {
|
const fallbackHeaders = buildDownloadHeaders(fallbackSource);
|
||||||
console.error(`[Sync] API Request Failed for ${song.name}: Status ${res.statusCode}`);
|
const fallbackCandidates = buildDownloadCandidates(
|
||||||
res.resume();
|
fallbackSource,
|
||||||
resolve(false);
|
fallbackHit.id,
|
||||||
return;
|
fallbackHit.url,
|
||||||
|
fallbackHit.types
|
||||||
|
);
|
||||||
|
|
||||||
|
const fallbackOk = await tryDownloadWithCandidates(
|
||||||
|
song,
|
||||||
|
fallbackSource,
|
||||||
|
baseName,
|
||||||
|
fallbackCandidates,
|
||||||
|
fallbackHeaders
|
||||||
|
);
|
||||||
|
if (fallbackOk) return { ok: true, existed: false, filename: `${baseName}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
let data = '';
|
return { ok: false, existed: false, filename: null }; // Failed
|
||||||
res.on('data', chunk => data += chunk);
|
|
||||||
res.on('end', () => {
|
|
||||||
try {
|
|
||||||
const apiRes = JSON.parse(data);
|
|
||||||
const url = apiRes.url;
|
|
||||||
|
|
||||||
if (!url) {
|
|
||||||
console.log(`[Sync] No URL found for ${song.name}`);
|
|
||||||
resolve(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
handleDownload(url);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('[Sync] API Parse Error:', e);
|
|
||||||
resolve(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}).on('error', (e) => {
|
|
||||||
console.error(`[Sync] API Request Error: ${e.message}`);
|
|
||||||
resolve(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function searchSongInNavidrome(filename) {
|
function normalizeMatchText(str) {
|
||||||
|
return (str || '')
|
||||||
|
.toString()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[\s\-_[\]().·()、]+/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripBracketed(str) {
|
||||||
|
return (str || '')
|
||||||
|
.toString()
|
||||||
|
.replace(/[\(\(\[\【].*?[\)\)\]\】]/g, '')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickSongByPathOrFuzzy(songs, filenameHint, idToken, rawName, rawArtist) {
|
||||||
|
if (!Array.isArray(songs) || songs.length === 0) return null;
|
||||||
|
|
||||||
|
if (filenameHint) {
|
||||||
|
const byPath = songs.find(s => typeof s.path === 'string' && s.path.includes(filenameHint));
|
||||||
|
if (byPath) return byPath.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (idToken) {
|
||||||
|
const byIdToken = songs.find(s => typeof s.path === 'string' && s.path.includes(idToken));
|
||||||
|
if (byIdToken) return byIdToken.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetName = normalizeMatchText(stripBracketed(rawName));
|
||||||
|
const targetArtist = normalizeMatchText(rawArtist);
|
||||||
|
|
||||||
|
const byTitleArtist = songs.find(s =>
|
||||||
|
normalizeMatchText(stripBracketed(s.title)) === targetName &&
|
||||||
|
normalizeMatchText(s.artist) === targetArtist
|
||||||
|
);
|
||||||
|
if (byTitleArtist) return byTitleArtist.id;
|
||||||
|
|
||||||
|
if (songs.length === 1) {
|
||||||
|
const only = songs[0];
|
||||||
|
if (normalizeMatchText(stripBracketed(only.title)) === targetName) {
|
||||||
|
return only.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchSongInNavidrome(song, filenameHint = null) {
|
||||||
try {
|
try {
|
||||||
const result = await callSubsonicAPI('search3', { query: filename });
|
const rawName = (song?.name || '').trim();
|
||||||
|
const rawArtist = (song?.artist || '').trim();
|
||||||
|
const rawAlbum = (song?.album || '').trim();
|
||||||
|
const idToken = song?.id ? `netease_${song.id}` : '';
|
||||||
|
|
||||||
|
const normalize = (str) => (str || '').toString().trim().toLowerCase();
|
||||||
|
const safeName = sanitizeFilename(rawName);
|
||||||
|
const safeArtist = sanitizeFilename(rawArtist);
|
||||||
|
|
||||||
|
const queries = [];
|
||||||
|
if (filenameHint) {
|
||||||
|
const base = filenameHint.replace(/\.[^.]+$/, '');
|
||||||
|
queries.push(base);
|
||||||
|
}
|
||||||
|
if (rawName && rawArtist) queries.push(`${rawName} ${rawArtist}`);
|
||||||
|
if (rawName) queries.push(rawName);
|
||||||
|
const strippedName = stripBracketed(rawName);
|
||||||
|
if (strippedName && strippedName !== rawName) queries.push(strippedName);
|
||||||
|
if (rawArtist && rawAlbum) queries.push(`${rawArtist} ${rawAlbum}`);
|
||||||
|
if (safeName) {
|
||||||
|
const fallbackName = safeArtist ? `${safeArtist} - ${safeName}` : safeName;
|
||||||
|
queries.push(idToken ? `${fallbackName} [${idToken}]` : fallbackName);
|
||||||
|
}
|
||||||
|
|
||||||
|
const seen = new Set();
|
||||||
|
const uniqueQueries = queries.filter(q => {
|
||||||
|
const key = normalize(q);
|
||||||
|
if (!key || seen.has(key)) return false;
|
||||||
|
seen.add(key);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const MAX_ATTEMPTS = 2;
|
||||||
|
|
||||||
|
for (const query of uniqueQueries) {
|
||||||
|
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
||||||
|
const result = await callSubsonicAPI('search3', { query });
|
||||||
if (result.searchResult3?.song) {
|
if (result.searchResult3?.song) {
|
||||||
const songs = Array.isArray(result.searchResult3.song)
|
const songs = Array.isArray(result.searchResult3.song)
|
||||||
? result.searchResult3.song
|
? result.searchResult3.song
|
||||||
: [result.searchResult3.song];
|
: [result.searchResult3.song];
|
||||||
|
|
||||||
for (const song of songs) {
|
const picked = pickSongByPathOrFuzzy(songs, filenameHint, idToken, rawName, rawArtist);
|
||||||
if (song.title && song.path) {
|
if (picked) return picked;
|
||||||
return song.id;
|
}
|
||||||
|
|
||||||
|
if (attempt < MAX_ATTEMPTS) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 兜底:用歌手进行宽松搜索,再按路径或模糊标题匹配
|
||||||
|
if (rawArtist) {
|
||||||
|
const fallbackResult = await callSubsonicAPI('search3', { query: rawArtist });
|
||||||
|
if (fallbackResult.searchResult3?.song) {
|
||||||
|
const songs = Array.isArray(fallbackResult.searchResult3.song)
|
||||||
|
? fallbackResult.searchResult3.song
|
||||||
|
: [fallbackResult.searchResult3.song];
|
||||||
|
const picked = pickSongByPathOrFuzzy(songs, filenameHint, idToken, rawName, rawArtist);
|
||||||
|
if (picked) return picked;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Search song error:', error.message);
|
console.error('Search song error:', error.message);
|
||||||
@@ -468,7 +773,7 @@ async function createNavidromePlaylist(name, songIds = []) {
|
|||||||
try {
|
try {
|
||||||
const result = await callSubsonicAPI('createPlaylist', {
|
const result = await callSubsonicAPI('createPlaylist', {
|
||||||
name: name,
|
name: name,
|
||||||
songId: songIds.join(',')
|
songId: songIds
|
||||||
});
|
});
|
||||||
return result.playlist?.id;
|
return result.playlist?.id;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -479,9 +784,10 @@ async function createNavidromePlaylist(name, songIds = []) {
|
|||||||
|
|
||||||
async function updateNavidromePlaylist(playlistId, songIdsToAdd) {
|
async function updateNavidromePlaylist(playlistId, songIdsToAdd) {
|
||||||
try {
|
try {
|
||||||
|
console.log(`[Sync] Updating Navidrome playlist ${playlistId} with ${songIdsToAdd.length} songs`);
|
||||||
await callSubsonicAPI('updatePlaylist', {
|
await callSubsonicAPI('updatePlaylist', {
|
||||||
playlistId: playlistId,
|
playlistId: playlistId,
|
||||||
songIdToAdd: songIdsToAdd.join(',')
|
songIdToAdd: songIdsToAdd
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Update playlist error:', error.message);
|
console.error('Update playlist error:', error.message);
|
||||||
@@ -489,6 +795,18 @@ async function updateNavidromePlaylist(playlistId, songIdsToAdd) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getNavidromePlaylistSongIds(playlistId) {
|
||||||
|
try {
|
||||||
|
const result = await callSubsonicAPI('getPlaylist', { id: playlistId });
|
||||||
|
const entries = result.playlist?.entry || result.playlist?.song || [];
|
||||||
|
const list = Array.isArray(entries) ? entries : [entries];
|
||||||
|
return list.map(e => e?.id).filter(Boolean);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get playlist error:', error.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function findNavidromePlaylistByName(name) {
|
async function findNavidromePlaylistByName(name) {
|
||||||
try {
|
try {
|
||||||
const result = await callSubsonicAPI('getPlaylists');
|
const result = await callSubsonicAPI('getPlaylists');
|
||||||
@@ -535,6 +853,9 @@ async function syncPlaylist(playlist, cachedInfo = null) {
|
|||||||
const totalTracks = tracks.length;
|
const totalTracks = tracks.length;
|
||||||
let syncedCount = 0;
|
let syncedCount = 0;
|
||||||
let failedCount = 0;
|
let failedCount = 0;
|
||||||
|
const downloadStatus = {};
|
||||||
|
const downloadInfo = {};
|
||||||
|
const unmatchedSongs = [];
|
||||||
|
|
||||||
syncStatus[playlistId] = { status: 'syncing', progress: 10, message: `获取到 ${totalTracks} 首歌曲` };
|
syncStatus[playlistId] = { status: 'syncing', progress: 10, message: `获取到 ${totalTracks} 首歌曲` };
|
||||||
|
|
||||||
@@ -544,7 +865,9 @@ async function syncPlaylist(playlist, cachedInfo = null) {
|
|||||||
artist: track.ar?.[0]?.name || track.artist || 'Unknown',
|
artist: track.ar?.[0]?.name || track.artist || 'Unknown',
|
||||||
album: track.al?.name || track.album || 'Unknown',
|
album: track.al?.name || track.album || 'Unknown',
|
||||||
platform: 'netease',
|
platform: 'netease',
|
||||||
source: 'netease'
|
source: 'netease',
|
||||||
|
url: track.url || '',
|
||||||
|
types: track.types || []
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// --- Integrated Download Logic ---
|
// --- Integrated Download Logic ---
|
||||||
@@ -558,9 +881,15 @@ async function syncPlaylist(playlist, cachedInfo = null) {
|
|||||||
const batch = songs.slice(i, i + BATCH_SIZE);
|
const batch = songs.slice(i, i + BATCH_SIZE);
|
||||||
await Promise.all(batch.map(async (song) => {
|
await Promise.all(batch.map(async (song) => {
|
||||||
try {
|
try {
|
||||||
await processSong(song);
|
const result = await processSong(song);
|
||||||
|
const ok = typeof result === 'object' ? result.ok : !!result;
|
||||||
|
downloadStatus[song.id] = ok;
|
||||||
|
if (typeof result === 'object') {
|
||||||
|
downloadInfo[song.id] = result;
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`[Sync] Failed to process song ${song.name}:`, e);
|
console.error(`[Sync] Failed to process song ${song.name}:`, e);
|
||||||
|
downloadStatus[song.id] = false;
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -591,11 +920,8 @@ async function syncPlaylist(playlist, cachedInfo = null) {
|
|||||||
if (playlist.songMapping && playlist.songMapping[neteaseSongId]) {
|
if (playlist.songMapping && playlist.songMapping[neteaseSongId]) {
|
||||||
navidromeSongId = playlist.songMapping[neteaseSongId];
|
navidromeSongId = playlist.songMapping[neteaseSongId];
|
||||||
} else {
|
} else {
|
||||||
const safeName = sanitizeFilename(song.name);
|
const fileHint = downloadInfo[neteaseSongId]?.filename || null;
|
||||||
const safeArtist = sanitizeFilename(song.artist);
|
navidromeSongId = await searchSongInNavidrome(song, fileHint);
|
||||||
const filename = `${safeArtist} - ${safeName} [netease_${neteaseSongId}]`;
|
|
||||||
|
|
||||||
navidromeSongId = await searchSongInNavidrome(filename);
|
|
||||||
|
|
||||||
if (navidromeSongId) {
|
if (navidromeSongId) {
|
||||||
newSongIds.push(navidromeSongId);
|
newSongIds.push(navidromeSongId);
|
||||||
@@ -612,6 +938,14 @@ async function syncPlaylist(playlist, cachedInfo = null) {
|
|||||||
syncedCount++;
|
syncedCount++;
|
||||||
} else {
|
} else {
|
||||||
failedCount++;
|
failedCount++;
|
||||||
|
unmatchedSongs.push({
|
||||||
|
id: neteaseSongId,
|
||||||
|
name: song.name,
|
||||||
|
artist: song.artist,
|
||||||
|
album: song.album,
|
||||||
|
downloaded: downloadStatus[neteaseSongId] === true,
|
||||||
|
filename: downloadInfo[neteaseSongId]?.filename || null
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const progress = 80 + Math.floor((i + 1) / songs.length * 15); // 80% -> 95%
|
const progress = 80 + Math.floor((i + 1) / songs.length * 15); // 80% -> 95%
|
||||||
@@ -624,6 +958,14 @@ async function syncPlaylist(playlist, cachedInfo = null) {
|
|||||||
|
|
||||||
syncStatus[playlistId] = { status: 'syncing', progress: 95, message: '更新 Navidrome 歌单...' };
|
syncStatus[playlistId] = { status: 'syncing', progress: 95, message: '更新 Navidrome 歌单...' };
|
||||||
|
|
||||||
|
if (unmatchedSongs.length > 0) {
|
||||||
|
const maxLog = 20;
|
||||||
|
const preview = unmatchedSongs.slice(0, maxLog)
|
||||||
|
.map(s => `${s.name} - ${s.artist} (id=${s.id}, downloaded=${s.downloaded}, file=${s.filename || 'n/a'})`)
|
||||||
|
.join(' | ');
|
||||||
|
console.warn(`[Sync] Unmatched songs (${unmatchedSongs.length}): ${preview}${unmatchedSongs.length > maxLog ? ' ...' : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Try to link existing Navidrome playlist by name if ID is missing
|
// 1. Try to link existing Navidrome playlist by name if ID is missing
|
||||||
if (!playlist.navidromePlaylistId) {
|
if (!playlist.navidromePlaylistId) {
|
||||||
console.log(`[Sync] searching for existing Navidrome playlist with name: ${playlist.name}`);
|
console.log(`[Sync] searching for existing Navidrome playlist with name: ${playlist.name}`);
|
||||||
@@ -634,23 +976,38 @@ async function syncPlaylist(playlist, cachedInfo = null) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[Sync] Matched ${allNavidromeIds.length} songs (New: ${newSongIds.length}). PlaylistID: ${playlist.navidromePlaylistId}`);
|
const uniqueAllNavidromeIds = Array.from(new Set(allNavidromeIds));
|
||||||
|
|
||||||
|
console.log(`[Sync] Matched ${uniqueAllNavidromeIds.length} songs (New: ${newSongIds.length}). PlaylistID: ${playlist.navidromePlaylistId}`);
|
||||||
|
|
||||||
if (playlist.navidromePlaylistId) {
|
if (playlist.navidromePlaylistId) {
|
||||||
// Playlist exists (either linked just now or before)
|
// Playlist exists (either linked just now or before)
|
||||||
// Just append new songs if any
|
// Always check existing playlist content and补齐缺失歌曲
|
||||||
if (newSongIds.length > 0) {
|
let missingIds = [];
|
||||||
console.log(`[Sync] Adding ${newSongIds.length} new songs to existing playlist ${playlist.navidromePlaylistId}`);
|
if (uniqueAllNavidromeIds.length > 0) {
|
||||||
await updateNavidromePlaylist(playlist.navidromePlaylistId, newSongIds);
|
const existingIds = await getNavidromePlaylistSongIds(playlist.navidromePlaylistId);
|
||||||
|
if (Array.isArray(existingIds)) {
|
||||||
|
const existingSet = new Set(existingIds.map(id => id?.toString()));
|
||||||
|
missingIds = uniqueAllNavidromeIds.filter(id => !existingSet.has(id?.toString()));
|
||||||
|
console.log(`[Sync] Existing playlist has ${existingIds.length} songs, missing ${missingIds.length}`);
|
||||||
} else {
|
} else {
|
||||||
console.log(`[Sync] No new songs to add to playlist ${playlist.navidromePlaylistId}`);
|
// Fallback: if无法读取,至少追加本次新匹配到的
|
||||||
|
missingIds = newSongIds;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (missingIds.length > 0) {
|
||||||
|
console.log(`[Sync] Adding ${missingIds.length} missing songs to existing playlist ${playlist.navidromePlaylistId}`);
|
||||||
|
await updateNavidromePlaylist(playlist.navidromePlaylistId, missingIds);
|
||||||
|
} else {
|
||||||
|
console.log(`[Sync] No missing songs to add to playlist ${playlist.navidromePlaylistId}`);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Playlist does NOT exist
|
// Playlist does NOT exist
|
||||||
// Create it with ALL matched songs (if any)
|
// Create it with ALL matched songs (if any)
|
||||||
if (allNavidromeIds.length > 0) {
|
if (uniqueAllNavidromeIds.length > 0) {
|
||||||
console.log(`[Sync] Creating new playlist '${playlist.name}' with ${allNavidromeIds.length} songs`);
|
console.log(`[Sync] Creating new playlist '${playlist.name}' with ${uniqueAllNavidromeIds.length} songs`);
|
||||||
const navidromePlaylistId = await createNavidromePlaylist(playlist.name, allNavidromeIds);
|
const navidromePlaylistId = await createNavidromePlaylist(playlist.name, uniqueAllNavidromeIds);
|
||||||
playlist.navidromePlaylistId = navidromePlaylistId;
|
playlist.navidromePlaylistId = navidromePlaylistId;
|
||||||
} else {
|
} else {
|
||||||
// Try creating empty playlist? Subsonic API usually requires songId.
|
// Try creating empty playlist? Subsonic API usually requires songId.
|
||||||
@@ -837,13 +1194,20 @@ app.get('/api/status/:id', (req, res) => {
|
|||||||
res.json(playlist);
|
res.json(playlist);
|
||||||
});
|
});
|
||||||
|
|
||||||
cron.schedule(`*/${SYNC_INTERVAL} * * * *`, () => {
|
function runScheduledSync() {
|
||||||
console.log('[Cron] Starting scheduled sync...');
|
console.log('[Cron] Starting scheduled sync...');
|
||||||
|
|
||||||
for (const playlist of playlists.playlists) {
|
for (const playlist of playlists.playlists) {
|
||||||
syncPlaylist(playlist);
|
syncPlaylist(playlist);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
|
const syncIntervalSeconds = Number.parseInt(SYNC_INTERVAL, 10);
|
||||||
|
if (Number.isFinite(syncIntervalSeconds) && syncIntervalSeconds > 0) {
|
||||||
|
setInterval(runScheduledSync, syncIntervalSeconds * 1000);
|
||||||
|
console.log(`[Cron] Scheduled sync every ${syncIntervalSeconds} seconds`);
|
||||||
|
} else {
|
||||||
|
console.warn(`[Cron] Invalid SYNC_INTERVAL=${SYNC_INTERVAL}, auto sync disabled`);
|
||||||
|
}
|
||||||
|
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
console.log(`Netease-sync server running on port ${PORT}`);
|
console.log(`Netease-sync server running on port ${PORT}`);
|
||||||
|
|||||||
44
index.html
44
index.html
@@ -138,7 +138,9 @@
|
|||||||
<script type="text/babel">
|
<script type="text/babel">
|
||||||
const { useState, useEffect, useRef, useMemo, useCallback } = React;
|
const { useState, useEffect, useRef, useMemo, useCallback } = React;
|
||||||
|
|
||||||
const API_BASE = "https://music-dl.sayqz.com";
|
// Use relative path for API proxy to avoid CORS issues
|
||||||
|
// Nginx forwards /api/ to sync-server, which proxies /music-api to music-dl.sayqz.com
|
||||||
|
const API_BASE = "/music-api";
|
||||||
// Use relative path for sync service, assuming Nginx proxy is configured to forward /api/kv to the sync service
|
// Use relative path for sync service, assuming Nginx proxy is configured to forward /api/kv to the sync service
|
||||||
const SYNC_API_BASE = "/api";
|
const SYNC_API_BASE = "/api";
|
||||||
|
|
||||||
@@ -159,7 +161,8 @@
|
|||||||
|
|
||||||
// --- Utility Functions ---
|
// --- Utility Functions ---
|
||||||
const formatTime = (seconds) => {
|
const formatTime = (seconds) => {
|
||||||
if (!seconds) return "0:00";
|
// 增加保护:处理负数、Infinity、NaN等异常值
|
||||||
|
if (!Number.isFinite(seconds) || seconds < 0) return "0:00";
|
||||||
const mins = Math.floor(seconds / 60);
|
const mins = Math.floor(seconds / 60);
|
||||||
const secs = Math.floor(seconds % 60);
|
const secs = Math.floor(seconds % 60);
|
||||||
return `${mins}:${secs < 10 ? '0' : ''}${secs}`;
|
return `${mins}:${secs < 10 ? '0' : ''}${secs}`;
|
||||||
@@ -232,11 +235,14 @@
|
|||||||
if (audio.seekable && audio.seekable.length) {
|
if (audio.seekable && audio.seekable.length) {
|
||||||
try {
|
try {
|
||||||
const end = audio.seekable.end(audio.seekable.length - 1);
|
const end = audio.seekable.end(audio.seekable.length - 1);
|
||||||
|
// 增加负数检查:iOS锁屏时可能返回负数
|
||||||
if (Number.isFinite(end) && end > 0) return end;
|
if (Number.isFinite(end) && end > 0) return end;
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return getSongDurationSeconds(song);
|
const fallbackDuration = getSongDurationSeconds(song);
|
||||||
|
// 再次确保不会返回负数或异常值
|
||||||
|
return Number.isFinite(fallbackDuration) && fallbackDuration > 0 ? fallbackDuration : 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- ID Normalization ---
|
// --- ID Normalization ---
|
||||||
@@ -252,7 +258,7 @@
|
|||||||
const api = {
|
const api = {
|
||||||
search: async (keyword, source = 'netease', page = 1) => {
|
search: async (keyword, source = 'netease', page = 1) => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE}/api/?type=search&keyword=${encodeURIComponent(keyword)}&source=${source}&page=${page}`);
|
const res = await fetch(`${API_BASE}?type=search&keyword=${encodeURIComponent(keyword)}&source=${source}&page=${page}`);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.code === 200) {
|
if (data.code === 200) {
|
||||||
const payload = data.data || {};
|
const payload = data.data || {};
|
||||||
@@ -265,14 +271,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
getSongUrl: (id, source, br = '320k') => {
|
getSongUrl: (id, source, br = '320k') => {
|
||||||
return `${API_BASE}/api/?source=${source}&id=${id}&type=url&br=${br}`;
|
return `${API_BASE}?source=${source}&id=${id}&type=url&br=${br}`;
|
||||||
},
|
},
|
||||||
getPicUrl: (id, source) => {
|
getPicUrl: (id, source) => {
|
||||||
return `${API_BASE}/api/?source=${source}&id=${id}&type=pic`;
|
return `${API_BASE}?source=${source}&id=${id}&type=pic`;
|
||||||
},
|
},
|
||||||
getLrc: async (id, source) => {
|
getLrc: async (id, source) => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE}/api/?source=${source}&id=${id}&type=lrc`);
|
const res = await fetch(`${API_BASE}?source=${source}&id=${id}&type=lrc`);
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
return text;
|
return text;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -305,7 +311,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE}/api/?source=${source}&type=toplists`);
|
const res = await fetch(`${API_BASE}/?source=${source}&type=toplists`);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.code === 200) {
|
if (data.code === 200) {
|
||||||
// 兼容多种返回结构:data本身是数组,或者data.list是数组
|
// 兼容多种返回结构:data本身是数组,或者data.list是数组
|
||||||
@@ -322,7 +328,7 @@
|
|||||||
},
|
},
|
||||||
getTopListSongs: async (id, source) => {
|
getTopListSongs: async (id, source) => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE}/api/?source=${source}&id=${id}&type=toplist`);
|
const res = await fetch(`${API_BASE}/?source=${source}&id=${id}&type=toplist`);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.code === 200) {
|
if (data.code === 200) {
|
||||||
const list = Array.isArray(data.data) ? data.data : (data.data.list || data.data.tracks || []);
|
const list = Array.isArray(data.data) ? data.data : (data.data.list || data.data.tracks || []);
|
||||||
@@ -336,7 +342,7 @@
|
|||||||
},
|
},
|
||||||
getPlaylist: async (id, source = 'netease') => {
|
getPlaylist: async (id, source = 'netease') => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE}/api/?type=playlist&id=${id}&source=${source}`);
|
const res = await fetch(`${API_BASE}/?type=playlist&id=${id}&source=${source}`);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.code === 200 && data.data && Array.isArray(data.data.list)) {
|
if (data.code === 200 && data.data && Array.isArray(data.data.list)) {
|
||||||
return normalizeSongList(data.data.list);
|
return normalizeSongList(data.data.list);
|
||||||
@@ -967,6 +973,8 @@
|
|||||||
const playAudioWithFallback = (audio, options = {}) => {
|
const playAudioWithFallback = (audio, options = {}) => {
|
||||||
if (!audio) return;
|
if (!audio) return;
|
||||||
const { deferOnIOS = false } = options;
|
const { deferOnIOS = false } = options;
|
||||||
|
const isHidden = typeof document !== 'undefined' && document.hidden;
|
||||||
|
const shouldDefer = deferOnIOS && IS_IOS && !isHidden;
|
||||||
const doPlay = () => {
|
const doPlay = () => {
|
||||||
const playPromise = audio.play();
|
const playPromise = audio.play();
|
||||||
if (playPromise && typeof playPromise.catch === 'function') {
|
if (playPromise && typeof playPromise.catch === 'function') {
|
||||||
@@ -978,7 +986,7 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (deferOnIOS && IS_IOS) {
|
if (shouldDefer) {
|
||||||
const onCanPlay = () => {
|
const onCanPlay = () => {
|
||||||
audio.removeEventListener('canplay', onCanPlay);
|
audio.removeEventListener('canplay', onCanPlay);
|
||||||
doPlay();
|
doPlay();
|
||||||
@@ -1221,20 +1229,24 @@
|
|||||||
const triggerAutoNext = () => {
|
const triggerAutoNext = () => {
|
||||||
if (autoAdvanceLockRef.current) return;
|
if (autoAdvanceLockRef.current) return;
|
||||||
autoAdvanceLockRef.current = true;
|
autoAdvanceLockRef.current = true;
|
||||||
playNext(true, { immediate: !IS_IOS, deferOnIOS: IS_IOS });
|
const isHidden = typeof document !== 'undefined' && document.hidden;
|
||||||
|
const immediate = IS_IOS || isHidden;
|
||||||
|
playNext(true, { immediate, deferOnIOS: IS_IOS });
|
||||||
};
|
};
|
||||||
|
|
||||||
const isNearEnd = () => {
|
const isNearEnd = () => {
|
||||||
const durationSeconds = resolveDurationSeconds(audio, currentSongRef.current);
|
const durationSeconds = resolveDurationSeconds(audio, currentSongRef.current);
|
||||||
if (!Number.isFinite(durationSeconds) || durationSeconds <= 0) return false;
|
if (!Number.isFinite(durationSeconds) || durationSeconds <= 0) return false;
|
||||||
const threshold = IS_IOS ? 0.15 : 0.35;
|
// iOS锁屏时timeupdate频率降低,需要更大的提前量
|
||||||
|
const threshold = IS_IOS ? 0.5 : 0.35;
|
||||||
return audio.currentTime >= durationSeconds - threshold;
|
return audio.currentTime >= durationSeconds - threshold;
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateTime = () => {
|
const updateTime = () => {
|
||||||
setCurrentTime(audio.currentTime);
|
setCurrentTime(audio.currentTime);
|
||||||
if (autoAdvanceLockRef.current) return;
|
if (autoAdvanceLockRef.current) return;
|
||||||
if (!IS_IOS && isNearEnd()) triggerAutoNext();
|
// 移除iOS限制:所有平台都使用timeupdate检查,解决iOS锁屏时ended事件不触发的问题
|
||||||
|
if (isNearEnd()) triggerAutoNext();
|
||||||
};
|
};
|
||||||
const updateDuration = () => setDuration(resolveDurationSeconds(audio, currentSongRef.current));
|
const updateDuration = () => setDuration(resolveDurationSeconds(audio, currentSongRef.current));
|
||||||
const onEnded = () => triggerAutoNext();
|
const onEnded = () => triggerAutoNext();
|
||||||
@@ -1377,7 +1389,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (auto) {
|
if (auto) {
|
||||||
autoNextPendingRef.current = true;
|
autoNextPendingRef.current = !options.immediate;
|
||||||
|
} else {
|
||||||
|
autoNextPendingRef.current = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
let nextIdx;
|
let nextIdx;
|
||||||
|
|||||||
@@ -10,16 +10,32 @@ server {
|
|||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 音乐 API 代理 (解决 HTTPS 握手和 Host 问题)
|
||||||
|
location /music-api {
|
||||||
|
# 将 /music-api 映射到 /api
|
||||||
|
# 例如: /music-api?source=... -> https://music-dl.sayqz.com/api?source=...
|
||||||
|
rewrite ^/music-api/?(.*)$ /api/$1 break;
|
||||||
|
|
||||||
|
proxy_pass https://music-dl.sayqz.com;
|
||||||
|
|
||||||
|
# [关键] 开启 SSL Server Name Indication (SNI),否则 HTTPS 握手会失败
|
||||||
|
proxy_ssl_server_name on;
|
||||||
|
|
||||||
|
# [关键] 设置正确的 Host 头,或者直接删掉这行让 Nginx 自动使用 proxy_pass 的域名
|
||||||
|
# 千万不要设置为 $host,因为对方服务器不认识你的域名
|
||||||
|
proxy_set_header Host music-dl.sayqz.com;
|
||||||
|
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
}
|
||||||
|
|
||||||
# 同步服务 API 代理
|
# 同步服务 API 代理
|
||||||
# 将 /api/kv/... 转发到 sync-service 的 7482 端口
|
|
||||||
# 注意:这里去掉了 /api 前缀,因为 sync-service 直接监听 /kv
|
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://127.0.0.1:7482/;
|
proxy_pass http://127.0.0.1:7482/;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
|
||||||
# 允许跨域 (如果前端和后端不在同一个域)
|
|
||||||
add_header 'Access-Control-Allow-Origin' '*';
|
add_header 'Access-Control-Allow-Origin' '*';
|
||||||
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
|
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
|
||||||
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
|
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
|
||||||
|
|||||||
@@ -32,6 +32,16 @@ const server = http.createServer((req, res) => {
|
|||||||
const parsedUrl = url.parse(req.url, true);
|
const parsedUrl = url.parse(req.url, true);
|
||||||
const pathname = parsedUrl.pathname;
|
const pathname = parsedUrl.pathname;
|
||||||
|
|
||||||
|
// --- Music API Proxy ---
|
||||||
|
// Proxy requests to music-dl.sayqz.com to avoid CORS issues
|
||||||
|
// if (pathname === '/music-api' || pathname === '/music-api/') {
|
||||||
|
// const queryString = parsedUrl.search || '';
|
||||||
|
// const targetUrl = `https://music-dl.sayqz.com/api/${queryString}`;
|
||||||
|
|
||||||
|
// proxyRequest(targetUrl, req, res);
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
|
||||||
// Path format: /kv/:key?token=...
|
// Path format: /kv/:key?token=...
|
||||||
const match = pathname.match(/^\/kv\/([a-zA-Z0-9_-]+)$/);
|
const match = pathname.match(/^\/kv\/([a-zA-Z0-9_-]+)$/);
|
||||||
|
|
||||||
@@ -298,6 +308,224 @@ function downloadFile(url, dest) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Music API Proxy Function ---
|
||||||
|
function proxyRequest(targetUrl, req, res) {
|
||||||
|
const parsedTarget = url.parse(targetUrl, true);
|
||||||
|
const requestType = parsedTarget.query && parsedTarget.query.type;
|
||||||
|
const isPicRequest = requestType === 'pic';
|
||||||
|
const isUrlRequest = requestType === 'url';
|
||||||
|
const maxRedirects = 5;
|
||||||
|
|
||||||
|
const buildHeaders = () => {
|
||||||
|
const headers = {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||||
|
'Accept': '*/*',
|
||||||
|
'Accept-Encoding': 'identity'
|
||||||
|
};
|
||||||
|
if (req.headers.range) headers['Range'] = req.headers.range;
|
||||||
|
if (req.headers['if-range']) headers['If-Range'] = req.headers['if-range'];
|
||||||
|
return headers;
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestStream = (nextUrl, redirectCount = 0) => {
|
||||||
|
if (redirectCount > maxRedirects) {
|
||||||
|
res.writeHead(502, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: 'Too many redirects' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedNext = url.parse(nextUrl);
|
||||||
|
const isHttps = parsedNext.protocol === 'https:';
|
||||||
|
const requestModule = isHttps ? https : http;
|
||||||
|
const options = {
|
||||||
|
hostname: parsedNext.hostname,
|
||||||
|
port: parsedNext.port || (isHttps ? 443 : 80),
|
||||||
|
path: parsedNext.path,
|
||||||
|
method: req.method,
|
||||||
|
headers: buildHeaders()
|
||||||
|
};
|
||||||
|
|
||||||
|
const proxyReq = requestModule.request(options, (proxyRes) => {
|
||||||
|
if (proxyRes.statusCode >= 300 && proxyRes.statusCode < 400 && proxyRes.headers.location) {
|
||||||
|
const resolved = url.resolve(nextUrl, proxyRes.headers.location);
|
||||||
|
proxyRes.resume();
|
||||||
|
requestStream(resolved, redirectCount + 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = { ...proxyRes.headers };
|
||||||
|
delete headers['content-encoding'];
|
||||||
|
delete headers['transfer-encoding'];
|
||||||
|
res.writeHead(proxyRes.statusCode || 200, headers);
|
||||||
|
proxyRes.pipe(res);
|
||||||
|
});
|
||||||
|
|
||||||
|
proxyReq.on('error', (e) => {
|
||||||
|
console.error('[Proxy] Stream error:', e.message);
|
||||||
|
res.writeHead(502, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: 'Proxy stream failed', message: e.message }));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (req.method === 'POST') {
|
||||||
|
req.pipe(proxyReq);
|
||||||
|
} else {
|
||||||
|
proxyReq.end();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
hostname: parsedTarget.hostname,
|
||||||
|
port: 443,
|
||||||
|
path: parsedTarget.path,
|
||||||
|
method: req.method,
|
||||||
|
headers: buildHeaders()
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isUrlRequest) {
|
||||||
|
const proxyReq = https.request(options, (proxyRes) => {
|
||||||
|
if (proxyRes.statusCode >= 300 && proxyRes.statusCode < 400 && proxyRes.headers.location) {
|
||||||
|
const resolved = url.resolve(targetUrl, proxyRes.headers.location);
|
||||||
|
proxyRes.resume();
|
||||||
|
requestStream(resolved, 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = proxyRes.headers['content-type'] || '';
|
||||||
|
const shouldInspectBody = contentType.includes('application/json') || contentType.startsWith('text/');
|
||||||
|
|
||||||
|
if (shouldInspectBody) {
|
||||||
|
let body = '';
|
||||||
|
proxyRes.setEncoding('utf8');
|
||||||
|
proxyRes.on('data', chunk => body += chunk);
|
||||||
|
proxyRes.on('end', () => {
|
||||||
|
try {
|
||||||
|
const parsedBody = JSON.parse(body);
|
||||||
|
const extractUrl = (payload) => {
|
||||||
|
if (!payload || typeof payload !== 'object') return null;
|
||||||
|
if (typeof payload.url === 'string') return payload.url;
|
||||||
|
if (typeof payload.data === 'string') return payload.data;
|
||||||
|
if (payload.data && typeof payload.data === 'object') {
|
||||||
|
if (typeof payload.data.url === 'string') return payload.data.url;
|
||||||
|
if (typeof payload.data.link === 'string') return payload.data.link;
|
||||||
|
if (payload.data.data && typeof payload.data.data.url === 'string') return payload.data.data.url;
|
||||||
|
if (Array.isArray(payload.data) && payload.data[0] && typeof payload.data[0].url === 'string') {
|
||||||
|
return payload.data[0].url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (payload.result && typeof payload.result.url === 'string') return payload.result.url;
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
const resolvedUrl = extractUrl(parsedBody);
|
||||||
|
if (resolvedUrl) {
|
||||||
|
requestStream(url.resolve(targetUrl, resolvedUrl), 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// fall through to return original body
|
||||||
|
}
|
||||||
|
|
||||||
|
res.writeHead(proxyRes.statusCode || 200, {
|
||||||
|
'Content-Type': proxyRes.headers['content-type'] || 'application/json'
|
||||||
|
});
|
||||||
|
res.end(body);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = { ...proxyRes.headers };
|
||||||
|
delete headers['content-encoding'];
|
||||||
|
delete headers['transfer-encoding'];
|
||||||
|
res.writeHead(proxyRes.statusCode || 200, headers);
|
||||||
|
proxyRes.pipe(res);
|
||||||
|
});
|
||||||
|
|
||||||
|
proxyReq.on('error', (e) => {
|
||||||
|
console.error('[Proxy] Request error:', e.message);
|
||||||
|
res.writeHead(502, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: 'Proxy request failed', message: e.message }));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (req.method === 'POST') {
|
||||||
|
req.pipe(proxyReq);
|
||||||
|
} else {
|
||||||
|
proxyReq.end();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const proxyReq = https.request(options, (proxyRes) => {
|
||||||
|
const passThrough = () => {
|
||||||
|
const headers = { ...proxyRes.headers };
|
||||||
|
delete headers['content-encoding'];
|
||||||
|
delete headers['transfer-encoding'];
|
||||||
|
|
||||||
|
res.writeHead(proxyRes.statusCode, headers);
|
||||||
|
proxyRes.pipe(res);
|
||||||
|
};
|
||||||
|
|
||||||
|
// pic endpoint now returns JSON, turn it into a redirect for <img>
|
||||||
|
if (isPicRequest) {
|
||||||
|
if (proxyRes.statusCode >= 300 && proxyRes.statusCode < 400 && proxyRes.headers.location) {
|
||||||
|
res.writeHead(302, { Location: proxyRes.headers.location });
|
||||||
|
proxyRes.resume();
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = proxyRes.headers['content-type'] || '';
|
||||||
|
if (contentType.startsWith('image/')) {
|
||||||
|
passThrough();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let body = '';
|
||||||
|
proxyRes.setEncoding('utf8');
|
||||||
|
proxyRes.on('data', chunk => body += chunk);
|
||||||
|
proxyRes.on('end', () => {
|
||||||
|
try {
|
||||||
|
const parsedBody = JSON.parse(body);
|
||||||
|
if (parsedBody && parsedBody.url) {
|
||||||
|
res.writeHead(302, { Location: parsedBody.url });
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// fall through
|
||||||
|
}
|
||||||
|
|
||||||
|
res.writeHead(proxyRes.statusCode || 200, {
|
||||||
|
'Content-Type': proxyRes.headers['content-type'] || 'application/json'
|
||||||
|
});
|
||||||
|
res.end(body);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle redirects
|
||||||
|
if (proxyRes.statusCode >= 300 && proxyRes.statusCode < 400 && proxyRes.headers.location) {
|
||||||
|
// For redirects, return the redirect URL to client
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ url: proxyRes.headers.location }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
passThrough();
|
||||||
|
});
|
||||||
|
|
||||||
|
proxyReq.on('error', (e) => {
|
||||||
|
console.error('[Proxy] Request error:', e.message);
|
||||||
|
res.writeHead(502, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: 'Proxy request failed', message: e.message }));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Forward request body for POST requests
|
||||||
|
if (req.method === 'POST') {
|
||||||
|
req.pipe(proxyReq);
|
||||||
|
} else {
|
||||||
|
proxyReq.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function writeMetadata(inputPath, outputPath, metadata) {
|
function writeMetadata(inputPath, outputPath, metadata) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const args = ['-i', inputPath];
|
const args = ['-i', inputPath];
|
||||||
|
|||||||
Reference in New Issue
Block a user