Bilibili-Evolved/src/video/download-video/batch-download.ts

238 lines
7.9 KiB
TypeScript

const fragmentSplitFactor = 12
interface BatchItem {
aid: number | string
cid: number | string
title: string
}
interface RawItemFragment { length: number, size: number, url: string }
interface RawItem {
fragments: RawItemFragment[]
title: string
totalSize: number
cid: number | string
referer: string
}
abstract class Batch {
itemList: BatchItem[] = []
itemFilter: (item: BatchItem) => boolean = () => true
abstract async getItemList(): Promise<BatchItem[]>
abstract async collectData(quality: number | string): Promise<string>
async collectAria2(quality: number | string, rpc: boolean) {
const json: RawItem[] = JSON.parse(await this.collectData(quality))
if (rpc) {
const option = settings.aria2RpcOption
const { sendRpc } = await import('./aria2-rpc')
for (const item of json) {
const params = item.fragments.map((fragment: { url: string }, index: number) => {
let indexNumber = ''
if (item.fragments.length > 1) {
indexNumber = ' - ' + (index + 1)
}
const params = []
if (option.secretKey !== '') {
params.push(`token:${option.secretKey}`)
}
params.push([fragment.url])
params.push({
referer: document.URL.replace(window.location.search, ''),
'user-agent': UserAgent,
out: `${item.title}${indexNumber}.flv`,
split: fragmentSplitFactor,
dir: (option.baseDir + option.dir) || undefined,
'max-download-limit': option.maxDownloadLimit || undefined,
})
const id = encodeURIComponent(`${item.title}${indexNumber}`)
return {
params,
id,
}
})
await sendRpc(params, true)
}
} else {
return `
# Generated by Bilibili Evolved Video Export
# https://github.com/the1812/Bilibili-Evolved/
${json.map(item => {
return item.fragments.map((f, index) => {
let indexNumber = ''
if (item.fragments.length > 1) {
indexNumber = ` - ${index + 1}`
}
return `
${f.url}
referer=${item.referer}
user-agent=${UserAgent}
out=${item.title}${indexNumber}.flv
split=${fragmentSplitFactor}
`.trim()
})
}).join('\n')}
`.trim()
}
}
}
class VideoEpisodeBatch extends Batch {
static async test() {
if (!document.URL.includes('/www.bilibili.com/video/av')) {
return false
}
return await SpinQuery.select('#multi_page') !== null
}
async getItemList() {
if (this.itemList.length > 0) {
return this.itemList
}
const api = `https://api.bilibili.com/x/web-interface/view?aid=${unsafeWindow.aid}`
const json = await Ajax.getJson(api)
if (json.code !== 0) {
Toast.error(`获取视频选集列表失败, message=${json.message}`, '批量下载')
return []
}
const pages = json.data.pages
if (pages === undefined) {
Toast.error(`获取视频选集列表失败, 没有找到选集信息.`, '批量下载')
return []
}
this.itemList = pages.map((page: any) => {
return {
title: `P${page.page} ${page.part}`,
cid: page.cid,
aid: unsafeWindow.aid,
}
})
return this.itemList
}
async collectData(quality: number | string) {
const result = []
for (const item of (await this.getItemList()).filter(this.itemFilter)) {
const url = `https://api.bilibili.com/x/player/playurl?avid=${item.aid}&cid=${item.cid}&qn=${quality}&otype=json`
const json = await Ajax.getJsonWithCredentials(url)
const data = json.data || json.result || json
if (data.quality !== quality) {
console.warn(`${item.title} 不支持所选画质, 已回退到较低画质. (quality=${data.quality})`)
}
const fragments: RawItemFragment[] = data.durl.map((it: any) => {
return {
length: it.length,
size: it.size,
url: it.url
}
})
result.push({
fragments,
title: item.title.replace(/[\/\\:\*\?"<>\|]/g, ' '),
totalSize: fragments.map(it => it.size).reduce((acc, it) => acc + it),
cid: item.cid,
referer: document.URL.replace(window.location.search, '')
})
}
return JSON.stringify(result)
}
}
class BangumiBatch extends Batch {
static async test() {
return document.URL.includes('/www.bilibili.com/bangumi')
}
async getItemList() {
if (this.itemList.length > 0) {
return this.itemList
}
const metaUrl = document.querySelector("meta[property='og:url']")
if (metaUrl === null) {
Toast.error('获取番剧数据失败: 无法找到 Season ID', '批量下载')
return []
}
const seasonId = metaUrl.getAttribute('content')!.match(/play\/ss(\d+)/)![1]
if (seasonId === undefined) {
Toast.error('获取番剧数据失败: 无法解析 Season ID', '批量下载')
return []
}
const json = await Ajax.getJson(`https://api.bilibili.com/pgc/web/season/section?season_id=${seasonId}`)
if (json.code !== 0) {
Toast.error(`获取番剧数据失败: 无法获取番剧集数列表, message=${json.message}`, '批量下载')
return []
}
this.itemList = json.result.main_section.episodes.map((it: any, index: number) => {
return {
aid: it.aid,
cid: it.cid,
title: it.long_title ? `${it.title} - ${it.long_title}` : `${index + 1} - ${it.title}`,
}
})
return this.itemList
}
async collectData(quality: string | number) {
const result = []
for (const item of (await this.getItemList()).filter(this.itemFilter)) {
const url = `https://api.bilibili.com/pgc/player/web/playurl?avid=${item.aid}&cid=${item.cid}&qn=${quality}&otype=json`
const json = await Ajax.getJsonWithCredentials(url)
const data = json.data || json.result || json
if (data.quality !== quality) {
console.warn(`${item.title} 不支持所选画质, 已回退到较低画质. (quality=${data.quality})`)
}
const fragments: RawItemFragment[] = data.durl.map((it: any) => {
return {
length: it.length,
size: it.size,
url: it.url
}
})
result.push({
fragments,
title: item.title.replace(/[\/\\:\*\?"<>\|]/g, ' '),
totalSize: fragments.map(it => it.size).reduce((acc, it) => acc + it),
cid: item.cid,
referer: document.URL.replace(window.location.search, '')
})
}
return JSON.stringify(result)
}
}
const extractors = [BangumiBatch, VideoEpisodeBatch]
let ExtractorClass: new() => Batch
export class BatchExtractor {
itemFilter: (item: BatchItem) => boolean = () => true
static async test() {
for (const e of extractors) {
if (await e.test() === true) {
ExtractorClass = e
return true
}
}
return false
}
getExtractor() {
if (ExtractorClass === null) {
logError('[批量下载] 未找到合适的解析模块.')
throw new Error(`[Batch Download] module not found.`)
}
const extractor = new ExtractorClass()
extractor.itemFilter = this.itemFilter
return extractor
}
async getItemList() {
const extractor = this.getExtractor()
return await extractor.getItemList()
}
async collectData(format: { quality: number | string }, toast: Toast) {
const extractor = this.getExtractor()
const result = await extractor.collectData(format.quality)
toast.dismiss()
return result
}
async collectAria2(format: { quality: number | string }, toast: Toast, rpc: false): Promise<string>
async collectAria2(format: { quality: number | string }, toast: Toast, rpc: true): Promise<undefined>
async collectAria2(format: { quality: number | string }, toast: Toast, rpc = false) {
const extractor = this.getExtractor()
const result = await extractor.collectAria2(format.quality, rpc)
toast.dismiss()
return result
}
}
export default {
export: {
BatchExtractor
}
}