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();