// ==UserScript==
// @name BlitzRhythm Editor Extra Song Search
// @name:en Extra Song Search
// @name:zh-CN 闪韵灵境歌曲搜索扩展
// @namespace cipher-editor-mod-extra-song-search
// @version 1.1.1
// @description Search for more songs from other websites
// @description:en Search for more songs from other websites
// @description:zh-CN 通过其他网站搜索更多的歌曲
// @author Moyuer
// @author:zh-CN 如梦Nya
// @source https://github.com/CMoyuer/BlitzRhythm-Editor-Mod-Loader
// @license MIT
// @run-at document-body
// @grant unsafeWindow
// @grant GM_xmlhttpRequest
// @connect beatsaver.com
// @match https://cipher-editor-cn.picovr.com/*
// @match https://cipher-editor-va.picovr.com/*
// @icon https://cipher-editor-va.picovr.com/favicon.ico
// @require https://code.jquery.com/jquery-3.6.0.min.js
// @require https://greasyfork.org/scripts/473358-jszip/code/main.js
// @require https://greasyfork.org/scripts/473361-xml-http-request-interceptor/code/main.js
// @require https://greasyfork.org/scripts/473362-web-indexeddb-helper/code/main.js
// @require https://greasyfork.org/scripts/474680-blitzrhythm-editor-mod-base-lib/code/main.js
// ==/UserScript==
const I18N = {
en: { // English
parameter: {
search_page_sum: {
name: "Search Page Count",
description: "Number of pages searched from BeatSaver at one time",
},
search_timeout: {
name: "Search Timeout",
description: "Timeout for searching for songs",
}
},
methods: {
// test: {
// name: "Test",
// description: "Just a test button",
// },
},
code: {
search: {
fail: "Search song failed!",
tip_timeout: "It seems that the search has timed out. Do you need to modify the timeout parameter?"
},
convert: {
title: "Convert To Custom Beatmap",
description: "Convert official beatmaps to custom beatmaps to export beatmap with ogg file.",
btn_name: "Start Convert",
tip_failed: "Conversion failed, please refresh and try again!"
}
}
},
zh: { // Chinese
parameter: {
search_page_sum: {
name: "搜索页面数量",
description: "每次从BeatSaver搜索歌曲的页数,页数越多速度越慢",
},
search_timeout: {
name: "搜索超时",
description: "搜索歌曲的超时时间",
}
},
methods: {
// test: {
// name: "测试",
// description: "只是一个测试按钮",
// },
},
code: {
search: {
fail: "搜索歌曲失败!",
tip_timeout: "看来搜索超时了, 是否需要修改超时时间?"
},
convert: {
title: "转换为自定义谱面",
description: "将官方谱面转换为自定义谱面, 以导出带有Ogg文件的完整谱面压缩包。",
btn_name: "开始转换谱面",
tip_failed: "转换谱面失败,请刷新再试!"
}
}
}
}
const PARAMETER = [
{
id: "search_page_sum",
name: $t("parameter.search_page_sum.name"),
description: $t("parameter.search_page_sum.description"),
type: "number",
default: 1,
min: 1,
max: 10
},
{
id: "search_timeout",
name: $t("parameter.search_timeout.name"),
description: $t("parameter.search_timeout.description"),
type: "number",
default: 10 * 1000,
min: 1000,
max: 20 * 1000
}
]
const METHODS = [
// {
// name: $t("methods.test.name"),
// description: $t("methods.test.description"),
// func: () => {
// log($t("methods.test.name"))
// }
// },
]
let pluginEnabled = false
let timerHandle = 0
function onEnabled() {
pluginEnabled = true
let timerFunc = () => {
if (!pluginEnabled) return
CipherUtils.waitLoading().then(() => {
tick()
}).catch(err => {
console.error(err)
}).finally(() => {
timerHandle = setTimeout(timerFunc, 250)
})
}
timerFunc()
}
function onDisabled() {
if (timerHandle > 0) {
clearTimeout(timerHandle)
timerHandle = 0
}
pluginEnabled = false
searchFromBeatSaver = false
}
function onParameterValueChanged(id, val) {
log("onParameterValueChanged", id, val)
// log("debug", $p(id))
}
// =====================================================================================
/**
* 闪韵灵境工具类
*/
class CipherUtils {
/**
* 获取当前谱面的信息
*/
static getNowBeatmapInfo() {
let url = location.href
// ID
let matchId = url.match(/id=(\w*)/)
let id = matchId ? matchId[1] : ""
// BeatSaverID
let beatsaverId = ""
let nameBoxList = $(".css-tpsa02")
if (nameBoxList.length > 0) {
let name = nameBoxList[0].innerHTML
let matchBeatsaverId = name.match(/\[(\w*)\]/)
if (matchBeatsaverId) beatsaverId = matchBeatsaverId[1]
}
// 难度
let matchDifficulty = url.match(/difficulty=(\w*)/)
let difficulty = matchDifficulty ? matchDifficulty[1] : ""
return { id, difficulty, beatsaverId }
}
/**
* 添加歌曲校验数据头
* @param {ArrayBuffer} rawBuffer
* @returns {Blob}
*/
static addSongVerificationCode(rawBuffer) {
// 前面追加数据,以通过校验
let rawData = new Uint8Array(rawBuffer)
let BYTE_VERIFY_ARRAY = [235, 186, 174, 235, 186, 174, 235, 186, 174, 85, 85]
let buffer = new ArrayBuffer(rawData.length + BYTE_VERIFY_ARRAY.length)
let dataView = new DataView(buffer)
for (let i = 0; i < BYTE_VERIFY_ARRAY.length; i++) {
dataView.setUint8(i, BYTE_VERIFY_ARRAY[i])
}
for (let i = 0; i < rawData.length; i++) {
dataView.setUint8(BYTE_VERIFY_ARRAY.length + i, rawData[i])
}
return new Blob([buffer], { type: "application/octet-stream" })
}
/**
* 获取当前页面类型
* @returns
*/
static getPageType() {
let url = window.location.href
let matchs = url.match(/edit\/(\w{1,})/)
if (!matchs) {
return "home"
} else {
return matchs[1]
}
}
/**
* 显示Loading
*/
static showLoading() {
let maskBox = $('<div style="position:fixed;top:0;left:0;width:100%;height:100%;background-color:rgba(0,0,0,0.5);z-index:9999;" id="loading"></div>')
maskBox.append('<span style="display: block;position: absolute;width:40px;height:40px;left: calc(50vw - 20px);top: calc(50vh - 20px);"><svg viewBox="22 22 44 44"><circle cx="44" cy="44" r="20.2" fill="none" stroke-width="3.6" class="css-14891ef"></circle></svg></span>')
$("#root").append(maskBox)
}
/**
* 隐藏Loading
*/
static hideLoading() {
$("#loading").remove()
}
/**
* 网页弹窗
*/
static showIframe(src) {
this.hideIframe()
let maskBox = $('<div style="position:fixed;top:0;left:0;width:100%;height:100%;background-color:rgba(0,0,0,0.5);z-index:9999;" id="iframe_box"></div>')
maskBox.click(this.hideIframe)
maskBox.append('<iframe src="' + src + '" style="width:calc(100vw - 400px);height:calc(100vh - 200px);position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);border-radius:12px;"></iframe>')
$("#root").append(maskBox)
}
/**
* 隐藏Loading
*/
static hideIframe() {
$("#iframe_box").remove()
}
/**
* 等待Loading结束
* @returns
*/
static waitLoading() {
return new Promise((resolve, reject) => {
let handle = setInterval((() => {
let loadingList = $(".css-c81162")
if (loadingList && loadingList.length > 0) return
clearInterval(handle)
resolve()
}), 500)
})
}
}
/**
* BeatSaver工具类
*/
class BeatSaverUtils {
/**
* 搜索歌曲列表
* @param {string} searchKey 搜索关键字
* @param {number} pageCount 搜索页数
* @returns
*/
static searchSongList(searchKey, pageCount = 1) {
return new Promise(function (resolve, reject) {
let songList = []
let songInfoMap = {}
let count = 0
let cbFlag = false
let timeoutCount = 0
let beatsaverMappingStr = localStorage.getItem("BeatSaverMapping")
let beatSaverMapping = beatsaverMappingStr ? JSON.parse(beatsaverMappingStr) : {
mapping: {}
}
let funDone = () => {
if (++count != pageCount) return
cbFlag = true
resolve({ songList, songInfoMap })
if (timeoutCount > 0) {
let flag = confirm($t("code.search.tip_timeout"))
if (flag) showSetupPage()
}
}
let funSuccess = data => {
// 填充数据
data.docs.forEach(rawInfo => {
let artist = rawInfo.metadata.songAuthorName
let bpm = rawInfo.metadata.bpm
let cover = rawInfo.versions[0].coverURL
let song_name = "[" + rawInfo.id + "]" + rawInfo.metadata.songName
let id = beatSaverMapping.mapping[rawInfo.id]
if (typeof id !== "number")
id = 80000000000 + parseInt(rawInfo.id, 36)
songList.push({ artist, bpm, cover, song_name, id })
let downloadURL = rawInfo.versions[0].downloadURL
let previewURL = rawInfo.versions[0].previewURL
songInfoMap[id] = { rawInfo, downloadURL, previewURL }
})
funDone()
}
let funFail = res => {
if (res[0] === "timeout") timeoutCount++
funDone()
}
for (let i = 0; i < pageCount; i++) {
Utils.ajax({
url: "https://api.beatsaver.com/search/text/" + i + "?sortOrder=Relevance&q=" + searchKey,
method: "GET",
responseType: "json",
timeout: $p("search_timeout")
}).then(funSuccess).catch(funFail)
}
})
}
/**
* 从BeatSaver下载ogg文件
* @param {number} zipUrl 歌曲压缩包链接
* @param {function} onprogress 进度回调
* @returns {Promise<blob, any>}
*/
static async downloadSongFile(zipUrl, onprogress) {
let blob = await Utils.downloadZipFile(zipUrl, onprogress)
// 解压出ogg文件
return await BeatSaverUtils.getOggFromZip(blob)
}
/**
* 从压缩包中提取出ogg文件
* @param {blob} zipBlob
* @param {boolean | undefined} verification
* @returns
*/
static async getOggFromZip(zipBlob, verification = true) {
let zip = await JSZip.loadAsync(zipBlob)
let eggFile = undefined
for (let fileName in zip.files) {
if (!fileName.endsWith(".egg")) continue
eggFile = zip.file(fileName)
break
}
if (verification) {
let rawBuffer = await eggFile.async("arraybuffer")
return CipherUtils.addSongVerificationCode(rawBuffer)
} else {
return await eggFile.async("blob")
}
}
}
/**
* 通用工具类
*/
class Utils {
/**
* 下载压缩包文件
* @param {number} zipUrl 歌曲压缩包链接
* @param {function | undefined} onprogress 进度回调
* @returns {Promise}
*/
static downloadZipFile(zipUrl, onprogress) {
return new Promise(function (resolve, reject) {
Utils.ajax({
url: zipUrl,
method: "GET",
responseType: "blob",
onprogress,
}).then(data => {
resolve(new Blob([data], { type: "application/zip" }))
}).catch(reject)
})
}
/**
* 异步发起网络请求
* @param {object} config
* @returns
*/
static ajax(config) {
return new Promise((resolve, reject) => {
config.onload = res => {
if (res.status >= 200 && res.status < 300) {
try {
resolve(JSON.parse(res.response))
} catch {
resolve(res.response)
}
}
else {
reject("HTTP Code: " + res.status)
}
}
config.onerror = (...data) => {
reject(["error", ...data])
}
config.ontimeout = (...data) => {
reject(["timeout", ...data])
}
GM_xmlhttpRequest(config)
})
}
}
// =====================================================================================
let searchFromBeatSaver = false
let songInfoMap = {}
let lastPageType = "other"
// 加载XHR拦截器
function initXHRIntercept() {
let _this = this
let xhrIntercept = new XHRIntercept()
/**
* @param {XMLHttpRequest} self
* @param {IArguments} args
* @param {function} complete
* @returns {boolean} 是否匹配
*/
let onSend = function (self, args, complete) {
let url = self._url
if (!url || !searchFromBeatSaver) return
if (url.startsWith("/song/staticList")) {
// 获取歌曲列表
let result = decodeURI(url).match(/songName=(\S*)&/)
let key = ""
if (result) key = result[1].replace("+", " ")
CipherUtils.showLoading()
BeatSaverUtils.searchSongList(key, $p("search_page_sum")).then(res => {
self.extraSongList = res.songList
songInfoMap = res.songInfoMap
complete()
}).catch(err => {
alert($t("code.search.fail"))
console.error(err)
self.extraSongList = []
complete()
}).finally(() => {
CipherUtils.hideLoading()
})
self.addEventListener("readystatechange", function () {
if (this.readyState !== this.DONE) return
const res = JSON.parse(this.responseText)
if (this.extraSongList) {
res.data.data = this.extraSongList
res.data.total = res.data.data.length
this.extraSongList = []
}
Object.defineProperty(this, 'responseText', {
writable: true
});
this.responseText = JSON.stringify(res)
setTimeout(() => {
fixSongListStyle()
addPreviewFunc()
}, 200)
});
return true
} else if (url.startsWith("/beatsaver/")) {
let _onprogress = self.onprogress
self.onprogress = undefined
// 从BeatSaver下载歌曲
let result = decodeURI(url).match(/\d{1,}/)
let id = parseInt(result[0])
BeatSaverUtils.downloadSongFile(songInfoMap[id].downloadURL, _onprogress).then(oggBlob => {
songInfoMap[id].ogg = oggBlob
saveBeatSaverMapping(id, songInfoMap[id].rawInfo)
complete()
}).catch(err => {
console.error(err)
self.onerror(err)
})
self.addEventListener("readystatechange", function () {
if (this.readyState !== this.DONE) return
let result = decodeURI(url).match(/\d{1,}/)
let id = parseInt(result[0])
Object.defineProperty(this, 'response', {
writable: true
});
this.response = songInfoMap[id].ogg
});
return true
} else if (url.startsWith("/song/ogg")) {
// 获取ogg文件下载链接
let result = decodeURI(url).match(/id=(\d*)/)
let id = parseInt(result[1])
if (id < 80000000000) return
self.addEventListener("readystatechange", function () {
if (this.readyState !== this.DONE) return
const res = JSON.parse(this.responseText)
res.code = 0
res.data = { link: "/beatsaver/" + id }
res.msg = "success"
Object.defineProperty(this, 'responseText', {
writable: true
});
this.responseText = JSON.stringify(res)
});
complete()
return true
}
}
xhrIntercept.onSend(onSend)
}
// Save BeatSaver Info
function saveBeatSaverMapping(id, rawInfo) {
let beatsaverMappingStr = localStorage.getItem("BeatSaverMapping")
let beatSaverMapping = beatsaverMappingStr ? JSON.parse(beatsaverMappingStr) : {}
if (!beatSaverMapping.mapping) beatSaverMapping.mapping = {}
beatSaverMapping.mapping[rawInfo.id] = id
localStorage.setItem("BeatSaverMapping", JSON.stringify(beatSaverMapping))
}
/**
* 更新数据库
* @param {Boolean} isForce 强制转换
* @returns
*/
async function updateDatabase(isForce) {
let BLITZ_RHYTHM = await WebDB.open("BLITZ_RHYTHM")
let BLITZ_RHYTHM_files = await WebDB.open("BLITZ_RHYTHM-files")
let BLITZ_RHYTHM_official = await WebDB.open("BLITZ_RHYTHM-official")
let songInfos = []
let hasChanged = false
let songsInfo
// 更新歌曲信息
{
let rawSongs = await BLITZ_RHYTHM.get("keyvaluepairs", "persist:songs")
songsInfo = JSON.parse(rawSongs)
let songsById = JSON.parse(songsInfo.byId)
for (let key in songsById) {
let officialId = songsById[key].officialId
if (typeof officialId != "number" || (!isForce && officialId < 80000000000)) continue
let songInfo = songsById[key]
songInfos.push(JSON.parse(JSON.stringify(songInfo)))
songInfo.coverArtFilename = songInfo.coverArtFilename.replace("" + songInfo.officialId, songInfo.id)
songInfo.songFilename = songInfo.songFilename.replace("" + songInfo.officialId, songInfo.id)
songInfo.officialId = ""
// Add Source Info
if (!songInfo.modSettings) songInfo.modSettings = {}
if (!songInfo.modSettings.source) songInfo.modSettings.source = {}
try {
let beatsaverMapping = JSON.parse(localStorage.getItem("BeatSaverMapping") || "{}")
let mapping = beatsaverMapping.mapping || {}
for (let bsId in mapping) {
if (mapping[bsId] !== officialId) continue
songInfo.modSettings.source.beatsaverId = bsId
break
}
} catch (error) {
console.error("Add source info failed:", error)
}
songsById[key] = songInfo
hasChanged = true
}
songsInfo.byId = JSON.stringify(songsById)
}
// 处理文件
for (let index in songInfos) {
let songInfo = songInfos[index]
// 复制封面和音乐文件
let cover = await BLITZ_RHYTHM_official.get("keyvaluepairs", songInfo.coverArtFilename)
let song = await BLITZ_RHYTHM_official.get("keyvaluepairs", songInfo.songFilename)
await BLITZ_RHYTHM_files.put("keyvaluepairs", songInfo.coverArtFilename.replace("" + songInfo.officialId, songInfo.id), cover)
await BLITZ_RHYTHM_files.put("keyvaluepairs", songInfo.songFilename.replace("" + songInfo.officialId, songInfo.id), song)
// 添加info记录
await BLITZ_RHYTHM_files.put("keyvaluepairs", songInfo.id + "_Info.dat", JSON.stringify({ _songFilename: "song.ogg" }))
}
// 保存数据
if (hasChanged) await BLITZ_RHYTHM.put("keyvaluepairs", "persist:songs", JSON.stringify(songsInfo))
BLITZ_RHYTHM.close()
BLITZ_RHYTHM_files.close()
BLITZ_RHYTHM_official.close()
return hasChanged
}
/**
* 修复歌单布局
*/
function fixSongListStyle() {
let songListBox = $(".css-10szcx0")[0]
songListBox.style["grid-template-columns"] = "repeat(3, minmax(0px, 1fr))"
let songBox = songListBox.parentNode
if ($(".css-1wfsuwr").length > 0) {
songBox.style["overflow-y"] = "hidden"
songBox.parentNode.style["margin-bottom"] = ""
} else {
songBox.style["overflow-y"] = "auto"
songBox.parentNode.style["margin-bottom"] = "44px"
}
let itemBox = $(".css-bil4eh")
for (let index = 0; index < itemBox.length; index++)
itemBox[index].style.width = "230px"
}
/**
* 在歌曲Card中添加双击预览功能
*/
function addPreviewFunc() {
let searchBox = $(".css-1d92frk")
$("#preview_tip").remove()
searchBox.after("<div style='text-align: center;color:gray;padding-bottom:10px;' id='preview_tip'>双击歌曲可预览曲谱</div>")
let infoViewList = $(".css-bil4eh")
for (let index = 0; index < infoViewList.length; index++) {
infoViewList[index].ondblclick = () => {
let name = $(infoViewList[index]).find(".css-1y1rcqj")[0].innerHTML
let result = name.match(/^\[(\w*)\]/)
if (!result) return
let previewUrl = "https://skystudioapps.com/bs-viewer/?id=" + result[1]
CipherUtils.showIframe(previewUrl)
// window.open(previewUrl)
}
}
}
/**
* 添加通过BeatSaver搜索歌曲的按钮
*/
function applySearchButton() {
let boxList = $(".css-1u8wof2") // 弹窗
try {
if (boxList.length == 0) throw "Box not found"
let searchBoxList = boxList.find(".css-70qvj9")
if (searchBoxList.length == 0) throw "item too few" // 搜索栏元素数量
if (searchBoxList[0].childNodes.length >= 3) return // 搜索栏元素数量
} catch {
if (searchFromBeatSaver) searchFromBeatSaver = false
return
}
let rawSearchBtn = $(boxList[0]).find("button")[0] // 搜索按钮
// 添加一个按钮
let searchBtn = document.createElement("button")
searchBtn.className = rawSearchBtn.className
searchBtn.innerHTML = "BeatSaver"
$(rawSearchBtn.parentNode).append(searchBtn);
// 绑定事件
rawSearchBtn.onmousedown = () => {
searchFromBeatSaver = false
$("#preview_tip").remove()
}
searchBtn.onmousedown = () => {
searchFromBeatSaver = true
$(rawSearchBtn).click()
}
}
/**
* 添加转换官方谱面的按钮
* @returns
*/
async function applyConvertCiphermapButton() {
let BLITZ_RHYTHM = await WebDB.open("BLITZ_RHYTHM")
try {
let rawSongs = await BLITZ_RHYTHM.get("keyvaluepairs", "persist:songs")
let songsInfo = JSON.parse(rawSongs)
let songsById = JSON.parse(songsInfo.byId)
let songId = CipherUtils.getNowBeatmapInfo().id
let officialId = songsById[songId].officialId
if (!officialId) return
} catch (error) {
console.error(error)
return
} finally {
BLITZ_RHYTHM.close()
}
let divList = $(".css-1tiz3p0")
if (divList.length > 0) {
if ($("#div-custom").length > 0) return
let divBox = $(divList[0]).clone()
divBox[0].id = "div-custom"
divBox.find(".css-ujbghi")[0].innerHTML = $t("code.convert.title")
divBox.find(".css-1exyu3y")[0].innerHTML = $t("code.convert.description")
divBox.find(".css-1y7rp4x")[0].innerText = $t("code.convert.btn_name")
divBox[0].onclick = e => {
// 更新歌曲信息
this.updateDatabase(true).then((hasChanged) => {
if (hasChanged) setTimeout(() => { window.location.reload() }, 1000)
}).catch(err => {
console.log("Convert map failed:", err)
alert($t("code.convert.btn_name"))
})
}
$(divList[0].parentNode).append(divBox)
}
}
/**
* 隐藏按钮
*/
function hideConvertCiphermapButton() {
$("#div-custom").remove()
}
/**
* 定时任务 1s
*/
function tick() {
let pageType = CipherUtils.getPageType()
if (pageType !== "home") {
if (pageType != lastPageType) {
// 隐藏按钮
if (pageType !== "download")
hideConvertCiphermapButton()
// 更新歌曲信息
updateDatabase().then((hasChanged) => {
if (hasChanged) setTimeout(() => { window.location.reload() }, 1000)
}).catch(err => {
console.log("Update map info failed:", err)
alert($t("tip_failed"))
})
} else if (pageType === "download") {
applyConvertCiphermapButton()
}
} else {
applySearchButton()
}
lastPageType = pageType
}
(function () {
'use strict'
// 初始化XHR拦截器
initXHRIntercept()
})()