- // ==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://gf.qytechs.cn/scripts/473358-jszip/code/main.js
- // @require https://gf.qytechs.cn/scripts/473361-xml-http-request-interceptor/code/main.js
- // @require https://gf.qytechs.cn/scripts/473362-web-indexeddb-helper/code/main.js
- // @require https://gf.qytechs.cn/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()
- })()