分享一个FLAC元数据重命名工具

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>FLAC 重...
分享一个FLAC元数据重命名工具
分享一个FLAC元数据重命名工具
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>FLAC 重命名工具 · 读取内嵌元数据</title>
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body {
      background: #f5f0e8;
      font-family: 'Segoe UI', system-ui, sans-serif;
      min-height: 100vh;
      display: flex;
      justify-content: center;
      align-items: center;
      padding: 1.5rem;
    }
    .container {
      max-width: 850px;
      width: 100%;
      background: #fffdf7;
      border-radius: 1.8rem;
      box-shadow: 0 20px 40px rgba(0,0,0,0.1);
      padding: 2rem;
    }
    h1 {
      font-size: 2rem;
      color: #4a3724;
      display: flex;
      align-items: center;
      gap: 0.5rem;
      margin-bottom: 0.3rem;
    }
    .badge {
      background: #d4b896;
      color: #2d1f0e;
      font-size: 0.85rem;
      padding: 0.3rem 1rem;
      border-radius: 20px;
      font-weight: 600;
    }
    .desc {
      color: #6b5d4b;
      margin-bottom: 1.5rem;
      font-size: 0.95rem;
      border-left: 3px solid #c9a87c;
      padding-left: 1rem;
    }
    .drop-area {
      background: #faf7f1;
      border: 2px dashed #c8b28b;
      border-radius: 1.5rem;
      padding: 2.5rem;
      text-align: center;
      cursor: pointer;
      transition: all 0.2s;
      margin-bottom: 1.5rem;
    }
    .drop-area:hover, .drop-area.active {
      border-color: #a0845c;
      background: #f5ede0;
      box-shadow: 0 0 0 4px rgba(180,140,90,0.1);
    }
    .drop-area .icon { font-size: 3rem; margin-bottom: 0.5rem; }
    .drop-area .main-text { font-weight: 600; color: #4d3a24; font-size: 1.1rem; }
    .drop-area .sub-text { color: #8b7a62; font-size: 0.9rem; margin-top: 0.2rem; }
    input[type="file"] { display: none; }

    .file-panel {
      background: #fefcf8;
      border: 1px solid #e3d5bd;
      border-radius: 1.2rem;
      padding: 1rem;
      max-height: 380px;
      overflow-y: auto;
      margin: 1.2rem 0;
      display: none;
    }
    .file-row {
      display: flex;
      align-items: center;
      gap: 0.8rem;
      padding: 0.7rem 0.5rem;
      border-bottom: 1px solid #efe4d0;
      flex-wrap: wrap;
    }
    .file-row:last-child { border-bottom: none; }
    .orig {
      font-family: 'Consolas', 'Monaco', monospace;
      background: #f3ecdd;
      padding: 0.3rem 0.8rem;
      border-radius: 20px;
      font-size: 0.85rem;
      color: #5c4a30;
      word-break: break-all;
      flex: 1;
      min-width: 130px;
    }
    .arrow { color: #b39260; font-weight: bold; font-size: 1.2rem; }
    .newname {
      font-family: 'Consolas', 'Monaco', monospace;
      background: #e2edda;
      padding: 0.3rem 0.8rem;
      border-radius: 20px;
      font-size: 0.85rem;
      color: #2d4a1e;
      font-weight: 600;
      word-break: break-all;
      flex: 1;
      min-width: 130px;
      text-align: right;
    }
    .newname.missing {
      background: #ffe8e0;
      color: #a04030;
      font-style: italic;
    }
    .meta-detail {
      font-size: 0.7rem;
      color: #8b7356;
      background: #f9f4ea;
      padding: 0.15rem 0.6rem;
      border-radius: 12px;
      white-space: nowrap;
    }

    .actions {
      display: flex;
      gap: 0.8rem;
      flex-wrap: wrap;
      align-items: center;
    }
    button {
      padding: 0.8rem 1.8rem;
      border-radius: 2rem;
      border: 1px solid #d4bc92;
      background: #f1e7d4;
      color: #4d3a22;
      font-weight: 600;
      cursor: pointer;
      font-size: 0.95rem;
      transition: 0.2s;
      display: flex;
      align-items: center;
      gap: 0.3rem;
    }
    button:hover:not(:disabled) { background: #e5d3b0; }
    button.primary {
      background: #c7a16b;
      border-color: #9c7a4a;
      color: #fffdf5;
      box-shadow: 0 4px 12px rgba(160,120,50,0.2);
    }
    button.primary:hover:not(:disabled) { background: #b38845; }
    button:disabled { opacity: 0.45; cursor: not-allowed; }
    .status {
      margin-left: auto;
      font-size: 0.9rem;
      color: #6b5d48;
      background: #f6f1e6;
      padding: 0.4rem 1.2rem;
      border-radius: 20px;
    }
    .footer {
      margin-top: 1rem;
      font-size: 0.8rem;
      color: #9b8b74;
      text-align: center;
    }
  </style>
</head>
<body>
<div class="container">
  <h1>🎵 FLAC 元数据重命名 <span class="badge">标题-作曲家</span></h1>
  <div class="desc">读取 FLAC 文件内嵌的歌曲标题 (TITLE) 和作曲家 (COMPOSER/ARTIST) 标签,自动重命名为 "标题-作曲家.flac"</div>

  <div class="drop-area" id="dropZone">
    <div class="icon">📂</div>
    <div class="main-text">点击选择或拖拽 FLAC 文件</div>
    <div class="sub-text">支持批量 · 读取内嵌元数据标签</div>
  </div>
  <input type="file" id="fileInput" accept=".flac,audio/flac" multiple>

  <div class="file-panel" id="filePanel">
    <div id="fileList"></div>
  </div>

  <div class="actions">
    <button id="clearBtn" disabled>🗑️ 清空</button>
    <button id="renameBtn" class="primary" disabled>💾 下载重命名文件</button>
    <span class="status" id="status">等待添加 FLAC 文件...</span>
  </div>
  <div class="footer">* 浏览器安全限制:通过下载方式生成新文件名,原文件不会被修改</div>
</div>

<script>
(function() {
  const dropZone = document.getElementById('dropZone');
  const fileInput = document.getElementById('fileInput');
  const filePanel = document.getElementById('filePanel');
  const fileListDiv = document.getElementById('fileList');
  const clearBtn = document.getElementById('clearBtn');
  const renameBtn = document.getElementById('renameBtn');
  const statusEl = document.getElementById('status');

  let filesData = []; // { file, title, composer, newName, error }

  // ========== 解析 FLAC 内嵌元数据 (Vorbis Comment) ==========
  async function readFlacMetadata(file) {
    return new Promise((resolve) => {
      const reader = new FileReader();
      reader.onload = (e) => {
        try {
          const buf = e.target.result;
          const view = new DataView(buf);
          if (buf.byteLength < 4) return resolve({ title: null, composer: null, error: '文件过小' });

          const magic = String.fromCharCode(view.getUint8(0), view.getUint8(1), view.getUint8(2), view.getUint8(3));
          if (magic !== 'fLaC') return resolve({ title: null, composer: null, error: '非FLAC文件' });

          let offset = 4;
          let lastBlock = false;
          let title = null;
          let composer = null;

          while (offset < buf.byteLength && !lastBlock) {
            if (offset + 4 > buf.byteLength) break;
            const header = view.getUint8(offset);
            lastBlock = (header & 0x80) !== 0;
            const blockType = header & 0x7F;
            const blockSize = (view.getUint8(offset+1) << 16) | (view.getUint8(offset+2) << 8) | view.getUint8(offset+3);
            offset += 4;

            if (blockType === 4 && offset + blockSize <= buf.byteLength) {
              // VORBIS_COMMENT 块
              const block = new Uint8Array(buf, offset, blockSize);
              const dec = new TextDecoder('utf-8');
              if (block.length < 4) { offset += blockSize; continue; }
              const vendorLen = block[0] | (block[1]<<8) | (block[2]<<16) | (block[3]<<24);
              let pos = 4 + vendorLen;
              if (pos + 4 > block.length) { offset += blockSize; continue; }
              const numComments = block[pos] | (block[pos+1]<<8) | (block[pos+2]<<16) | (block[pos+3]<<24);
              pos += 4;

              for (let i = 0; i < numComments; i++) {
                if (pos + 4 > block.length) break;
                const commentLen = block[pos] | (block[pos+1]<<8) | (block[pos+2]<<16) | (block[pos+3]<<24);
                pos += 4;
                if (pos + commentLen > block.length) break;
                const commentStr = dec.decode(block.slice(pos, pos + commentLen));
                pos += commentLen;

                const eqIdx = commentStr.indexOf('=');
                if (eqIdx > 0) {
                  const key = commentStr.substring(0, eqIdx).toUpperCase().trim();
                  const val = commentStr.substring(eqIdx + 1).trim();
                  if (key === 'TITLE' && !title) title = val;
                  // 优先 COMPOSER,其次 ARTIST
                  if (key === 'COMPOSER' && !composer) composer = val;
                  if (key === 'ARTIST' && !composer) composer = val;
                }
              }
              // 找到注释块即可退出
              break;
            }
            offset += blockSize;
          }

          resolve({
            title: title || null,
            composer: composer || null,
            error: null
          });
        } catch (err) {
          resolve({ title: null, composer: null, error: err.message });
        }
      };
      reader.onerror = () => resolve({ title: null, composer: null, error: '读取失败' });
      reader.readAsArrayBuffer(file);
    });
  }

  // ========== 生成新文件名 ==========
  function makeNewName(title, composer, origName) {
    const sanitize = (s) => s.replace(/[\\/:*?"<>|]/g, '_').replace(/\s+/g, ' ').trim().substring(0, 200);
    const fallback = origName.replace(/\.flac$/i, '') || 'unknown';
    const t = (title && title.trim()) ? sanitize(title) : sanitize(fallback);
    const c = (composer && composer.trim()) ? sanitize(composer) : '未知作曲家';
    return `${t}-${c}.flac`;
  }

  // ========== 渲染文件列表 ==========
  function render() {
    fileListDiv.innerHTML = '';
    if (filesData.length === 0) {
      filePanel.style.display = 'none';
      clearBtn.disabled = true;
      renameBtn.disabled = true;
      setStatus('📭 暂无文件');
      return;
    }
    filePanel.style.display = 'block';
    clearBtn.disabled = false;
    renameBtn.disabled = false;

    filesData.forEach(item => {
      const row = document.createElement('div');
      row.className = 'file-row';

      const origSpan = document.createElement('span');
      origSpan.className = 'orig';
      origSpan.textContent = item.file.name;

      const arrow = document.createElement('span');
      arrow.className = 'arrow';
      arrow.textContent = '→';

      const newSpan = document.createElement('span');
      newSpan.className = 'newname';
      if (item.error && !item.title && !item.composer) {
        newSpan.classList.add('missing');
        newSpan.textContent = '⚠ 元数据缺失';
      } else {
        newSpan.textContent = item.newName;
      }

      // 显示读取到的标签详情
      const metaDetail = document.createElement('span');
      metaDetail.className = 'meta-detail';
      const t = item.title || '—';
      const c = item.composer || '—';
      metaDetail.textContent = `TITLE: ${t} | COMPOSER: ${c}`;

      row.appendChild(origSpan);
      row.appendChild(arrow);
      row.appendChild(newSpan);
      row.appendChild(metaDetail);
      fileListDiv.appendChild(row);
    });

    const valid = filesData.filter(f => f.title || f.composer).length;
    setStatus(`📋 ${filesData.length} 个文件 · ${valid} 个可重命名`);
  }

  function setStatus(msg) {
    statusEl.textContent = msg;
  }

  // ========== 处理添加文件 ==========
  async function addFiles(fileList) {
    const flacFiles = Array.from(fileList).filter(f => f.name.toLowerCase().endsWith('.flac'));
    if (flacFiles.length === 0) {
      setStatus('❌ 请选择 .flac 文件');
      return;
    }
    setStatus('🔍 正在读取内嵌元数据...');
    renameBtn.disabled = true;
    clearBtn.disabled = true;

    for (const file of flacFiles) {
      const meta = await readFlacMetadata(file);
      const newName = makeNewName(meta.title, meta.composer, file.name);
      filesData.push({
        file,
        title: meta.title,
        composer: meta.composer,
        newName,
        error: meta.error
      });
    }

    // 去重
    const seen = new Map();
    filesData = filesData.filter(item => {
      const key = `${item.file.name}|${item.file.size}|${item.file.lastModified}`;
      if (seen.has(key)) return false;
      seen.set(key, true);
      return true;
    });

    render();
    setStatus('✅ 元数据读取完成');
  }

  // ========== 执行下载重命名 ==========
  async function doRename() {
    const valid = filesData.filter(f => f.title || f.composer);
    if (valid.length === 0) {
      setStatus('⚠️ 没有可重命名的文件');
      return;
    }
    setStatus('⏳ 正在下载重命名文件...');
    renameBtn.disabled = true;
    clearBtn.disabled = true;

    for (const item of valid) {
      const blob = new Blob([item.file], { type: 'audio/flac' });
      const url = URL.createObjectURL(blob);
      const a = document.createElement('a');
      a.href = url;
      a.download = item.newName;
      document.body.appendChild(a);
      a.click();
      document.body.removeChild(a);
      await new Promise(r => setTimeout(r, 150));
      URL.revokeObjectURL(url);
    }
    setStatus('🎉 下载完成!原文件未被修改');
    renameBtn.disabled = false;
    clearBtn.disabled = false;
  }

  function clearAll() {
    filesData = [];
    fileInput.value = '';
    render();
    setStatus('🧹 已清空');
  }

  // ========== 事件绑定 ==========
  dropZone.addEventListener('click', () => fileInput.click());
  dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('active'); });
  dropZone.addEventListener('dragleave', e => { e.preventDefault(); dropZone.classList.remove('active'); });
  dropZone.addEventListener('drop', e => {
    e.preventDefault();
    dropZone.classList.remove('active');
    if (e.dataTransfer.files.length) addFiles(e.dataTransfer.files);
  });
  fileInput.addEventListener('change', e => {
    if (e.target.files.length) addFiles(e.target.files);
  });
  clearBtn.addEventListener('click', clearAll);
  renameBtn.addEventListener('click', doRename);

  render();
  setStatus('📎 选择或拖入 FLAC 文件以读取内嵌元数据');
})();
</script>
</body>
</html>

1 个帖子 - 1 位参与者

阅读完整话题

来源: LinuxDo 最新话题查看原文