新增FLAC格式音乐标签信息写入

pull/225/head
lyswhut 2020-03-21 22:31:02 +08:00
parent bc7c3968b6
commit 83f0ca918a
12 changed files with 1072 additions and 384 deletions

843
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"name": "lx-music-desktop",
"version": "0.17.0",
"version": "0.18.0",
"description": "一个免费的音乐下载助手",
"main": "./dist/electron/main.js",
"productName": "lx-music-desktop",
@ -141,13 +141,13 @@
},
"homepage": "https://github.com/lyswhut/lx-music-desktop#readme",
"devDependencies": {
"@babel/core": "^7.8.7",
"@babel/core": "^7.9.0",
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
"@babel/polyfill": "^7.8.7",
"@babel/preset-env": "^7.8.7",
"@babel/preset-env": "^7.9.0",
"autoprefixer": "^9.7.4",
"babel-eslint": "^10.0.3",
"babel-loader": "^8.0.6",
"babel-loader": "^8.1.0",
"babel-minify-webpack-plugin": "^0.3.1",
"babel-preset-minify": "^0.5.1",
"browserslist": "^4.10.0",
@ -164,9 +164,9 @@
"electron-builder": "^22.4.1",
"electron-debug": "^3.0.1",
"electron-devtools-installer": "^2.2.4",
"electron-to-chromium": "^1.3.379",
"electron-to-chromium": "^1.3.380",
"eslint": "^6.8.0",
"eslint-config-standard": "^14.1.0",
"eslint-config-standard": "^14.1.1",
"eslint-formatter-friendly": "^7.0.0",
"eslint-loader": "^3.0.3",
"eslint-plugin-html": "^6.0.0",
@ -209,11 +209,10 @@
"electron-log": "^4.1.0",
"electron-store": "^5.1.1",
"electron-updater": "^4.2.5",
"flac-metadata": "^0.1.1",
"js-htmlencode": "^0.3.0",
"lrc-file-parser": "^1.0.1",
"needle": "^2.3.3",
"node-id3": "^0.1.14",
"node-id3": "^0.1.15",
"request": "^2.88.2",
"vue": "^2.6.11",
"vue-electron": "^1.0.6",

View File

@ -1,5 +1,6 @@
### 新增
- 新增FLAC格式音乐标签信息写入
### 优化

View File

@ -0,0 +1,10 @@
// https://github.com/claus/flac-metadata
module.exports.Processor = require('./lib/Processor')
module.exports.data = {
MetaDataBlock: require('./lib/data/MetaDataBlock'),
MetaDataBlockStreamInfo: require('./lib/data/MetaDataBlockStreamInfo'),
MetaDataBlockVorbisComment: require('./lib/data/MetaDataBlockVorbisComment'),
MetaDataBlockPicture: require('./lib/data/MetaDataBlockPicture'),
}

View File

@ -0,0 +1,214 @@
const Transform = require('stream').Transform
const MetaDataBlock = require('./data/MetaDataBlock')
const MetaDataBlockStreamInfo = require('./data/MetaDataBlockStreamInfo')
const MetaDataBlockVorbisComment = require('./data/MetaDataBlockVorbisComment')
const MetaDataBlockPicture = require('./data/MetaDataBlockPicture')
const STATE_IDLE = 0
const STATE_MARKER = 1
const STATE_MDB_HEADER = 2
const STATE_MDB = 3
const STATE_PASS_THROUGH = 4
class Processor extends Transform {
constructor(options) {
super(options)
// MDB types
this.MDB_TYPE_STREAMINFO = 0
this.MDB_TYPE_PADDING = 1
this.MDB_TYPE_APPLICATION = 2
this.MDB_TYPE_SEEKTABLE = 3
this.MDB_TYPE_VORBIS_COMMENT = 4
this.MDB_TYPE_CUESHEET = 5
this.MDB_TYPE_PICTURE = 6
this.MDB_TYPE_INVALID = 127
this.state = STATE_IDLE
this.isFlac = false
this.buf = null
this.bufPos = 0
this.mdb = null
this.mdbLen = 0
this.mdbLast = false
this.mdbPush = false
this.mdbLastWritten = false
this.parseMetaDataBlocks = false
if (!(this instanceof Processor)) return new Processor(options)
if (options && !!options.parseMetaDataBlocks) { this.parseMetaDataBlocks = true }
}
_transform(chunk, enc, done) {
let chunkPos = 0
let chunkLen = chunk.length
let isChunkProcessed = false
let _this = this
function _safePush(minCapacity, persist, validate) {
let slice
let chunkAvailable = chunkLen - chunkPos
let isDone = (chunkAvailable + this.bufPos >= minCapacity)
validate = (typeof validate === 'function') ? validate : function() { return true }
if (isDone) {
// Enough data available
if (persist) {
// Persist the entire block so it can be parsed
if (this.bufPos > 0) {
// Part of this block's data is in backup buffer, copy rest over
chunk.copy(this.buf, this.bufPos, chunkPos, chunkPos + minCapacity - this.bufPos)
slice = this.buf.slice(0, minCapacity)
} else {
// Entire block fits in current chunk
slice = chunk.slice(chunkPos, chunkPos + minCapacity)
}
} else {
slice = chunk.slice(chunkPos, chunkPos + minCapacity - this.bufPos)
}
// Push block after validation
validate(slice, isDone) && _this.push(slice)
chunkPos += minCapacity - this.bufPos
this.bufPos = 0
this.buf = null
} else {
// Not enough data available
if (persist) {
// Copy/append incomplete block to backup buffer
this.buf = this.buf || Buffer.alloc(minCapacity)
chunk.copy(this.buf, this.bufPos, chunkPos, chunkLen)
} else {
// Push incomplete block after validation
slice = chunk.slice(chunkPos, chunkLen)
validate(slice, isDone) && _this.push(slice)
}
this.bufPos += chunkLen - chunkPos
}
return isDone
};
let safePush = _safePush.bind(this)
while (!isChunkProcessed) {
switch (this.state) {
case STATE_IDLE:
this.state = STATE_MARKER
break
case STATE_MARKER:
if (safePush(4, true, this._validateMarker.bind(this))) {
this.state = this.isFlac ? STATE_MDB_HEADER : STATE_PASS_THROUGH
} else {
isChunkProcessed = true
}
break
case STATE_MDB_HEADER:
if (safePush(4, true, this._validateMDBHeader.bind(this))) {
this.state = STATE_MDB
} else {
isChunkProcessed = true
}
break
case STATE_MDB:
if (safePush(this.mdbLen, this.parseMetaDataBlocks, this._validateMDB.bind(this))) {
if (this.mdb.isLast) {
// This MDB has the isLast flag set to true.
// Ignore all following MDBs.
this.mdbLastWritten = true
}
this.emit('postprocess', this.mdb)
this.state = this.mdbLast ? STATE_PASS_THROUGH : STATE_MDB_HEADER
} else {
isChunkProcessed = true
}
break
case STATE_PASS_THROUGH:
safePush(chunkLen - chunkPos, false)
isChunkProcessed = true
break
}
}
done()
}
_validateMarker(slice, isDone) {
this.isFlac = (slice.toString('utf8', 0) === 'fLaC')
// TODO: completely bail out if file is not a FLAC?
return true
}
_validateMDBHeader(slice, isDone) {
// Parse MDB header
let header = slice.readUInt32BE(0)
let type = (header >>> 24) & 0x7f
this.mdbLast = (((header >>> 24) & 0x80) !== 0)
this.mdbLen = header & 0xffffff
// Create appropriate MDB object
// (data is injected later in _validateMDB, if parseMetaDataBlocks option is set to true)
switch (type) {
case Processor.MDB_TYPE_STREAMINFO:
this.mdb = new MetaDataBlockStreamInfo(this.mdbLast)
break
case Processor.MDB_TYPE_VORBIS_COMMENT:
this.mdb = new MetaDataBlockVorbisComment(this.mdbLast)
break
case Processor.MDB_TYPE_PICTURE:
this.mdb = new MetaDataBlockPicture(this.mdbLast)
break
case Processor.MDB_TYPE_PADDING:
case Processor.MDB_TYPE_APPLICATION:
case Processor.MDB_TYPE_SEEKTABLE:
case Processor.MDB_TYPE_CUESHEET:
case Processor.MDB_TYPE_INVALID:
default:
this.mdb = new MetaDataBlock(this.mdbLast, type)
break
}
this.emit('preprocess', this.mdb)
if (this.mdbLastWritten) {
// A previous MDB had the isLast flag set to true.
// Ignore all following MDBs.
this.mdb.remove()
} else {
// The consumer may change the MDB's isLast flag in the preprocess handler.
// Here that flag is updated in the MDB header.
if (this.mdbLast !== this.mdb.isLast) {
if (this.mdb.isLast) {
header |= 0x80000000
} else {
header &= 0x7fffffff
}
slice.writeUInt32BE(header >>> 0, 0)
}
}
this.mdbPush = !this.mdb.removed
return this.mdbPush
}
_validateMDB(slice, isDone) {
// Parse the MDB if parseMetaDataBlocks option is set to true
if (this.parseMetaDataBlocks && isDone) {
this.mdb.parse(slice)
}
return this.mdbPush
}
_flush(done) {
// All chunks have been processed
// Clean up
this.state = STATE_IDLE
this.mdbLastWritten = false
this.isFlac = false
this.bufPos = 0
this.buf = null
this.mdb = null
done()
}
}
module.exports = Processor

View File

@ -0,0 +1,25 @@
class MetaDataBlock {
constructor(isLast, type) {
this.isLast = isLast
this.type = type
this.error = null
this.hasData = false
this.removed = false
}
remove() {
this.removed = true
}
parse(buffer) {
}
toString() {
let str = '[MetaDataBlock]'
str += ' type: ' + this.type
str += ', isLast: ' + this.isLast
return str
}
}
module.exports = MetaDataBlock

View File

@ -0,0 +1,130 @@
const MetaDataBlock = require('./MetaDataBlock')
class MetaDataBlockPicture extends MetaDataBlock {
constructor(isLast) {
super(isLast, 6)
this.pictureType = 0
this.mimeType = ''
this.description = ''
this.width = 0
this.height = 0
this.bitsPerPixel = 0
this.colors = 0
this.pictureData = null
}
static create(isLast, pictureType, mimeType, description, width, height, bitsPerPixel, colors, pictureData) {
let mdb = new MetaDataBlockPicture(isLast)
mdb.pictureType = pictureType
mdb.mimeType = mimeType
mdb.description = description
mdb.width = width
mdb.height = height
mdb.bitsPerPixel = bitsPerPixel
mdb.colors = colors
mdb.pictureData = pictureData
mdb.hasData = true
return mdb
}
parse(buffer) {
try {
let pos = 0
this.pictureType = buffer.readUInt32BE(pos)
pos += 4
let mimeTypeLength = buffer.readUInt32BE(pos)
this.mimeType = buffer.toString('utf8', pos + 4, pos + 4 + mimeTypeLength)
pos += 4 + mimeTypeLength
let descriptionLength = buffer.readUInt32BE(pos)
this.description = buffer.toString('utf8', pos + 4, pos + 4 + descriptionLength)
pos += 4 + descriptionLength
this.width = buffer.readUInt32BE(pos)
this.height = buffer.readUInt32BE(pos + 4)
this.bitsPerPixel = buffer.readUInt32BE(pos + 8)
this.colors = buffer.readUInt32BE(pos + 12)
pos += 16
let pictureDataLength = buffer.readUInt32BE(pos)
this.pictureData = Buffer.alloc(pictureDataLength)
buffer.copy(this.pictureData, 0, pos + 4, pictureDataLength)
this.hasData = true
} catch (e) {
this.error = e
this.hasData = false
}
}
publish() {
let pos = 0
let size = this.getSize()
let buffer = Buffer.alloc(4 + size)
let header = size
header |= (this.type << 24)
header |= (this.isLast ? 0x80000000 : 0)
buffer.writeUInt32BE(header >>> 0, pos)
pos += 4
buffer.writeUInt32BE(this.pictureType, pos)
pos += 4
let mimeTypeLen = Buffer.byteLength(this.mimeType)
buffer.writeUInt32BE(mimeTypeLen, pos)
buffer.write(this.mimeType, pos + 4)
pos += 4 + mimeTypeLen
let descriptionLen = Buffer.byteLength(this.description)
buffer.writeUInt32BE(descriptionLen, pos)
buffer.write(this.description, pos + 4)
pos += 4 + descriptionLen
buffer.writeUInt32BE(this.width, pos)
buffer.writeUInt32BE(this.height, pos + 4)
buffer.writeUInt32BE(this.bitsPerPixel, pos + 8)
buffer.writeUInt32BE(this.colors, pos + 12)
pos += 16
buffer.writeUInt32BE(this.pictureData.length, pos)
this.pictureData.copy(buffer, pos + 4)
return buffer
}
getSize() {
let size = 4
size += 4 + Buffer.byteLength(this.mimeType)
size += 4 + Buffer.byteLength(this.description)
size += 16
size += 4 + this.pictureData.length
return size
}
toString() {
let str = '[MetaDataBlockPicture]'
str += ' type: ' + this.type
str += ', isLast: ' + this.isLast
if (this.error) {
str += '\n ERROR: ' + this.error
}
if (this.hasData) {
str += '\n pictureType: ' + this.pictureType
str += '\n mimeType: ' + this.mimeType
str += '\n description: ' + this.description
str += '\n width: ' + this.width
str += '\n height: ' + this.height
str += '\n bitsPerPixel: ' + this.bitsPerPixel
str += '\n colors: ' + this.colors
str += '\n pictureData: ' + (this.pictureData ? this.pictureData.length : '<null>')
}
return str
}
}
module.exports = MetaDataBlockPicture

View File

@ -0,0 +1,84 @@
const MetaDataBlock = require('./MetaDataBlock')
function pad(n, width) {
n = '' + n
return (n.length >= width) ? n : new Array(width - n.length + 1).join('0') + n
}
class MetaDataBlockStreamInfo extends MetaDataBlock {
constructor(isLast) {
super(isLast, 0)
this.minBlockSize = 0
this.maxBlockSize = 0
this.minFrameSize = 0
this.maxFrameSize = 0
this.sampleRate = 0
this.channels = 0
this.bitsPerSample = 0
this.samples = 0
this.checksum = null
this.duration = 0
this.durationStr = '0:00.000'
}
remove() {
console.error("WARNING: Can't remove StreamInfo block!")
}
parse(buffer) {
try {
let pos = 0
this.minBlockSize = buffer.readUInt16BE(pos)
this.maxBlockSize = buffer.readUInt16BE(pos + 2)
this.minFrameSize = (buffer.readUInt8(pos + 4) << 16) | buffer.readUInt16BE(pos + 5)
this.maxFrameSize = (buffer.readUInt8(pos + 7) << 16) | buffer.readUInt16BE(pos + 8)
let tmp = buffer.readUInt32BE(pos + 10)
this.sampleRate = tmp >>> 12
this.channels = (tmp >>> 9) & 0x07
this.bitsPerSample = (tmp >>> 4) & 0x1f
this.samples = +((tmp & 0x0f) << 4) + buffer.readUInt32BE(pos + 14)
this.checksum = Buffer.alloc(16)
buffer.copy(this.checksum, 0, 18, 34)
this.duration = this.samples / this.sampleRate
let minutes = '' + Math.floor(this.duration / 60)
let seconds = pad(Math.floor(this.duration % 60), 2)
let milliseconds = pad(Math.round(((this.duration % 60) - Math.floor(this.duration % 60)) * 1000), 3)
this.durationStr = minutes + ':' + seconds + '.' + milliseconds
this.hasData = true
} catch (e) {
this.error = e
this.hasData = false
}
}
toString() {
let str = '[MetaDataBlockStreamInfo]'
str += ' type: ' + this.type
str += ', isLast: ' + this.isLast
if (this.error) {
str += '\n ERROR: ' + this.error
}
if (this.hasData) {
str += '\n minBlockSize: ' + this.minBlockSize
str += '\n maxBlockSize: ' + this.maxBlockSize
str += '\n minFrameSize: ' + this.minFrameSize
str += '\n maxFrameSize: ' + this.maxFrameSize
str += '\n samples: ' + this.samples
str += '\n sampleRate: ' + this.sampleRate
str += '\n channels: ' + (this.channels + 1)
str += '\n bitsPerSample: ' + (this.bitsPerSample + 1)
str += '\n duration: ' + this.durationStr
str += '\n checksum: ' + (this.checksum ? this.checksum.toString('hex') : '<null>')
}
return str
}
}
module.exports = MetaDataBlockStreamInfo

View File

@ -0,0 +1,105 @@
const MetaDataBlock = require('./MetaDataBlock')
class MetaDataBlockVorbisComment extends MetaDataBlock {
constructor(isLast) {
super(isLast, 4)
this.vendor = ''
this.comments = []
}
static create(isLast, vendor, comments) {
let mdb = new MetaDataBlockVorbisComment(isLast)
mdb.vendor = vendor
mdb.comments = comments
mdb.hasData = true
return mdb
}
parse(buffer) {
try {
let pos = 0
let vendorLen = buffer.readUInt32LE(pos)
let vendor = buffer.toString('utf8', pos + 4, pos + 4 + vendorLen)
this.vendor = vendor
pos += 4 + vendorLen
let commentCount = buffer.readUInt32LE(pos)
pos += 4
while (commentCount-- > 0) {
let commentLen = buffer.readUInt32LE(pos)
let comment = buffer.toString('utf8', pos + 4, pos + 4 + commentLen)
this.comments.push(comment)
pos += 4 + commentLen
}
this.hasData = true
} catch (e) {
this.error = e
this.hasData = false
}
}
publish() {
let pos = 0
let size = this.getSize()
let buffer = Buffer.alloc(4 + size)
let header = size
header |= (this.type << 24)
header |= (this.isLast ? 0x80000000 : 0)
buffer.writeUInt32BE(header >>> 0, pos)
pos += 4
let vendorLen = Buffer.byteLength(this.vendor)
buffer.writeUInt32LE(vendorLen, pos)
buffer.write(this.vendor, pos + 4)
pos += 4 + vendorLen
let commentCount = this.comments.length
buffer.writeUInt32LE(commentCount, pos)
pos += 4
for (let i = 0; i < commentCount; i++) {
let comment = this.comments[i]
let commentLen = Buffer.byteLength(comment)
buffer.writeUInt32LE(commentLen, pos)
buffer.write(comment, pos + 4)
pos += 4 + commentLen
}
return buffer
}
getSize() {
let size = 8 + Buffer.byteLength(this.vendor)
for (let i = 0; i < this.comments.length; i++) {
size += 4 + Buffer.byteLength(this.comments[i])
}
return size
}
toString() {
let str = '[MetaDataBlockVorbisComment]'
str += ' type: ' + this.type
str += ', isLast: ' + this.isLast
if (this.error) {
str += '\n ERROR: ' + this.error
}
if (this.hasData) {
str += '\n vendor: ' + this.vendor
if (this.comments.length) {
str += '\n comments:'
for (let i = 0; i < this.comments.length; i++) {
str += '\n ' + this.comments[i].split('=').join(': ')
}
} else {
str += '\n comments: none'
}
}
return str
}
}
module.exports = MetaDataBlockVorbisComment

View File

@ -1,32 +1,32 @@
const fs = require('fs')
const flac = require('flac-metadata')
const { Processor: FlacProcessor, data: { MetaDataBlockVorbisComment: FlacComment } } = require('./flac-metadata')
const vendor = 'reference libFLAC 1.2.1 20070917'
module.exports = (filenPath, meta) => {
const reader = fs.createReadStream(filenPath)
const tempPath = filenPath + '.lxmtemp'
const writer = fs.createWriteStream(tempPath)
const processor = new flac.Processor()
const flacProcessor = new FlacProcessor()
if (meta.APIC) delete meta.APIC
const comments = []
for (const key in meta) {
comments.push(`${key.toUpperCase()}=${meta[key]}`)
}
const vendor = 'lx-music-desktop'
processor.on('preprocess', function(mdb) {
let isInjected = false
flacProcessor.on('preprocess', function(mdb) {
// Remove existing VORBIS_COMMENT block, if any.
if (mdb.type === flac.Processor.MDB_TYPE_VORBIS_COMMENT) {
mdb.remove()
}
if (mdb.type === flacProcessor.MDB_TYPE_VORBIS_COMMENT) mdb.remove()
// Inject new VORBIS_COMMENT block.
if (mdb.removed || mdb.isLast) {
let mdbVorbis = flac.data.MetaDataBlockVorbisComment.create(mdb.isLast, vendor, comments)
if ((mdb.removed || mdb.isLast) && !isInjected) {
isInjected = true
let mdbVorbis = FlacComment.create(mdb.isLast, vendor, comments)
this.push(mdbVorbis.publish())
}
})
reader.pipe(processor).pipe(writer).on('finish', () => {
reader.pipe(flacProcessor).pipe(writer).on('finish', () => {
fs.unlink(filenPath, err => {
if (err) return console.log(err.message)
fs.rename(tempPath, filenPath, err => {

View File

@ -91,7 +91,7 @@ const getUrl = (downloadInfo, isRefresh) => {
* @param {*} isEmbedPic // 是否嵌入图片
*/
const saveMeta = (downloadInfo, filePath, isEmbedPic) => {
if (downloadInfo.type === 'ape' || downloadInfo.type === 'flac') return
if (downloadInfo.type === 'ape') return
const promise = isEmbedPic
? downloadInfo.musicInfo.img
? Promise.resolve(downloadInfo.musicInfo.img)
@ -104,6 +104,12 @@ const saveMeta = (downloadInfo, filePath, isEmbedPic) => {
album: downloadInfo.musicInfo.albumName,
APIC: url,
})
}).catch(() => {
setMeta(filePath, {
title: downloadInfo.musicInfo.name,
artist: downloadInfo.musicInfo.singer,
album: downloadInfo.musicInfo.albumName,
})
})
}

View File

@ -9,7 +9,6 @@ import { getProxyInfo } from './index'
const request = (url, options, callback) => {
let data
if (options.method == 'get') options.headers['Content-Length'] = 0
if (options.body) {
data = options.body
} else if (options.form) {