// ==UserScript==
// @name Bilibili Music Extractor
// @namespace http://tampermonkey.net/
// @version 0.2.3
// @description 从B站上提取带封面的音乐
// @author ☆
// @include https://www.bilibili.com/video/*
// @icon https://www.bilibili.com/favicon.ico
// @require https://unpkg.com/@ffmpeg/[email protected]/dist/ffmpeg.min.js
// @license MIT
// @grant none
// ==/UserScript==
(function() {
'use strict';
// Your code here...
const CHUNK_SIZE = 1024 * 1024 * 1;
const download = (url, filename) => {
const stubLink = document.createElement('a');
stubLink.style.display = 'none';
stubLink.href = url;
stubLink.download = filename;
document.body.appendChild(stubLink);
stubLink.click();
document.body.removeChild(stubLink);
}
const getAudioPieces = (baseUrl, start, end) => {
const headers = {
'Range': 'bytes=' + start + '-' + end,
'Referer': location.href
};
const result = [];
console.log('start fetching piece...');
return fetch(baseUrl, {
method: 'GET',
cache: 'no-cache',
headers,
referrerPolicy: 'no-referrer-when-downgrade',
}).then(response => {
if (response.status === 416) {
console.log('reached last piece');
throw response;
}
if (!response.ok) {
console.error(response);
throw new Error('Network response was not ok');
}
if (!response.headers.get('Content-Range')) {
console.log('content reached the end');
const endError = new Error('reached the end');
endError.status = 204;
throw endError;
}
return response.blob();
}).then(buffer => {
result.push(buffer);
return getAudioPieces(baseUrl, end + 1, end + CHUNK_SIZE);
}).then(buffers => {
return result.concat(buffers);
}).catch(error => {
if (error.status === 204) {
return result;
} else if (error.status === 416) {
return getLastAudioPiece(baseUrl, start).then(lastPiece => {
result.push(lastPiece);
return result;
});
} else throw error;
})
}
const getLastAudioPiece = (baseUrl, start) => {
const headers = {
'Range': '' + start + '-',
'Referer': location.href
};
console.log('start fetching last piece...');
return fetch(baseUrl, {
method: 'GET',
cache: 'no-cache',
headers,
referrerPolicy: 'no-referrer-when-downgrade',
}).then(response => {
if (!response.ok) {
console.error(response);
throw new Error('Network response was not ok');
}
return response.blob();
})
}
const getAudio = (baseUrl) => {
const start = 0;
const end = CHUNK_SIZE - 1;
return getAudioPieces(baseUrl, start, end);
}
const getInfo = (fieldname) => {
let info = '';
const infoMetadataElement = document.head.querySelector(`meta[itemprop="${fieldname}"]`);
if (infoMetadataElement) {
info = infoMetadataElement.content;
}
return info.trim();
}
const getLyricsTime = (seconds) => {
const minutes = Math.floor(seconds / 60);
const rest = seconds - minutes * 60;
return `${minutes < 10 ? '0' : ''}${minutes}:${rest < 10 ? '0' : ''}${rest.toFixed(2)}`;
};
const getLyrics = () => {
if (
!__INITIAL_STATE__
|| !__INITIAL_STATE__.videoData
|| !__INITIAL_STATE__.videoData.subtitle
|| !Array.isArray(__INITIAL_STATE__.videoData.subtitle.list)
|| __INITIAL_STATE__.videoData.subtitle.list.length === 0
) return Promise.resolve(null);
const defaultLyricsUrl = __INITIAL_STATE__.videoData.subtitle.list[0].subtitle_url;
return fetch(defaultLyricsUrl.replace('http', 'https'))
.then(response => response.json())
.then(lyricsObject => {
if (!lyricsObject) return null;
const videoElement = document.querySelector('#bilibiliPlayer .bilibili-player-video bwp-video') || document.querySelector('#bilibiliPlayer .bilibili-player-video video');
if (!videoElement) return null;
const totalLength = videoElement.duration;
const lyrics = lyricsObject.body;
const lyricsText = lyricsObject.body.reduce((accu, current) => {
accu += `[${getLyricsTime(current.from)}]${current.content}\r\n`;
return accu;
}, '');
return lyricsText;
});
}
const parse = () => {
const playInfoHead = 'window.__playinfo__=';
const scriptElements = document.head.getElementsByTagName('script');
const playInfoScript = Array.from(scriptElements).find(script => script.text.trim().startsWith(playInfoHead));
if (playInfoScript) {
const playInfoText = playInfoScript.text.trim().substring(playInfoHead.length);
const playInfo = JSON.parse(playInfoText);
const audioUrlList = playInfo.data.dash.audio;
if (Array.isArray(audioUrlList) && audioUrlList.length > 0) {
const {baseUrl, mimeType} = audioUrlList[0];
return getAudio(baseUrl).then(result => {
const wholeBlob = new Blob(result, {type: mimeType});
return wholeBlob.arrayBuffer().then(buffer => ({ buffer, mimeType }));
}).catch(error => {
console.error('There has been a problem with your fetch operation:', error);
});
}
}
return Promise.resolve();
}
const buildPluginElement = () => {
const styles = {
color: {
primary: '#00a1d6',
secondary: '#fb7299',
lightText: '#f4f4f4'
},
spacing: {
xsmall: '0.25rem',
small: '0.5rem',
medium: '1rem',
large: '2rem',
xlarge: '3rem'
}
};
const strings = {
cover: {
title: '封面'
},
infoItems: {
filename: '文件名',
title: '标题',
author: '作者'
},
download: {
idle: '下载音乐',
processing: '处理中…',
lyrics: '下载歌词',
noLyrics: '无歌词'
}
}
const box = document.createElement('div');
box.isOpen = false;
// ------------- Container Box START -------------
const resetBoxStyle = () => {
box.style.position = 'absolute';
box.style.left = `calc(-${styles.spacing.xlarge} - ${styles.spacing.small})`;
box.style.top = 0;
box.style.transition = box.style.webkitTransition = 'all 0.25s ease';
box.style.width = box.style.height = `calc(${styles.spacing.xlarge} + ${styles.spacing.small})`;
box.style.border = `solid 0.25rem ${styles.color.primary}`;
box.style.opacity = 0.5;
box.style.cursor = 'pointer';
box.style.zIndex = 100;
box.style.boxSizing = 'border-box';
box.style.overflow = 'hidden';
box.style.padding = styles.spacing.small;
box.style.display = 'flex';
box.style.flexDirection = 'column';
};
const openBox = () => {
box.style.width = '40rem';
box.style.height = '40rem';
box.style.backgroundColor = 'white';
box.style.cursor = 'auto';
box.isOpen = true;
}
const closeBox = () => {
resetBoxStyle();
box.isOpen = false;
}
resetBoxStyle();
box.addEventListener('mouseenter', () => {
box.style.opacity = 1;
});
box.addEventListener('mouseleave', () => {
if (!box.isOpen) box.style.opacity = 0.5;
});
box.addEventListener('click', () => {
if (!box.isOpen) openBox();
});
// ------------- Container Box END -------------
// ------------- Icon START -------------
const svgNamespace = 'http://www.w3.org/2000/svg';
const icon = document.createElementNS(svgNamespace, 'svg');
const useIcon = document.createElementNS(svgNamespace, 'use');
useIcon.setAttributeNS('http://www.w3.org/1999/xlink', "href", "#bili-music");
icon.style.width = icon.style.height = styles.spacing.large;
icon.style.flexShrink = 0;
icon.appendChild(useIcon);
// ------------- Icon END -------------
// ------------- Close Button START -------------
const closeButton = document.createElement('button');
closeButton.className = 'bilifont bili-icon_sousuo_yichu cancel-icon';
closeButton.style.width = closeButton.style.height = styles.spacing.large;
closeButton.style.position = 'absolute';
closeButton.style.left = `max(${styles.spacing.xlarge} + ${styles.spacing.small}, 100% - ${styles.spacing.large} - ${styles.spacing.small})`;
closeButton.style.top = styles.spacing.small;
closeButton.style.display = 'flex';
closeButton.style.alignItems = 'center';
closeButton.style.justifyContent = 'center';
closeButton.style.fontSize = '1.5em';
closeButton.style.color = styles.color.primary;
closeButton.addEventListener('click', (e) => {
e.stopPropagation();
closeBox();
});
// ------------- Close Button END -------------
// ------------- Panel START -------------
const panel = document.createElement('div');
panel.style.flex = '1';
panel.style.margin = '0';
panel.style.alignSelf = 'stretch';
panel.style.overflow = 'auto';
panel.style.marginTop = styles.spacing.small;
panel.style.paddingTop = styles.spacing.small;
panel.style.borderTop = `solid 0.125rem ${styles.color.primary}`;
// ------------- Panel END -------------
const setTitleStyles = element => {
element.style.lineHeight = 1.5;
element.style.margin = 0;
element.style.padding = 0;
element.style.color = styles.color.primary;
};
const coverImageUrl = getInfo('image');
// ------------- Cover START -------------
const coverContainer = document.createElement('div');
coverContainer.style.width = '100%';
coverContainer.style.marginBottom = styles.spacing.small;
const coverTitle = document.createElement('h5');
coverTitle.textContent = strings.cover.title;
setTitleStyles(coverTitle);
const coverImage = document.createElement('img');
coverImage.style.width = '100%';
coverImage.objectFit = 'contain';
coverImage.src = coverImageUrl;
coverContainer.append(coverTitle, coverImage);
// ------------- Cover END -------------
// ------------- Info Item START -------------
const buildInfoItem = (title, text) => {
const infoContainer = document.createElement('div');
infoContainer.style.width = '100%';
infoContainer.style.display = 'flex';
infoContainer.style.alignItems = 'center';
infoContainer.style.flexWrap = 'nowrap';
infoContainer.style.overflow = 'hidden';
infoContainer.style.marginBottom = styles.spacing.small;
const infoTitle = document.createElement('h5');
infoTitle.textContent = title;
setTitleStyles(infoTitle);
infoTitle.display = 'inline';
const infoText = document.createElement('input');
infoText.type = 'text';
infoText.value = text;
infoText.style.flex = '1';
infoText.style.marginLeft = styles.spacing.xsmall;
infoText.style.background = 'none';
infoText.style.border = '0';
infoText.style.borderBottom = `solid 1px ${styles.color.primary}`;
infoText.style.padding = styles.spacing.xsmall;
infoContainer.append(infoTitle, infoText);
infoContainer.textInput = infoText;
return infoContainer;
}
const dummyText = /_哔哩哔哩.+/;
const titleText = getInfo('name').replace(dummyText, '');
const filenameItem = buildInfoItem(strings.infoItems.filename, titleText + '.mp3');
const titleItem = buildInfoItem(strings.infoItems.title, titleText);
const authorItem = buildInfoItem(strings.infoItems.author, getInfo('author'));
// ------------- Info Item END -------------
// ------------- Download Button START -------------
const downloadButton = document.createElement('button');
downloadButton.textContent = strings.download.idle;
downloadButton.style.background = 'none';
downloadButton.style.border = '0';
downloadButton.style.backgroundColor = styles.color.primary;
downloadButton.style.color = styles.color.lightText;
downloadButton.style.width = '45%';
downloadButton.style.cursor = 'pointer';
downloadButton.style.textAlign = 'center';
downloadButton.style.padding = styles.spacing.small;
downloadButton.style.marginBottom = styles.spacing.small;
downloadButton.style.transition = downloadButton.style.webkitTransition = 'all 0.25s ease';
downloadButton.addEventListener('mouseenter', () => {
downloadButton.style.filter = 'brightness(1.1)';
});
downloadButton.addEventListener('mouseleave', () => {
downloadButton.style.filter = 'none';
});
downloadButton.addEventListener('mousedown', () => {
downloadButton.style.filter = 'brightness(0.9)';
});
downloadButton.addEventListener('mouseup', () => {
downloadButton.style.filter = 'brightness(1.1)';
});
downloadButton.addEventListener('click', (e) => {
if (downloadButton.disabled) return;
e.stopPropagation();
downloadButton.textContent = strings.download.processing;
downloadButton.disabled = true;
downloadButton.style.cursor = 'not-allowed';
const title = unescape(encodeURIComponent(titleItem.textInput.value));
const author = unescape(encodeURIComponent(authorItem.textInput.value));
return parse(filenameItem.textInput.value)
.then(({ buffer, mimeType }) => {
const {
createFFmpeg,
fetchFile
} = FFmpeg;
const ffmpeg = createFFmpeg();
const transcode = (blob, filename) => fetchFile(blob)
.then((file) => {
ffmpeg.FS('writeFile', 'original.mp3', file);
console.log('encoding file...');
return ffmpeg.run(
'-i', 'original.mp3',
'-i', 'cover.jpg',
'-map', '0',
'-map', '1:v',
'-ar', '44100',
'-b:a', '192k',
'-disposition:v:1', 'attached_pic',
'out.mp3'
).then(() => {
console.log('adding metadata...');
const videoElement = document.querySelector('#bilibiliPlayer .bilibili-player-video bwp-video') || document.querySelector('#bilibiliPlayer .bilibili-player-video video');
return ffmpeg.run(
'-i', 'out.mp3',
'-codec', 'copy',
'-t', `${videoElement.duration}`,
'-metadata', `title=${title}`,
'-metadata', `artist=${author}`,
'outWithMetadata.mp3'
);
});
}).then(() => {
const data = ffmpeg.FS('readFile', 'outWithMetadata.mp3');
return data.buffer;
});
ffmpeg.load()
.then(() => fetch(coverImageUrl.replace('http', 'https')))
.then(response => response.blob())
.then(fetchFile)
.then(imageFile => {
ffmpeg.FS('writeFile', 'cover.jpg', imageFile);
return transcode(buffer, filenameItem.textInput.value);
}).then(encodedBuffer => {
const audioBlob = new Blob([encodedBuffer], {type: mimeType});
const audioUrl = URL.createObjectURL(audioBlob);
return download(audioUrl, filenameItem.textInput.value);
})
.finally(() => {
downloadButton.textContent = strings.download.idle;
downloadButton.disabled = false;
downloadButton.style.cursor = 'pointer';
});
});
});
const downloadLyricsButton = downloadButton.cloneNode();
downloadLyricsButton.disabled = true;
downloadLyricsButton.style.cursor = 'not-allowed';
downloadLyricsButton.style.marginRight = '10%';
downloadLyricsButton.textContent = strings.download.noLyrics;
downloadLyricsButton.addEventListener('mouseenter', () => {
downloadLyricsButton.style.filter = 'brightness(1.1)';
});
downloadLyricsButton.addEventListener('mouseleave', () => {
downloadLyricsButton.style.filter = 'none';
});
downloadLyricsButton.addEventListener('mousedown', () => {
downloadLyricsButton.style.filter = 'brightness(0.9)';
});
downloadLyricsButton.addEventListener('mouseup', () => {
downloadLyricsButton.style.filter = 'brightness(1.1)';
});
let lyricsText = null;
getLyrics().then(lyrics => {
if (!lyrics) return;
lyricsText = lyrics;
downloadLyricsButton.disabled = false;
downloadLyricsButton.style.cursor = 'pointer';
downloadLyricsButton.textContent = strings.download.lyrics;
})
downloadLyricsButton.addEventListener('click', (e) => {
if (downloadLyricsButton.disabled) return;
e.stopPropagation();
downloadLyricsButton.textContent = strings.download.processing;
downloadLyricsButton.disabled = true;
downloadLyricsButton.style.cursor = 'not-allowed';
const title = titleItem.textInput.value;
const author = authorItem.textInput.value;
lyricsText = `[ti:${title}]\n[ar:${author}]\n${lyricsText}`.trim();
const lyrics = new Blob([lyricsText], {type: 'text/plain'});
const lyricsUrl = URL.createObjectURL(lyrics);
download(lyricsUrl, filenameItem.textInput.value.replace(/\.[^\s\.]+$/, '.lrc'));
downloadLyricsButton.textContent = strings.download.lyrics;
downloadButton.disabled = false;
downloadLyricsButton.style.cursor = 'pointer';
});
// ------------- Download Button END -------------
panel.append(
coverContainer,
filenameItem,
titleItem,
authorItem,
downloadLyricsButton,
downloadButton
);
box.append(
icon,
closeButton,
panel
);
return box;
}
const bilibiliPlayer = document.querySelector('#bilibiliPlayer');
if (bilibiliPlayer) {
const pluginBox = buildPluginElement();
bilibiliPlayer.appendChild(pluginBox);
}
})();