diff --git a/package.json b/package.json index 2e7167a2a..2097040c2 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "electron-store": "^11.0.2", "electron-updater": "^6.8.3", "file-saver": "^2.0.5", + "browser-id3-writer": "^6.3.1", "font-list": "^2.1.0", "fuse.js": "^7.4.1", "get-port": "^7.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2c3f50ff1..40d3229ee 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -69,6 +69,9 @@ importers: better-sqlite3: specifier: ^12.10.0 version: 12.10.0 + browser-id3-writer: + specifier: ^6.3.1 + version: 6.3.1 change-case: specifier: ^5.4.4 version: 5.4.4 @@ -2836,6 +2839,7 @@ packages: binary-install@1.1.2: resolution: {integrity: sha512-ZS2cqFHPZOy4wLxvzqfQvDjCOifn+7uCPqNmYRIBM/03+yllON+4fNnsD0VJdW0p97y+E+dTRNPStWNqMBq+9g==} engines: {node: '>=10'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. bindings@1.5.0: resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} @@ -2881,6 +2885,9 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} + browser-id3-writer@6.3.1: + resolution: {integrity: sha512-sRA4Uq9Q3NsmXiVpLvIDxzomtgCdbw6SY85A6fw7dUQGRVoOBg1/buFv6spPhYiSo6FlVtN5OJQTvvhbmfx9rQ==} + browserslist@4.28.2: resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} @@ -8605,6 +8612,8 @@ snapshots: dependencies: fill-range: 7.1.1 + browser-id3-writer@6.3.1: {} + browserslist@4.28.2: dependencies: baseline-browser-mapping: 2.10.33 diff --git a/src/core/resource/DownloadManager.ts b/src/core/resource/DownloadManager.ts index 25252192e..8a463c98b 100644 --- a/src/core/resource/DownloadManager.ts +++ b/src/core/resource/DownloadManager.ts @@ -65,7 +65,7 @@ class SongDownloadStrategy implements DownloadStrategy { constructor( public readonly song: SongType, private quality: SongLevelType, - ) {} + ) { } get id() { return this.song.id; @@ -577,7 +577,80 @@ class DownloadManager { } else { // 浏览器端兜底处理 if (!strategy.downloadUrl) throw new Error("Download URL missing"); - saveAs(strategy.downloadUrl, config.fileName + "." + config.fileType); + + const axios = (await import("axios")).default; + const { injectAudioMetadata } = await import("./MetadataWriter"); + + const mixedContentSafeURL = (downloadUrl: string) => { + if (window.location.protocol === "https:" && downloadUrl.startsWith("http://")) { + console.warn(`Rewrote HTTP request to HTTPS: ${downloadUrl}`); + return downloadUrl.replace(/^http:\/\//, "https://"); + } else { + return downloadUrl; + } + }; + + let downloaded = false; + if (config.downloadMeta && config.songData) { + try { + // 获取歌曲数据 + const songResponse = await axios.get(mixedContentSafeURL(strategy.downloadUrl), { + responseType: "arraybuffer", + }); + const songBuffer = songResponse.data; + + // 获取歌手名字列表 + const artists = config.songData.artists; + let artistNames: string[] = []; + if (typeof artists === "string") { + artistNames = [artists]; + } else if (Array.isArray(artists)) { + artistNames = artists.map((a: any) => (typeof a === "string" ? a : a.name || "")); + } + + const albumString = + typeof config.songData.album === "string" + ? config.songData.album + : config.songData.album?.name; + + // 获取并嵌入封面图片 + let coverBuffer: ArrayBuffer | undefined = undefined; + if (config.downloadCover) { + const coverUrl = config.songData.coverSize?.l || config.songData.cover; + if (coverUrl) { + try { + const coverResponse = await axios.get(mixedContentSafeURL(coverUrl), { + responseType: "arraybuffer", + }); + coverBuffer = coverResponse.data; + } catch (coverErr) { + console.error("Failed to download cover:", coverErr); + } + } + } + + const taggedBlob = await injectAudioMetadata(songBuffer, config.fileType, { + title: config.songData.name, + artists: artistNames, + album: albumString || undefined, + lyric: config.downloadLyric && config.lyric ? config.lyric : undefined, + coverBuffer: coverBuffer, + albumArtist: config.albumArtists?.join("; ") || undefined, + }); + + saveAs(taggedBlob, config.fileName + "." + config.fileType); + downloaded = true; + } catch (metaErr) { + console.error("Failed to inject metadata, falling back to basic download:", metaErr); + } + } + + // 兜底下载 + if (!downloaded) { + console.warn("Cannot download song with metadata. Trying fallback method..."); + saveAs(mixedContentSafeURL(strategy.downloadUrl), config.fileName + "." + config.fileType); + } + dataStore.removeDownloadingSong(strategy.id); } } catch (error: any) { diff --git a/src/core/resource/FlacMetadataWriter.ts b/src/core/resource/FlacMetadataWriter.ts new file mode 100644 index 000000000..33866ad0c --- /dev/null +++ b/src/core/resource/FlacMetadataWriter.ts @@ -0,0 +1,260 @@ +/** + * 辅助写入 32 位大端无符号整数 (Big-Endian) + */ +function writeUInt32BE(target: Uint8Array, offset: number, value: number) { + target[offset] = (value >>> 24) & 0xff; + target[offset + 1] = (value >>> 16) & 0xff; + target[offset + 2] = (value >>> 8) & 0xff; + target[offset + 3] = value & 0xff; +} + +/** + * 辅助写入 32 位小端无符号整数 (Little-Endian) + */ +function writeUInt32LE(target: Uint8Array, offset: number, value: number) { + target[offset] = value & 0xff; + target[offset + 1] = (value >>> 8) & 0xff; + target[offset + 2] = (value >>> 16) & 0xff; + target[offset + 3] = (value >>> 24) & 0xff; +} + +/** + * 辅助写入 24 位大端无符号整数 (Big-Endian) + */ +function writeUInt24BE(target: Uint8Array, offset: number, value: number) { + target[offset] = (value >>> 16) & 0xff; + target[offset + 1] = (value >>> 8) & 0xff; + target[offset + 2] = value & 0xff; +} + +/** + * 自动分析图片前几个字节以返回对应的 MIME 类型 + */ +function getMimeType(bytes: Uint8Array): string { + if ( + bytes.length >= 8 && + bytes[0] === 0x89 && + bytes[1] === 0x50 && + bytes[2] === 0x4e && + bytes[3] === 0x47 && + bytes[4] === 0x0d && + bytes[5] === 0x0a && + bytes[6] === 0x1a && + bytes[7] === 0x0a + ) { + return "image/png"; + } + if (bytes.length >= 2 && bytes[0] === 0xff && bytes[1] === 0xd8) { + return "image/jpeg"; + } + if (bytes.length >= 3 && bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46) { + return "image/gif"; + } + return "image/jpeg"; // 默认 jpeg +} + +interface FlacMetadata { + title?: string; + artists?: string[]; + album?: string; + lyric?: string; + coverBuffer?: ArrayBuffer; + albumArtist?: string; +} + + +/** + * 在内存中为 FLAC 二进制 Buffer 注入元数据 + * @param songBuffer 原始音频二进制流 + * @param metadata 标题、歌手、专辑、歌词及封面图等元数据 + */ +export function writeFlacMetadata(songBuffer: ArrayBuffer, metadata: FlacMetadata): Uint8Array { + const bytes = new Uint8Array(songBuffer); + if ( + bytes.length < 4 || + bytes[0] !== 102 || + bytes[1] !== 76 || + bytes[2] !== 97 || + bytes[3] !== 67 + ) { + // "fLaC" + throw new Error("不是合法的 FLAC 文件"); + } + + // 1. 解析原有的元数据块 + let offset = 4; + let isLast = false; + const preservedBlocks: { type: number; data: Uint8Array }[] = []; + + while (!isLast) { + if (offset + 4 > bytes.length) break; + const headerByte = bytes[offset++]; + isLast = (headerByte & 0x80) !== 0; + const blockType = headerByte & 0x7f; + const blockLength = (bytes[offset] << 16) | (bytes[offset + 1] << 8) | bytes[offset + 2]; + offset += 3; + + if (offset + blockLength > bytes.length) break; + const blockData = bytes.slice(offset, offset + blockLength); + offset += blockLength; + + // 过滤掉原有的 VORBIS_COMMENT(4)、PICTURE(6) 和 PADDING(1) 块 + if (blockType !== 4 && blockType !== 6 && blockType !== 1) { + preservedBlocks.push({ type: blockType, data: blockData }); + } + } + + const audioFrames = bytes.slice(offset); + + // 2. 构建新的 VORBIS_COMMENT 块 + const encoder = new TextEncoder(); + const vendorString = "reference libFLAC 1.3.0"; + const vendorBytes = encoder.encode(vendorString); + + // 整理要写入的 Comments + const comments: string[] = []; + if (metadata.title) comments.push(`TITLE=${metadata.title}`); + if (metadata.artists && metadata.artists.length > 0) { + for (const artist of metadata.artists) { + comments.push(`ARTIST=${artist}`); + } + } + if (metadata.album) comments.push(`ALBUM=${metadata.album}`); + if (metadata.albumArtist) comments.push(`ALBUMARTIST=${metadata.albumArtist}`); + if (metadata.lyric) comments.push(`LYRICS=${metadata.lyric}`); + + // 计算 VORBIS_COMMENT 内容的大小并写入 + const commentBytesList = comments.map((c) => encoder.encode(c)); + const vorbisContentSize = + 4 + vendorBytes.length + 4 + commentBytesList.reduce((acc, c) => acc + 4 + c.length, 0); + const vorbisBlockData = new Uint8Array(vorbisContentSize); + + let vOffset = 0; + writeUInt32LE(vorbisBlockData, vOffset, vendorBytes.length); + vOffset += 4; + vorbisBlockData.set(vendorBytes, vOffset); + vOffset += vendorBytes.length; + + writeUInt32LE(vorbisBlockData, vOffset, comments.length); + vOffset += 4; + + for (const cBytes of commentBytesList) { + writeUInt32LE(vorbisBlockData, vOffset, cBytes.length); + vOffset += 4; + vorbisBlockData.set(cBytes, vOffset); + vOffset += cBytes.length; + } + + // 3. 构建新的 PICTURE 块 + let pictureBlockData: Uint8Array | null = null; + if (metadata.coverBuffer && metadata.coverBuffer.byteLength > 0) { + const coverBytes = new Uint8Array(metadata.coverBuffer); + const mimeString = getMimeType(coverBytes); + const mimeBytes = encoder.encode(mimeString); + const descString = "Cover"; + const descBytes = encoder.encode(descString); + + const pictureContentSize = + 4 + + 4 + + mimeBytes.length + + 4 + + descBytes.length + + 4 + + 4 + + 4 + + 4 + + 4 + + coverBytes.length; + pictureBlockData = new Uint8Array(pictureContentSize); + + let pOffset = 0; + // Picture type: 3 (Front Cover) + writeUInt32BE(pictureBlockData, pOffset, 3); + pOffset += 4; + // Mime length + writeUInt32BE(pictureBlockData, pOffset, mimeBytes.length); + pOffset += 4; + // Mime string + pictureBlockData.set(mimeBytes, pOffset); + pOffset += mimeBytes.length; + // Description length + writeUInt32BE(pictureBlockData, pOffset, descBytes.length); + pOffset += 4; + // Description string + pictureBlockData.set(descBytes, pOffset); + pOffset += descBytes.length; + // Width, Height (直接填0) + writeUInt32BE(pictureBlockData, pOffset, 0); + pOffset += 4; + writeUInt32BE(pictureBlockData, pOffset, 0); + pOffset += 4; + // Color depth (色深,通常为24) + writeUInt32BE(pictureBlockData, pOffset, 24); + pOffset += 4; + // Colors + writeUInt32BE(pictureBlockData, pOffset, 0); + pOffset += 4; + // Data length + writeUInt32BE(pictureBlockData, pOffset, coverBytes.length); + pOffset += 4; + // Data + pictureBlockData.set(coverBytes, pOffset); + } + + // 4. 构建 PADDING 块 (预留 4096 字节) + const paddingBlockData = new Uint8Array(4096); + + // 5. 组合最终的所有元数据块 + const finalBlocks: { type: number; data: Uint8Array }[] = []; + // 保留的原有块 + finalBlocks.push(...preservedBlocks); + // 新的 VORBIS_COMMENT + finalBlocks.push({ type: 4, data: vorbisBlockData }); + // 新的 PICTURE + if (pictureBlockData) { + finalBlocks.push({ type: 6, data: pictureBlockData }); + } + // 填充 Padding 块 + finalBlocks.push({ type: 1, data: paddingBlockData }); + + // 6. 重新打包,输出整体二进制数据 + let totalMetadataSize = 4 + finalBlocks.length * 4; + for (const block of finalBlocks) { + totalMetadataSize += block.data.length; + } + const totalSize = totalMetadataSize + audioFrames.length; + const result = new Uint8Array(totalSize); + + // 写入 "fLaC" 标志 + result[0] = 102; // 'f' + result[1] = 76; // 'L' + result[2] = 97; // 'a' + result[3] = 67; // 'C' + + let writeOffset = 4; + for (let i = 0; i < finalBlocks.length; i++) { + const block = finalBlocks[i]; + const isLastBlock = i === finalBlocks.length - 1; + + // Header 字节 1: 1位 isLast + 7位 blockType + let typeByte = block.type & 0x7f; + if (isLastBlock) { + typeByte |= 0x80; + } + result[writeOffset++] = typeByte; + + // Header 字节 2-4: 24位长度 + writeUInt24BE(result, writeOffset, block.data.length); + writeOffset += 3; + + // 块数据 + result.set(block.data, writeOffset); + writeOffset += block.data.length; + } + + // 写入音频帧 + result.set(audioFrames, writeOffset); + + return result; +} diff --git a/src/core/resource/MetadataWriter.ts b/src/core/resource/MetadataWriter.ts new file mode 100644 index 000000000..d77b0e687 --- /dev/null +++ b/src/core/resource/MetadataWriter.ts @@ -0,0 +1,78 @@ +import { writeFlacMetadata } from "./FlacMetadataWriter"; + +interface WriteMetadataOptions { + title?: string; + artists?: string[]; + album?: string; + lyric?: string; + coverBuffer?: ArrayBuffer; + albumArtist?: string; +} + +/** + * 在内存中为音频 Buffer 嵌入元数据(支持 MP3 和 FLAC)并返回对应的 Blob + * @param songBuffer 原始音频二进制流 + * @param fileType 音频格式后缀名(如 "mp3", "flac") + * @param options 元数据配置(标题、歌手列表、专辑、歌词、封面及专辑歌手等) + */ +export async function injectAudioMetadata( + songBuffer: ArrayBuffer, + fileType: string, + options: WriteMetadataOptions +): Promise { + const fileTypeLower = fileType.toLowerCase(); + + if (fileTypeLower === "mp3") { + const { ID3Writer } = await import("browser-id3-writer"); + const writer = new ID3Writer(songBuffer); + + if (options.title) { + writer.setFrame("TIT2", options.title); + } + if (options.artists && options.artists.length > 0) { + writer.setFrame("TPE1", options.artists); + } + if (options.album) { + writer.setFrame("TALB", options.album); + } + if (options.albumArtist) { + writer.setFrame("TPE2", options.albumArtist); + } + + // 设置歌词 + if (options.lyric) { + writer.setFrame("USLT", { + description: "", + lyrics: options.lyric, + language: "eng", + }); + } + + // 设置封面图片 + if (options.coverBuffer) { + writer.setFrame("APIC", { + type: 3, + data: options.coverBuffer, + description: "Cover", + useUnicodeEncoding: true, + }); + } + + writer.addTag(); + return writer.getBlob(); + } else if (fileTypeLower === "flac") { + const modifiedBytes = writeFlacMetadata(songBuffer, { + title: options.title, + artists: options.artists, + album: options.album, + lyric: options.lyric, + coverBuffer: options.coverBuffer, + albumArtist: options.albumArtist, + }); + // 使用 any 避开严格 TS DOM 环境下的 BlobPart 兼容性限制 + return new Blob([modifiedBytes as any], { type: "audio/flac" }); + } + + // 兜底格式直接返回原 Buffer 包装的 Blob + return new Blob([songBuffer]); +}