Files
CeruMusic/playlist-converter.html

693 lines
19 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>歌单格式转换器</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.1.1/crypto-js.min.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1200px;
margin: 0 auto;
background: white;
border-radius: 20px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.header {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
color: white;
padding: 30px;
text-align: center;
}
.header h1 {
font-size: 2.5em;
margin-bottom: 10px;
font-weight: 300;
}
.header p {
font-size: 1.1em;
opacity: 0.9;
}
.content {
padding: 40px;
}
.upload-section {
background: #f8f9ff;
border: 3px dashed #4facfe;
border-radius: 15px;
padding: 40px;
text-align: center;
margin-bottom: 30px;
transition: all 0.3s ease;
cursor: pointer;
}
.upload-section:hover {
border-color: #00f2fe;
background: #f0f8ff;
transform: translateY(-2px);
}
.upload-section.dragover {
border-color: #00f2fe;
background: #e8f4ff;
transform: scale(1.02);
}
.upload-icon {
font-size: 4em;
color: #4facfe;
margin-bottom: 20px;
}
.upload-text {
font-size: 1.3em;
color: #333;
margin-bottom: 10px;
}
.upload-subtext {
color: #666;
font-size: 0.9em;
}
.file-input {
display: none;
}
.preview-section {
display: none;
margin-bottom: 30px;
}
.preview-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.preview-title {
font-size: 1.5em;
color: #333;
font-weight: 500;
}
.song-count {
background: #4facfe;
color: white;
padding: 5px 15px;
border-radius: 20px;
font-size: 0.9em;
}
.preview-content {
background: #f8f9ff;
border-radius: 15px;
padding: 20px;
max-height: 400px;
overflow-y: auto;
}
.song-item {
background: white;
border-radius: 10px;
padding: 15px;
margin-bottom: 10px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
display: flex;
align-items: center;
transition: all 0.3s ease;
}
.song-item:hover {
transform: translateX(5px);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
}
.song-number {
background: #4facfe;
color: white;
width: 30px;
height: 30px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
margin-right: 15px;
font-size: 0.9em;
}
.song-info {
flex: 1;
}
.song-name {
font-weight: 600;
color: #333;
margin-bottom: 5px;
}
.song-artist {
color: #666;
font-size: 0.9em;
}
.controls {
display: flex;
gap: 15px;
margin-bottom: 30px;
flex-wrap: wrap;
}
.btn {
padding: 12px 24px;
border: none;
border-radius: 25px;
font-size: 1em;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
align-items: center;
gap: 8px;
}
.btn-primary {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
color: white;
}
.btn-secondary {
background: #f1f3f4;
color: #333;
}
.btn-success {
background: linear-gradient(135deg, #56ab2f 0%, #a8e6cf 100%);
color: white;
}
.btn:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.result-section {
display: none;
background: #f8f9ff;
border-radius: 15px;
padding: 30px;
text-align: center;
}
.result-icon {
font-size: 4em;
margin-bottom: 20px;
}
.result-success {
color: #28a745;
}
.result-error {
color: #dc3545;
}
.result-message {
font-size: 1.2em;
margin-bottom: 20px;
color: #333;
}
.download-link {
display: inline-block;
background: linear-gradient(135deg, #56ab2f 0%, #a8e6cf 100%);
color: white;
padding: 12px 30px;
border-radius: 25px;
text-decoration: none;
font-weight: 500;
transition: all 0.3s ease;
}
.download-link:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
}
.loading {
display: none;
text-align: center;
padding: 40px;
}
.spinner {
border: 4px solid #f3f3f3;
border-top: 4px solid #4facfe;
border-radius: 50%;
width: 50px;
height: 50px;
animation: spin 1s linear infinite;
margin: 0 auto 20px;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.error-details {
background: #fff5f5;
border: 1px solid #fed7d7;
border-radius: 10px;
padding: 15px;
margin-top: 15px;
text-align: left;
font-family: 'Courier New', monospace;
font-size: 0.9em;
color: #c53030;
max-height: 200px;
overflow-y: auto;
}
@media (max-width: 768px) {
.container {
margin: 10px;
border-radius: 15px;
}
.content {
padding: 20px;
}
.controls {
flex-direction: column;
}
.btn {
width: 100%;
justify-content: center;
}
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🎵 澜音歌单格式转换器</h1>
<p>将洛雪音乐平台的歌单转换为澜音音乐的加密格式</p>
</div>
<div class="content">
<div class="upload-section" id="uploadSection">
<div class="upload-icon">📁</div>
<div class="upload-text">点击或拖拽洛雪歌单文件到此处</div>
<div class="upload-subtext">支持 *.json 和 *.lxmc 格式的歌单文件</div>
<input type="file" id="fileInput" class="file-input" accept=".json,.lxmc" />
</div>
<div class="preview-section" id="previewSection">
<div class="preview-header">
<div class="preview-title">歌单预览</div>
<div class="song-count" id="songCount">0 首歌曲</div>
</div>
<div class="preview-content" id="previewContent">
<!-- 歌曲列表将在这里显示 -->
</div>
</div>
<div class="controls" id="controls" style="display: none">
<button class="btn btn-primary" id="convertBtn">
<span>🔐</span>
转换并加密
</button>
<button class="btn btn-secondary" id="resetBtn">
<span>🔄</span>
重新选择
</button>
</div>
<div class="loading" id="loadingSection">
<div class="spinner"></div>
<div>正在转换中...</div>
</div>
<div class="result-section" id="resultSection">
<div class="result-icon" id="resultIcon"></div>
<div class="result-message" id="resultMessage"></div>
<a href="#" class="download-link" id="downloadLink" style="display: none">
<span>📥</span>
下载澜音歌单
</a>
<div class="error-details" id="errorDetails" style="display: none"></div>
</div>
</div>
</div>
<script>
// 全局变量
let currentPlaylist = null
let convertedPlaylist = null
const SECRET_KEY = 'CeruMusic-PlaylistSecretKey'
// Gzip解压函数
async function decompressGzip(arrayBuffer) {
try {
// 使用浏览器的 DecompressionStream API
const stream = new Response(arrayBuffer).body.pipeThrough(new DecompressionStream('gzip'))
const decompressed = await new Response(stream).arrayBuffer()
return new TextDecoder().decode(decompressed)
} catch (error) {
throw new Error('Gzip解压失败: ' + error.message)
}
}
// DOM元素
const uploadSection = document.getElementById('uploadSection')
const fileInput = document.getElementById('fileInput')
const previewSection = document.getElementById('previewSection')
const previewContent = document.getElementById('previewContent')
const songCount = document.getElementById('songCount')
const controls = document.getElementById('controls')
const convertBtn = document.getElementById('convertBtn')
const resetBtn = document.getElementById('resetBtn')
const loadingSection = document.getElementById('loadingSection')
const resultSection = document.getElementById('resultSection')
const resultIcon = document.getElementById('resultIcon')
const resultMessage = document.getElementById('resultMessage')
const downloadLink = document.getElementById('downloadLink')
const errorDetails = document.getElementById('errorDetails')
// 文件上传处理
uploadSection.addEventListener('click', () => fileInput.click())
uploadSection.addEventListener('dragover', (e) => {
e.preventDefault()
uploadSection.classList.add('dragover')
})
uploadSection.addEventListener('dragleave', () => {
uploadSection.classList.remove('dragover')
})
uploadSection.addEventListener('drop', (e) => {
e.preventDefault()
uploadSection.classList.remove('dragover')
const files = e.dataTransfer.files
if (files.length > 0) {
handleFile(files[0])
}
})
fileInput.addEventListener('change', (e) => {
const file = e.target.files[0]
if (file) {
handleFile(file)
}
})
// 按钮事件
convertBtn.addEventListener('click', convertPlaylist)
resetBtn.addEventListener('click', resetConverter)
// 处理文件
function handleFile(file) {
const fileName = file.name.toLowerCase()
if (fileName.endsWith('.json')) {
// 处理JSON文件
const reader = new FileReader()
reader.onload = (e) => {
try {
const content = JSON.parse(e.target.result)
currentPlaylist = content
displayPreview(content)
} catch (error) {
showError('JSON文件解析失败: ' + error.message)
}
}
reader.readAsText(file)
} else if (fileName.endsWith('.lxmc')) {
// 处理LXMC文件gzip压缩
const reader = new FileReader()
reader.onload = async (e) => {
try {
const arrayBuffer = e.target.result
const decompressed = await decompressGzip(arrayBuffer)
const content = JSON.parse(decompressed)
currentPlaylist = content
displayPreview(content)
} catch (error) {
showError('LXMC文件处理失败: ' + error.message)
}
}
reader.readAsArrayBuffer(file)
} else {
showError('请选择JSON或LXMC格式的文件')
return
}
}
// 显示预览
function displayPreview(playlist) {
previewContent.innerHTML = ''
const playlistData =
playlist.type === 'playListPart_v2' && playlist.data ? playlist.data : playlist
const songList = playlistData.list
if (!songList || !Array.isArray(songList)) {
showError('无效的歌单格式:缺少歌曲列表')
return
}
// 显示歌曲列表
songList.forEach((song, index) => {
const songItem = document.createElement('div')
songItem.className = 'song-item'
songItem.innerHTML = `
<div class="song-number">${index + 1}</div>
<div class="song-info">
<div class="song-name">${song.name || '未知歌曲'}</div>
<div class="song-artist">${song.singer || '未知艺术家'}${song.interval || '0:00'}</div>
</div>
`
previewContent.appendChild(songItem)
})
songCount.textContent = `${songList.length} 首歌曲`
previewSection.style.display = 'block'
controls.style.display = 'flex'
uploadSection.style.display = 'none'
}
// 转换歌单
function convertPlaylist() {
if (!currentPlaylist) return
showLoading()
try {
// 转换格式
const convertedSongs = convertToTargetFormat(currentPlaylist)
// 加密
const encryptedData = encryptPlaylist(convertedSongs)
convertedPlaylist = {
original: currentPlaylist,
converted: convertedSongs,
encrypted: encryptedData
}
showSuccess(convertedPlaylist)
} catch (error) {
showError('转换失败: ' + error.message, error.stack)
}
}
// 转换为目标格式
function convertToTargetFormat(originalPlaylist) {
if (originalPlaylist.type === 'playListPart_v2' && originalPlaylist.data) {
originalPlaylist = originalPlaylist.data
}
if (!originalPlaylist.list || !Array.isArray(originalPlaylist.list)) {
throw new Error('原始歌单格式无效')
}
return originalPlaylist.list.map((song) => {
// 从meta中提取额外信息
const meta = song.meta || {}
return {
songmid: meta.hash || meta.songId,
singer: song.singer || '未知艺术家',
name: song.name || '未知歌曲',
albumName: meta.albumName || '未知专辑',
albumId: meta.albumId,
source: song.source || 'unknown',
interval: song.interval || '0:00',
img: meta.picUrl || '',
lrc: null,
types: meta.qualitys,
_types: meta._qualitys,
typeUrl: {},
url: ''
}
})
}
// 转换音质类型
function convertQualityTypes(qualities) {
const types = {}
if (!qualities || !Array.isArray(qualities)) {
types['128k'] = { size: '未知' }
return types
}
qualities.forEach((quality) => {
if (typeof quality.type !== 'string') return
const q = quality.type.toLowerCase()
if (q.includes('flac')) {
if (q.includes('24')) {
types['flac24bit'] = { size: '未知' }
} else {
types['flac'] = { size: '未知' }
}
} else if (q.includes('320')) {
types['320k'] = { size: '未知' }
} else if (q.includes('128')) {
types['128k'] = { size: '未知' }
} else {
types[q] = { size: '未知' }
}
})
return Object.keys(types).length > 0 ? types : { '128k': { size: '未知' } }
}
// 生成随机ID
function generateRandomId() {
return Math.random().toString(36).substr(2, 9)
}
// 加密歌单
function encryptPlaylist(songs) {
const dataToEncrypt = JSON.stringify(songs)
return CryptoJS.AES.encrypt(dataToEncrypt, SECRET_KEY).toString()
}
// 显示加载状态
function showLoading() {
loadingSection.style.display = 'block'
controls.style.display = 'none'
resultSection.style.display = 'none'
}
// 显示成功结果
function showSuccess(result) {
loadingSection.style.display = 'none'
resultSection.style.display = 'block'
resultIcon.className = 'result-icon result-success'
resultIcon.textContent = '✅'
resultMessage.textContent = '歌单转换成功!'
// 创建下载链接
const encryptedData = result.encrypted
const blob = new Blob([encryptedData], {
type: 'application/octet-stream'
})
const url = URL.createObjectURL(blob)
downloadLink.href = url
downloadLink.download = `cerumusic-playlist-${new Date().toISOString().slice(0, 10)}.cpl`
downloadLink.style.display = 'inline-block'
// 清理之前的URL
if (downloadLink.dataset.url) {
URL.revokeObjectURL(downloadLink.dataset.url)
}
downloadLink.dataset.url = url
}
// 显示错误信息
function showError(message, details = null) {
loadingSection.style.display = 'none'
resultSection.style.display = 'block'
resultIcon.className = 'result-icon result-error'
resultIcon.textContent = '❌'
resultMessage.textContent = message
if (details) {
errorDetails.textContent = details
errorDetails.style.display = 'block'
}
downloadLink.style.display = 'none'
}
// 重置转换器
function resetConverter() {
currentPlaylist = null
convertedPlaylist = null
uploadSection.style.display = 'block'
previewSection.style.display = 'none'
controls.style.display = 'none'
loadingSection.style.display = 'none'
resultSection.style.display = 'none'
fileInput.value = ''
previewContent.innerHTML = ''
errorDetails.style.display = 'none'
// 清理下载链接
if (downloadLink.dataset.url) {
URL.revokeObjectURL(downloadLink.dataset.url)
delete downloadLink.dataset.url
}
}
// 页面卸载时清理资源
window.addEventListener('beforeunload', () => {
if (downloadLink.dataset.url) {
URL.revokeObjectURL(downloadLink.dataset.url)
}
})
</script>
</body>
</html>