Files

286 lines
11 KiB
JavaScript

const form = document.querySelector('#search-form');
const keyword = document.querySelector('#keyword');
const notice = document.querySelector('#notice');
const results = document.querySelector('#results');
const modeNote = document.querySelector('#mode-note');
const localDownloads = document.querySelector('#local-downloads');
const lyricCache = new Map();
function text(value) { return document.createTextNode(value ?? ''); }
function hasText(value) { return typeof value === 'string' && value.trim().length > 0; }
function safeName(value) { return (value || 'unknown').replace(/[\\/:*?"<>|\u0000-\u001f]/g, '_').trim().slice(0, 180) || 'unknown'; }
function duration(seconds) { const n = Number(seconds || 0); return n ? `${Math.floor(n / 60)}:${String(n % 60).padStart(2, '0')}` : ''; }
function plainText(lrc) { return (lrc || '').split(/\r?\n/).map(line => line.replace(/\[[^\]]*\]/g, '').trim()).filter(Boolean).join('\n'); }
async function api(path, payload) {
const response = await fetch(path, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload) });
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.error || `请求失败 (${response.status})`);
return data;
}
async function loadInfo() {
try {
const response = await fetch('/api/info');
const info = await response.json();
if (info.mode === 'local') {
modeNote.textContent = '本机模式:查询从这台电脑直接发出,不经过 lrc.flechazo.xin。';
} else {
modeNote.textContent = '网页模式:查询由本站后端转发,但不会写入数据库或日志正文。下载文件在浏览器中生成。';
localDownloads.hidden = false;
}
} catch {
modeNote.textContent = '无法确认运行模式,请刷新页面重试。';
}
}
function download(name, body, type = 'text/plain;charset=utf-8') {
const url = URL.createObjectURL(new Blob([body], {type}));
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = name;
anchor.hidden = true;
document.body.append(anchor);
anchor.click();
anchor.remove();
setTimeout(() => URL.revokeObjectURL(url), 1500);
}
function parseLRC(lrc) {
const lines = [];
for (const raw of (lrc || '').split(/\r?\n/)) {
const stamps = [...raw.matchAll(/\[(\d+):(\d+)(?:[.:](\d{1,3}))?\]/g)];
if (!stamps.length) continue;
const content = raw.replace(/\[[^\]]*\]/g, '').trim();
if (!content) continue;
for (const stamp of stamps) {
const fraction = stamp[3] || '0';
const milliseconds = Number(fraction.padEnd(3, '0').slice(0, 3));
lines.push({time: Number(stamp[1]) * 60000 + Number(stamp[2]) * 1000 + milliseconds, content});
}
}
return lines.sort((a, b) => a.time - b.time);
}
function timestamp(milliseconds) {
const minutes = Math.floor(milliseconds / 60000);
const seconds = Math.floor(milliseconds % 60000 / 1000);
const millis = milliseconds % 1000;
return `[${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}.${String(millis).padStart(3, '0')}]`;
}
function alignTrack(base, extra) {
const aligned = new Array(base.length).fill('');
let cursor = 0;
for (let index = 0; index < base.length && cursor < extra.length; index += 1) {
while (cursor + 1 < extra.length && Math.abs(extra[cursor + 1].time - base[index].time) <= Math.abs(extra[cursor].time - base[index].time)) cursor += 1;
if (Math.abs(extra[cursor].time - base[index].time) <= 1800) {
aligned[index] = extra[cursor].content;
cursor += 1;
}
}
return aligned;
}
function previewText(data, mode) {
const original = parseLRC(data.lyric);
if (!original.length) return data.lyric || '';
const translations = mode.includes('trans') ? alignTrack(original, parseLRC(data.trans)) : [];
const phonetics = mode.includes('roma') ? alignTrack(original, parseLRC(data.roma)) : [];
const output = [];
original.forEach((line, index) => {
output.push(`${timestamp(line.time)}${line.content}`);
if (translations[index]) output.push(` 译:${translations[index]}`);
if (phonetics[index]) output.push(` 音:${phonetics[index]}`);
});
return output.join('\n');
}
function cachedLyrics(song) {
if (!lyricCache.has(song.mid)) {
const request = api('/api/lyrics', {mid: song.mid}).catch(error => {
lyricCache.delete(song.mid);
throw error;
});
lyricCache.set(song.mid, request);
}
return lyricCache.get(song.mid);
}
function actionButton(label, primary, handler) {
const button = document.createElement('button');
button.type = 'button';
if (primary) button.className = 'primary';
button.append(text(label));
button.addEventListener('click', handler);
return button;
}
function saveLyrics(song, data, kind) {
const base = safeName(`${song.name} - ${song.singer}`);
const variants = {
lrc: [`${base}.lrc`, data.lyric],
txt: [`${base}.txt`, plainText(data.lyric)],
transLRC: [`${base}.trans.lrc`, data.trans],
transTXT: [`${base}.trans.txt`, plainText(data.trans)],
romaLRC: [`${base}.sound.lrc`, data.roma],
romaTXT: [`${base}.sound.txt`, plainText(data.roma)]
};
const selected = variants[kind];
if (!selected || !hasText(selected[1])) throw new Error('这首歌没有对应歌词');
download(selected[0], selected[1]);
}
function populatePreview(select, output, data) {
const current = select.value;
select.replaceChildren();
const choices = [['original', '仅原文']];
if (hasText(data.trans)) choices.push(['trans', '原文 + 翻译']);
if (hasText(data.roma)) choices.push(['roma', '原文 + 音']);
if (hasText(data.trans) && hasText(data.roma)) choices.push(['trans-roma', '原文 + 翻译 + 音']);
choices.forEach(([value, label]) => {
const option = document.createElement('option');
option.value = value;
option.append(text(label));
select.append(option);
});
if ([...select.options].some(option => option.value === current)) select.value = current;
output.textContent = previewText(data, select.value);
}
function renderSongActions(song, actions, data) {
actions.replaceChildren();
const definitions = [['lrc', '下载 LRC', true], ['txt', '原文 TXT', false]];
if (hasText(data.trans)) definitions.push(['transLRC', '翻译 LRC', false], ['transTXT', '翻译 TXT', false]);
if (hasText(data.roma)) definitions.push(['romaLRC', '音 LRC', false], ['romaTXT', '音 TXT', false]);
definitions.forEach(([kind, label, primary]) => {
actions.append(actionButton(label, primary, () => {
try {
saveLyrics(song, data, kind);
notice.className = 'notice';
notice.textContent = '文件已在浏览器中生成。';
} catch (error) {
notice.className = 'notice error';
notice.textContent = error.message;
}
}));
});
}
function renderSongs(songs) {
results.replaceChildren();
songs.forEach((song, index) => {
const article = document.createElement('article');
article.className = 'song';
const top = document.createElement('div');
top.className = 'song-top';
const info = document.createElement('div');
info.className = 'song-info';
const title = document.createElement('h2');
title.append(text(`${String(index + 1).padStart(2, '0')} ${song.name}`));
const meta = document.createElement('div');
meta.className = 'meta';
meta.append(text([song.singer, song.album ? `《${song.album}》` : '', duration(song.interval)].filter(Boolean).join(' · ')));
info.append(title, meta);
const actions = document.createElement('div');
actions.className = 'actions';
const details = document.createElement('details');
details.className = 'preview';
const summary = document.createElement('summary');
summary.append(text('完整歌词预览'));
const previewBody = document.createElement('div');
previewBody.className = 'preview-body';
const previewStatus = document.createElement('p');
previewStatus.className = 'preview-status';
previewStatus.append(text('展开后读取歌词。'));
const controls = document.createElement('label');
controls.className = 'preview-controls';
controls.hidden = true;
controls.append(text('预览内容 '));
const select = document.createElement('select');
controls.append(select);
const output = document.createElement('pre');
output.className = 'lyric-preview';
output.hidden = true;
previewBody.append(previewStatus, controls, output);
details.append(summary, previewBody);
let loadedData = null;
const load = async trigger => {
if (loadedData) return loadedData;
if (trigger) trigger.disabled = true;
previewStatus.textContent = `正在读取《${song.name}》…`;
notice.className = 'notice';
notice.textContent = `正在读取《${song.name}》…`;
try {
loadedData = await cachedLyrics(song);
renderSongActions(song, actions, loadedData);
populatePreview(select, output, loadedData);
controls.hidden = false;
output.hidden = false;
previewStatus.hidden = true;
notice.textContent = '歌词已读取;翻译和“音”按钮只在存在对应内容时显示。';
return loadedData;
} catch (error) {
previewStatus.hidden = false;
previewStatus.textContent = error.message;
notice.className = 'notice error';
notice.textContent = error.message;
if (details.open) details.open = false;
throw error;
} finally {
if (trigger) trigger.disabled = false;
}
};
const initialLRC = actionButton('下载 LRC', true, async () => {
try {
const data = await load(initialLRC);
saveLyrics(song, data, 'lrc');
notice.textContent = '文件已在浏览器中生成。';
} catch { /* load already reports the error */ }
});
const initialTXT = actionButton('原文 TXT', false, async () => {
try {
const data = await load(initialTXT);
saveLyrics(song, data, 'txt');
notice.textContent = '文件已在浏览器中生成。';
} catch { /* load already reports the error */ }
});
actions.append(initialLRC, initialTXT);
details.addEventListener('toggle', () => {
if (details.open && !loadedData) load().catch(() => {});
});
select.addEventListener('change', () => {
if (loadedData) output.textContent = previewText(loadedData, select.value);
});
top.append(info, actions);
article.append(top, details);
results.append(article);
});
}
form.addEventListener('submit', async event => {
event.preventDefault();
const submit = form.querySelector('button');
submit.disabled = true;
notice.className = 'notice';
notice.textContent = '正在搜索…';
results.replaceChildren();
lyricCache.clear();
try {
const data = await api('/api/search', {keyword: keyword.value.trim(), limit: 15});
renderSongs(data.songs || []);
notice.textContent = data.songs?.length ? `找到 ${data.songs.length} 条结果。展开预览或下载时才会读取歌词。` : '没有找到相关歌曲。';
} catch (error) {
notice.className = 'notice error';
notice.textContent = error.message;
} finally {
submit.disabled = false;
}
});
loadInfo();