Refactor VideoDownloader

This commit is contained in:
the1812 2019-11-02 23:45:13 +08:00
parent 4d23654940
commit cd96cdfce2
17 changed files with 267 additions and 145 deletions

View File

@ -35,6 +35,7 @@ declare global {
}
type RunAtOptions = "document-start" | "document-end" | "document-idle" | "document-body" | "context-menu";
type DanmakuOption = '无' | 'XML' | 'ASS'
type FfmpegOption = '无' | '文件列表' | '文件列表+脚本'
type Pattern = string
interface RpcOption {
secretKey: string
@ -369,6 +370,7 @@ declare global {
customNavbarSeasonLogo: boolean,
selectableColumnText: boolean,
downloadVideoFormat: 'flv' | 'dash',
downloadVideoFfmpegSupport: FfmpegOption,
enableDashDownload: boolean,
watchlaterExpireWarnings: boolean,
watchlaterExpireWarningDays: number,

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -44,10 +44,10 @@
"double-click-fullscreen.min.js": "09C035FC7E281ABB042A4200F69757AB093079DBCC9939BE8C53C23EE2EDE41C",
"download-audio.min.js": "1F4536DE4FF302BFD026CA17E028AF8EB037FB6D0CB355692549DECAEAE0E8C9",
"download-danmaku.min.js": "B254644525ABF064DB770FE986D2AB88063406783EC0538004EFBE0E2B2ACD62",
"download-video-package.min.js": "13FE4E54C20669A6726DAFDA4D9433D8AE495530494E5C8A989FB6EDC28D7F85",
"download-video-package.min.js": "9460B7FC79E4F7A2CCC35F5CC9B01A5D7476BAC9833BAD15917C6806C488E17E",
"download-video.min.css": "B570412DD5A8AA9C10020600084CE5EB520E92CDDA03B78450E6480B6A60DB3C",
"download-video.min.html": "4311ABAF2886EB02B1003EA864DD605B113CDB05FF04C3A6882AD931F320D216",
"download-video.min.js": "91131BF338135AC256B8F743A33DE586A87EBC461D436A0E59107607C14424A5",
"download-video.min.html": "08F44F7F2A6C7680D89F229CA9A99FFBCDBE2CC79C912AD6432750FC631E667E",
"download-video.min.js": "6CAC0A0EFB6B3C196027D8338B50334A1BE1D02B953536BC16D7AEE9C507DB4B",
"dpi-img.vue.min.js": "3D9D93079365DCABA8DC8970F051AD1A7F25C3EFFF850CD1000C4312E9B910DD",
"expand-danmaku.min.js": "B21658C40085AEA8DC49652AE62EB8610BB2EBEAF7A9C9AF69EF3B11E08EEC8D",
"expand-description.min.css": "58C7710A50521B80F7D872BDC4C652610D84C4FABC6874BA66DA37B4F8759224",
@ -167,7 +167,7 @@
"v-dropdown.vue.min.js": "D26B4A74515F53A34A3CB0590FC9683476D99FBBBB9647FDFCBF33E265C3F662",
"video-card-info.min.js": "13B755C0EE0CA018AE65251E168BAF3395FEA2EB4FAD6949AADD752F61B8819B",
"video-card.vue.min.js": "340436CDE3B102F467F671411D4373E0DBF59C5A72867D18BDF45BA6C66156C2",
"video-dash.min.js": "AB61CB28867B75A472CA6450046F6F10862BD1D8D8D206A3AAB143CDCE27B8A0",
"video-dash.min.js": "57EEF7B547E9BD2A84338F89869D4F5DAF8A08C9F372C915E8F68E4A2B51CF43",
"video-downloader-fragment.min.js": "13B755C0EE0CA018AE65251E168BAF3395FEA2EB4FAD6949AADD752F61B8819B",
"video-info.min.js": "7234A74CA28A772A31E41BBD4925FC2B22C6701419ADFD511402C6A9D6D9E5FA",
"video-list.vue.min.js": "BC1DB652C4CD8E98AC620E74ED306AACE0CD99359EE472BFD2A846ADCA37AD0B",

Binary file not shown.

View File

@ -1 +1 @@
(()=>{return(e,t)=>{class n{constructor(e={}){this.config=e;this.entries=[]}download(e,t){const n=document.createElement("a");const i=URL.createObjectURL(t);n.setAttribute("href",i);n.setAttribute("download",e);document.body.appendChild(n);n.click();n.remove();URL.revokeObjectURL(i)}add(e,t){if(t===null||t===undefined){return}this.entries.push({name:e,data:t})}async emit(e){if(this.entries.length===0){return}if(!e||this.entries.length===1){e=this.entries[0].name}if(this.entries.length===1){const t=this.entries[0].data;this.download(e,typeof t==="string"?new Blob([t]):t);return}const t=new JSZip;this.entries.forEach(({name:e,data:n})=>{t.file(e,n)});const n=await t.generateAsync({type:"blob"});this.download(e,n)}static async single(e,t){const i=new n;i.add(e,t);await i.emit()}}return{export:{DownloadVideoPackage:n}}}})();
(()=>{return(e,t)=>{class n{constructor(e={}){this.config=e;this.entries=[]}download(e,t){const i=document.createElement("a");const a=URL.createObjectURL(t);if(n.lastPackageUrl){URL.revokeObjectURL(n.lastPackageUrl)}n.lastPackageUrl=a;i.setAttribute("href",a);i.setAttribute("download",e);document.body.appendChild(i);i.click();i.remove()}add(e,t){if(t===null||t===undefined){return}this.entries.push({name:e,data:t})}async preEmit(){const e=this.entries.filter(e=>[".flv",".mp4",".m4s"].some(t=>e.name.endsWith(t)));if(this.config.ffmpeg!==undefined&&e.length>=2){if(this.config.ffmpeg==="文件列表"||this.config.ffmpeg==="文件列表+脚本"){this.entries.push({name:"ffmpeg-files.txt",data:e.map(e=>`file '${e.name}'`).join("\n")})}if(this.config.ffmpeg==="文件列表+脚本"){const e=`ffmpeg -f concat -i ffmpeg-files.txt -c copy ""`;this.entries.push({name:"ffmpeg.bat",data:e},{name:"ffmpeg.sh",data:e.replace(/"/g,"'")})}}}async blob(){await this.preEmit();if(this.entries.length===0){return null}if(this.entries.length===1){const e=this.entries[0].data;return typeof e==="string"?new Blob([e]):e}const e=new JSZip;this.entries.forEach(({name:t,data:n})=>{e.file(t,n)});return await e.generateAsync({type:"blob"})}async emit(e){if(!e||this.entries.length===1){e=this.entries[0].name}const t=await this.blob();if(!t){return}return this.download(e,t)}static async single(e,t,i={}){const a=new n(i);a.add(e,t);return await a.emit()}}n.lastPackageUrl="";return{export:{DownloadVideoPackage:n}}}})();

View File

@ -1 +1 @@
<div class=download-video><a v-bind:href=blobUrl id=video-complete style="display: none"></a><div class=header><h1>下载视频</h1><i class="mdi mdi-close"v-on:click=close()></i></div><div v-if=batch class=tabs><div class="tab download-single"v-bind:class="{active: downloadSingle}"v-on:click="downloadSingle = true">单个视频</div><div class="tab download-batch"v-bind:class="{active: !downloadSingle}"v-on:click="downloadSingle = false">批量导出</div></div><div v-show=downloadSingle class=info><img v-bind:src=coverUrl class=cover><div class=title><span class=size><div v-if=sizeWarning class=size-warning><i class="mdi mdi-alert-circle"></i><div class=size-warning-tip>警告: 过大的视频大小会在直接下载时占用大量内存, 并可能导致浏览器标签页崩溃. 请考虑降低清晰度或使用导出选项.</div></div>预计大小: {{displaySize}}</span></div></div><div class=options><div class=option-item v-if=enableDash>格式<v-dropdown style="text-transform: uppercase;"v-on:change=dashChange() v-bind:items=dashModel.items v-bind:value.sync=dashModel.value></v-dropdown></div><div class=option-item>清晰度<v-dropdown v-on:change=formatChange() v-bind:items=qualityModel.items v-bind:value.sync=qualityModel.value></v-dropdown></div><div class=option-item>弹幕<v-dropdown v-on:change=danmakuOptionChange() v-bind:items=danmakuModel.items v-bind:value.sync=danmakuModel.value></v-dropdown></div></div><div class=separator></div><div v-show=downloadSingle class=direct-download><div class=direct-download-header><h2>直接下载</h2><span class=download-speed>{{speed}}</span></div><button v-if=!downloading class="primary start-download"v-on:click=startDownload()>开始</button><button v-else class="primary cancel-download"v-on:click=cancelDownload()>取消</button><div class=progress><div class=background><div class=foreground v-bind:style="{width: progressPercent + '%'}"></div></div><span class=percent>{{progressPercent}}%</span></div></div><div v-show=!downloadSingle class=batch-download><div class=episode-header><h2>选集</h2><button class=list-tool title=全选 v-on:click=selectAllEpisodes()><i class="mdi mdi-checkbox-multiple-marked-circle"></i></button><button class=list-tool title=全不选 v-on:click=unselectAllEpisodes()><i class="mdi mdi-checkbox-multiple-blank-circle-outline"></i></button><button class=list-tool title=反选 v-on:click=inverseAllEpisodes()><i class="mdi mdi-circle-slice-4"></i></button><span class=selected-count>{{selectedEpisodeCount}}/{{episodeList.length}}</span></div><div class=episode-list><v-checkbox v-for="ep of episodeList"v-bind:key=ep.index v-bind:title=ep.title v-bind:checked.sync=ep.checked></v-checkbox></div></div><div class=separator></div><div class=exports><h2>导出</h2><div class=actions v-bind:class="{busy: busy}"><button class=show-link v-bind:disabled="downloadSingle ? null : 'disabled'"v-on:click="exportData('showLink')">显示链接</button><button class=aria2-file v-on:click="exportData('aria2')">aria2</button><div class="button aria2-rpc"v-on:click.self=toggleRpcSettings()>{{showRpcSettings ? '取消' : 'aria2 RPC'}}<i :class="{'mdi-close': showRpcSettings, 'mdi-chevron-right': !showRpcSettings}"class=mdi v-on:click.self=toggleRpcSettings()></i><div class=rpc-settings v-bind:class="{show: showRpcSettings}"><h1>aria2 RPC</h1><rpc-profiles @profile-change=updateProfile></rpc-profiles><h2>配置</h2><div class=rpc-settings-item>主机<input type=text v-model=rpcSettings.host placeholder=127.0.0.1></div><div class=rpc-settings-item>端口<input type=text v-model=rpcSettings.port placeholder=6800></div><div class=rpc-settings-item>密钥<input type=text v-model=rpcSettings.secretKey></div><div class=rpc-settings-item>限速<input type=text v-model=rpcSettings.maxDownloadLimit placeholder=></div><div class=rpc-settings-item>默认路径<input type=text v-model=rpcSettings.baseDir></div><div class=rpc-settings-item>路径<input type=text v-model=rpcSettings.dir></div><div class=final-dir>最终路径: {{rpcSettings.baseDir + rpcSettings.dir}}</div><div class=rpc-settings-item>方法<v-dropdown style="text-transform: uppercase;"v-bind:items="['get', 'post']"v-bind:value.sync=rpcSettings.method></v-dropdown></div><div class=operations><div class="primary button"v-on:click="saveRpcSettings();toggleRpcSettings();exportData('aria2RPC')">开始下载</div><div class=button v-on:click=saveRpcSettings()>{{saveRpcSettingsText}}</div></div></div></div></div></div></div>
<div class=download-video><a v-bind:href=blobUrl id=video-complete style="display: none"></a><div class=header><h1>下载视频</h1><i class="mdi mdi-close"v-on:click=close()></i></div><div v-if=batch class=tabs><div class="tab download-single"v-bind:class="{active: downloadSingle}"v-on:click="downloadSingle = true">单个视频</div><div class="tab download-batch"v-bind:class="{active: !downloadSingle}"v-on:click="downloadSingle = false">批量导出</div></div><div v-show=downloadSingle class=info><img v-bind:src=coverUrl class=cover><div class=title><span class=size><div v-if=sizeWarning class=size-warning><i class="mdi mdi-alert-circle"></i><div class=size-warning-tip>警告: 过大的视频大小会在直接下载时占用大量内存, 并可能导致浏览器标签页崩溃. 请考虑降低清晰度或使用导出选项.</div></div>预计大小: {{displaySize}}</span></div></div><div class=options><div class=option-item v-if=enableDash>格式<v-dropdown style="text-transform: uppercase;"v-on:change=dashChange() v-bind:items=dashModel.items v-bind:value.sync=dashModel.value></v-dropdown></div><div class=option-item>清晰度<v-dropdown v-on:change=formatChange() v-bind:items=qualityModel.items v-bind:value.sync=qualityModel.value></v-dropdown></div><div class=option-item>弹幕<v-dropdown v-on:change=danmakuOptionChange() v-bind:items=danmakuModel.items v-bind:value.sync=danmakuModel.value></v-dropdown></div><div class=option-item v-if=enableDash>ffmpeg支持<v-dropdown v-on:change=ffmpegChange() v-bind:items=ffmpegModel.items v-bind:value.sync=ffmpegModel.value></v-dropdown></div></div><div class=separator></div><div v-show=downloadSingle class=direct-download><div class=direct-download-header><h2>直接下载</h2><span class=download-speed>{{speed}}</span></div><button v-if=!downloading class="primary start-download"v-on:click=startDownload()>开始</button><button v-else class="primary cancel-download"v-on:click=cancelDownload()>取消</button><div class=progress><div class=background><div class=foreground v-bind:style="{width: progressPercent + '%'}"></div></div><span class=percent>{{progressPercent}}%</span></div></div><div v-show=!downloadSingle class=batch-download><div class=episode-header><h2>选集</h2><button class=list-tool title=全选 v-on:click=selectAllEpisodes()><i class="mdi mdi-checkbox-multiple-marked-circle"></i></button><button class=list-tool title=全不选 v-on:click=unselectAllEpisodes()><i class="mdi mdi-checkbox-multiple-blank-circle-outline"></i></button><button class=list-tool title=反选 v-on:click=inverseAllEpisodes()><i class="mdi mdi-circle-slice-4"></i></button><span class=selected-count>{{selectedEpisodeCount}}/{{episodeList.length}}</span></div><div class=episode-list><v-checkbox v-for="ep of episodeList"v-bind:key=ep.index v-bind:title=ep.title v-bind:checked.sync=ep.checked></v-checkbox></div></div><div class=separator></div><div class=exports><h2>导出</h2><div class=actions v-bind:class="{busy: busy}"><button class=show-link v-bind:disabled="downloadSingle ? null : 'disabled'"v-on:click="exportData('showLink')">显示链接</button><button class=aria2-file v-on:click="exportData('aria2')">aria2</button><div class="button aria2-rpc"v-on:click.self=toggleRpcSettings()>{{showRpcSettings ? '取消' : 'aria2 RPC'}}<i :class="{'mdi-close': showRpcSettings, 'mdi-chevron-right': !showRpcSettings}"class=mdi v-on:click.self=toggleRpcSettings()></i><div class=rpc-settings v-bind:class="{show: showRpcSettings}"><h1>aria2 RPC</h1><rpc-profiles @profile-change=updateProfile></rpc-profiles><h2>配置</h2><div class=rpc-settings-item>主机<input type=text v-model=rpcSettings.host placeholder=127.0.0.1></div><div class=rpc-settings-item>端口<input type=text v-model=rpcSettings.port placeholder=6800></div><div class=rpc-settings-item>密钥<input type=text v-model=rpcSettings.secretKey></div><div class=rpc-settings-item>限速<input type=text v-model=rpcSettings.maxDownloadLimit placeholder=></div><div class=rpc-settings-item>默认路径<input type=text v-model=rpcSettings.baseDir></div><div class=rpc-settings-item>路径<input type=text v-model=rpcSettings.dir></div><div class=final-dir>最终路径: {{rpcSettings.baseDir + rpcSettings.dir}}</div><div class=rpc-settings-item>方法<v-dropdown style="text-transform: uppercase;"v-bind:items="['get', 'post']"v-bind:value.sync=rpcSettings.method></v-dropdown></div><div class=operations><div class="primary button"v-on:click="saveRpcSettings();toggleRpcSettings();exportData('aria2RPC')">开始下载</div><div class=button v-on:click=saveRpcSettings()>{{saveRpcSettingsText}}</div></div></div></div></div></div></div>

File diff suppressed because one or more lines are too long

View File

@ -1 +1 @@
(()=>{return(t,a)=>{const d=t=>{return{url:t.downloadUrl,backupUrls:t.backupUrls,length:t.duration,size:Math.trunc(t.bandWidth*t.duration/8)}};const r=async(t,a)=>{const d=await Ajax.getJsonWithCredentials(t);const r=d.data||d.result||d;if(d.code!==0||!r.dash){throw new Error("DASH api failed")}const e=r.accept_quality;if(!e.includes(a)){throw new Error("没有找到请求的清晰度")}if(r.quality!==a){throw new Error("无法获取请求的清晰度, 请确认当前账号有相应的权限")}const o=r.accept_description;const n=o[e.indexOf(a)];const c=r.dash.duration;const i=r.dash.video.filter(t=>t.id===a).map(t=>{const d={quality:a,qualityText:n,width:t.width,height:t.height,codecs:t.codecs,codecId:t.codecid,bandWidth:t.bandwidth,frameRate:t.frameRate,backupUrls:t.backupUrl,downloadUrl:t.baseUrl,duration:c};return d});const s=r.dash.audio.map(t=>{return{bandWidth:t.bandwidth,codecs:t.codecs,codecId:t.codecid,backupUrls:t.backupUrl,downloadUrl:t.baseUrl,duration:c}});return{videoDashes:i,audioDashes:s}};return{export:{getDashInfo:r,dashToFragment:d}}}})();
(()=>{return(t,a)=>{const r=t=>{return{url:t.downloadUrl,backupUrls:t.backupUrls,length:t.duration,size:Math.trunc(t.bandWidth*t.duration/8)}};const e=async(t,a)=>{const r=await Ajax.getJsonWithCredentials(t);const e=r.data||r.result||r;if(r.code!==0||!e.dash){throw new Error("DASH api failed")}const d=e.accept_quality;if(!d.includes(a)){throw new Error("没有找到请求的清晰度")}if(e.quality!==a){throw new Error("无法获取请求的清晰度, 请确认当前账号有相应的权限")}const c=e.accept_description;const o=c[d.indexOf(a)];const s=e.dash.duration;const n=e.dash.video.filter(t=>t.id===a).map(t=>{const r={quality:a,qualityText:o,width:t.width,height:t.height,codecs:t.codecs,codecId:t.codecid,bandWidth:t.bandwidth,frameRate:t.frameRate,backupUrls:(t.backupUrl||t.backup_url||"").replace("http:","https:"),downloadUrl:(t.baseUrl||t.base_url||"").replace("http:","https:"),duration:s};return r});const i=e.dash.audio.map(t=>{return{bandWidth:t.bandwidth,codecs:t.codecs,codecId:t.codecid,backupUrls:(t.backupUrl||t.backup_url||"").replace("http:","https:"),downloadUrl:(t.baseUrl||t.base_url||"").replace("http:","https:"),duration:s}});return{videoDashes:n,audioDashes:i}};return{export:{getDashInfo:e,dashToFragment:r}}}})();

View File

@ -286,13 +286,17 @@ Resource.manifest = {
useCache: '启用缓存'
}
},
videoDownloadPackage: {
path: 'download-video-package.min.js',
},
downloadVideo: {
path: 'download-video.min.js',
html: true,
style: 'instant',
dependencies: ['title', 'videoInfo'],
dependencies: ['title', 'videoInfo', 'videoDownloadPackage'],
displayNames: {
'downloadVideo': '下载视频',
'videoDownloadPackage': '下载视频打包器',
'batchDownload': '批量下载',
'aria2Rpc': 'aria2 RPC',
}

View File

@ -163,6 +163,7 @@ export const settings = {
customNavbarSeasonLogo: false,
selectableColumnText: true,
downloadVideoFormat: 'flv',
downloadVideoFfmpegSupport: '无',
enableDashDownload: false,
watchlaterExpireWarnings: true,
watchlaterExpireWarningDays: 14,

View File

@ -1,49 +1,85 @@
export interface DownloadVideoPackageConfig {
ffmpeg?: boolean
ffmpeg?: FfmpegOption
}
export class DownloadVideoPackage {
entries: {name: string; data: Blob | string}[] = []
static lastPackageUrl: string = ''
entries: { name: string; data: Blob | string }[] = []
constructor(private config: DownloadVideoPackageConfig = {}) {
}
private download(filename: string, blob: Blob) {
const a = document.createElement('a')
const url = URL.createObjectURL(blob)
if (DownloadVideoPackage.lastPackageUrl) {
URL.revokeObjectURL(DownloadVideoPackage.lastPackageUrl)
}
DownloadVideoPackage.lastPackageUrl = url
a.setAttribute('href', url)
a.setAttribute('download', filename)
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
}
add(name: string, data: string | Blob | null | undefined) {
if (data === null || data === undefined) {
return
}
this.entries.push({name, data})
this.entries.push({ name, data })
}
async emit(filename?: string) {
if (this.entries.length === 0) {
return
private async preEmit() {
const videoFiles = this.entries
.filter(it => ['.flv', '.mp4', '.m4s']
.some(ext => it.name.endsWith(ext)))
if (this.config.ffmpeg !== undefined && videoFiles.length >= 2) {
if (this.config.ffmpeg === '文件列表' || this.config.ffmpeg === '文件列表+脚本') {
this.entries.push({
name: 'ffmpeg-files.txt',
data: videoFiles.map(it => `file '${it.name}'`).join('\n'),
})
}
if (this.config.ffmpeg === '文件列表+脚本') {
const command = `ffmpeg -f concat -i ffmpeg-files.txt -c copy ""`
this.entries.push(
{
name: 'ffmpeg.bat',
data: command,
},
{
name: 'ffmpeg.sh',
data: command.replace(/"/g, "'"),
}
)
}
}
if (!filename || this.entries.length === 1) {
filename = this.entries[0].name
}
async blob(): Promise<Blob | null> {
await this.preEmit()
if (this.entries.length === 0) {
return null
}
if (this.entries.length === 1) {
const data = this.entries[0].data
this.download(filename, typeof data === 'string' ? new Blob([data]) : data)
return
return typeof data === 'string' ? new Blob([data]) : data
}
const zip = new JSZip()
this.entries.forEach(({name, data}) => {
this.entries.forEach(({ name, data }) => {
zip.file(name, data)
})
const blob = await zip.generateAsync({type: 'blob'})
this.download(filename, blob)
return await zip.generateAsync({ type: 'blob' })
}
static async single(filename: string, data: string | Blob) {
const pack = new DownloadVideoPackage()
async emit(filename?: string) {
if (!filename || this.entries.length === 1) {
filename = this.entries[0].name
}
const blob = await this.blob()
if (!blob) {
return
}
return this.download(filename, blob)
}
static async single(filename: string, data: string | Blob, config: DownloadVideoPackageConfig = {}) {
const pack = new DownloadVideoPackage(config)
pack.add(filename, data)
await pack.emit()
return await pack.emit()
}
}
export default {

View File

@ -41,6 +41,11 @@
弹幕
<v-dropdown v-on:change="danmakuOptionChange()" v-bind:items="danmakuModel.items" v-bind:value.sync="danmakuModel.value"></v-dropdown>
</div>
<div class="option-item" v-if="enableDash">
ffmpeg支持
<v-dropdown v-on:change="ffmpegChange()" v-bind:items="ffmpegModel.items"
v-bind:value.sync="ffmpegModel.value"></v-dropdown>
</div>
</div>
<div class="separator"></div>
<div v-show="downloadSingle" class="direct-download">

View File

@ -1,6 +1,7 @@
import { getFriendlyTitle, formatTitle } from '../title'
import { VideoInfo, DanmakuInfo } from '../video-info'
import { VideoDownloaderFragment } from './video-downloader-fragment';
import { VideoDownloaderFragment } from './video-downloader-fragment'
import { DownloadVideoPackage } from './download-video-package'
interface PageData {
entity: Video
@ -39,6 +40,24 @@ class Bangumi extends Video {
}
}
}
// 课程, 不知道为什么b站给它起名cheese
class Cheese extends Video {
constructor(public ep: number | string) { super() }
async getDashUrl(quality?: number) {
if (quality) {
return `https://api.bilibili.com/pugv/player/web/playurl?avid=${pageData.aid}&cid=${pageData.cid}&qn=${quality}&otype=json&ep_id=${this.ep}&fnver=0&fnval=16`
} else {
return `https://api.bilibili.com/pugv/player/web/playurl?avid=${pageData.aid}&cid=${pageData.cid}&otype=json&ep_id=${this.ep}&fnver=0&fnval=16`
}
}
async getUrl(quality?: number) {
if (quality) {
return `https://api.bilibili.com/pugv/player/web/playurl?avid=${pageData.aid}&cid=${pageData.cid}&qn=${quality}&otype=json&ep_id=${this.ep}`
} else {
return `https://api.bilibili.com/pugv/player/web/playurl?avid=${pageData.aid}&cid=${pageData.cid}&otype=json&ep_id=${this.ep}`
}
}
}
const pageData: PageData = {
entity: new Video(),
@ -108,7 +127,8 @@ class VideoDownloader {
workingXhr: XMLHttpRequest[] | null = null
progress: (progress: number) => void
progressMap: Map<XMLHttpRequest, number> = new Map()
danmakuOption: DanmakuOption
danmakuOption: DanmakuOption = '无'
ffmpegOption: FfmpegOption = '无'
videoSpeed: VideoSpeed
constructor(format: VideoFormat, fragments?: VideoDownloaderFragment[]) {
@ -167,17 +187,17 @@ class VideoDownloader {
}
}
downloadFragment(fragment: VideoDownloaderFragment) {
const promises: Promise<any>[] = []
const promises: Promise<ArrayBuffer>[] = []
this.workingXhr = []
this.progressMap = new Map()
this.updateProgress()
// const partialLength = Math.round(fragment.size / this.fragmentSplitFactor)
// 按一定大小分段或许对大视频更好
let partialLength: number
if (fragment.size <= 96 * 1024 * 1024) { // 小于等于96MB时, 均分为12段 (this.fragmentSplitFactor)
if (fragment.size <= 48 * 1024 * 1024) { // 小于等于48MB时, 均分为12段 (this.fragmentSplitFactor)
partialLength = fragment.size / this.fragmentSplitFactor
} else { // 大于96MB时, 每16MB为一段
partialLength = 16 * 1024 * 1024 // 16MB
} else { // 大于48MB时, 每4MB为一段
partialLength = 4 * 1024 * 1024 // 4MB
}
let startByte = 0
const getPartNumber = (xhr: XMLHttpRequest) => [...this.progressMap.keys()].indexOf(xhr) + 1
@ -185,7 +205,7 @@ class VideoDownloader {
const endByte = Math.min(fragment.size - 1, Math.round(startByte + partialLength))
const range = `bytes=${startByte}-${endByte}`
const rangeLength = endByte - startByte + 1
promises.push(new Promise<any>((resolve, reject) => {
promises.push(new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.open('GET', fragment.url)
xhr.responseType = 'arraybuffer'
@ -262,8 +282,7 @@ class VideoDownloader {
} else {
const blob = new Blob([data], { type: 'text/json' })
const danmaku = await this.downloadDanmaku()
const { DownloadVideoPackage } = await import('./download-video-package')
const pack = new DownloadVideoPackage()
const pack = new DownloadVideoPackage({ ffmpeg: this.ffmpegOption })
pack.add(`${getFriendlyTitle()}.json`, blob)
pack.add(getFriendlyTitle() + '.' + this.danmakuOption.toLowerCase(), danmaku)
await pack.emit(`${getFriendlyTitle()}.zip`)
@ -272,13 +291,12 @@ class VideoDownloader {
async exportAria2(rpc = false) {
if (rpc) { // https://aria2.github.io/manual/en/html/aria2c.html#json-rpc-using-http-get
const danmaku = await this.downloadDanmaku()
if (danmaku !== null) {
const { DownloadVideoPackage } = await import('./download-video-package')
DownloadVideoPackage.single(
`${getFriendlyTitle()}.${this.danmakuOption === 'ASS' ? 'ass' : 'xml'}`,
danmaku
)
}
const pack = new DownloadVideoPackage({ ffmpeg: this.ffmpegOption })
pack.add(
`${getFriendlyTitle()}.${this.danmakuOption === 'ASS' ? 'ass' : 'xml'}`,
danmaku
)
await pack.emit()
const option = settings.aria2RpcOption
const params = this.fragments.map((fragment, index) => {
let indexNumber = ''
@ -326,8 +344,7 @@ ${it.url}
`.trim()
const blob = new Blob([input], { type: 'text/plain' })
const danmaku = await this.downloadDanmaku()
const { DownloadVideoPackage } = await import('./download-video-package')
const pack = new DownloadVideoPackage()
const pack = new DownloadVideoPackage({ ffmpeg: this.ffmpegOption })
pack.add(`${getFriendlyTitle()}.txt`, blob)
pack.add(getFriendlyTitle() + '.' + this.danmakuOption.toLowerCase(), danmaku)
await pack.emit(`${getFriendlyTitle()}.zip`)
@ -347,20 +364,20 @@ ${it.url}
return '.flv'
}
}
makeBlob(data: any, fragment?: VideoDownloaderFragment) {
return new Blob(Array.isArray(data) ? data : [data], {
type: this.extension(fragment) === '.flv' ? 'video/x-flv' : 'video/mp4'
})
}
cleanUpOldBlobUrl() {
const oldBlobUrl = dq('a#video-complete')!.getAttribute('href')
if (oldBlobUrl && !dq(`.link[href="${oldBlobUrl}"]`)) {
URL.revokeObjectURL(oldBlobUrl)
}
dqa('.toast-card-header')
.filter((it: HTMLElement) => it.innerText.includes('下载视频'))
.forEach((it: HTMLElement) => (it.querySelector('.toast-card-dismiss') as HTMLElement).click())
}
// makeBlob(data: any, fragment?: VideoDownloaderFragment) {
// return new Blob(Array.isArray(data) ? data : [data], {
// type: this.extension(fragment) === '.flv' ? 'video/x-flv' : 'video/mp4'
// })
// }
// cleanUpOldBlobUrl() {
// const oldBlobUrl = dq('a#video-complete')!.getAttribute('href')
// if (oldBlobUrl && !dq(`.link[href="${oldBlobUrl}"]`)) {
// URL.revokeObjectURL(oldBlobUrl)
// }
// dqa('.toast-card-header')
// .filter((it: HTMLElement) => it.innerText.includes('下载视频'))
// .forEach((it: HTMLElement) => (it.querySelector('.toast-card-dismiss') as HTMLElement).click())
// }
async downloadDanmaku() {
if (this.danmakuOption !== '无') {
const danmakuInfo = new DanmakuInfo(pageData.cid)
@ -375,44 +392,51 @@ ${it.url}
return null
}
}
async downloadSingle(downloadedData: any[]) {
const danmaku = await this.downloadDanmaku()
const [data] = downloadedData
if (danmaku === null) {
const blob = this.makeBlob(data)
const filename = getFriendlyTitle() + this.extension()
return { blob, filename }
} else {
const zip = new JSZip()
zip.file(getFriendlyTitle() + this.extension(), this.makeBlob(data))
zip.file(getFriendlyTitle() + '.' + this.danmakuOption.toLowerCase(), danmaku)
const blob = await zip.generateAsync({ type: 'blob' })
const filename = getFriendlyTitle() + '.zip'
return { blob, filename }
}
}
async downloadMultiple(downloadedData: any[]) {
const zip = new JSZip()
const title = getFriendlyTitle()
if (downloadedData.length > 1) {
downloadedData.forEach((data, index) => {
const fragment = this.fragments[index]
zip.file(`${title} - ${index + 1}${this.extension(fragment)}`, this.makeBlob(data, fragment))
})
} else {
const [data] = downloadedData
zip.file(`${title}${this.extension()}`, this.makeBlob(data))
}
const danmaku = await this.downloadDanmaku()
if (danmaku !== null) {
zip.file(getFriendlyTitle() + '.' + this.danmakuOption.toLowerCase(), danmaku)
}
const blob = await zip.generateAsync({ type: 'blob' })
const filename = title + '.zip'
return { blob, filename }
}
// async downloadSingle(downloadedData: any[]) {
// const danmaku = await this.downloadDanmaku()
// const [data] = downloadedData
// const pack = new DownloadVideoPackage({ ffmpeg: this.ffmpegOption })
// pack.add(getFriendlyTitle() + this.extension(), this.makeBlob(data))
// pack.add(getFriendlyTitle() + '.' + this.danmakuOption.toLowerCase(), danmaku)
// return {
// blob: await pack.blob(),
// filename: getFriendlyTitle() + '.zip'
// }
// // if (danmaku === null) {
// // const blob = this.makeBlob(data)
// // const filename = getFriendlyTitle() + this.extension()
// // return { blob, filename }
// // } else {
// // const zip = new JSZip()
// // zip.file(getFriendlyTitle() + this.extension(), this.makeBlob(data))
// // zip.file(getFriendlyTitle() + '.' + this.danmakuOption.toLowerCase(), danmaku)
// // const blob = await zip.generateAsync({ type: 'blob' })
// // const filename = getFriendlyTitle() + '.zip'
// // return { blob, filename }
// // }
// }
// async downloadMultiple(downloadedData: any[]) {
// const zip = new JSZip()
// const title = getFriendlyTitle()
// if (downloadedData.length > 1) {
// downloadedData.forEach((data, index) => {
// const fragment = this.fragments[index]
// zip.file(`${title} - ${index + 1}${this.extension(fragment)}`, this.makeBlob(data, fragment))
// })
// } else {
// const [data] = downloadedData
// zip.file(`${title}${this.extension()}`, this.makeBlob(data))
// }
// const danmaku = await this.downloadDanmaku()
// if (danmaku !== null) {
// zip.file(getFriendlyTitle() + '.' + this.danmakuOption.toLowerCase(), danmaku)
// }
// const blob = await zip.generateAsync({ type: 'blob' })
// const filename = title + '.zip'
// return { blob, filename }
// }
async download() {
const downloadedData = []
const downloadedData: ArrayBuffer[][] = []
this.videoSpeed.startMeasure()
for (const fragment of this.fragments) {
const data = await this.downloadFragment(fragment)
@ -422,21 +446,41 @@ ${it.url}
throw new Error('下载失败.')
}
let { blob, filename } = await (async () => {
if (downloadedData.length === 1) {
return await this.downloadSingle(downloadedData)
// let { blob, filename } = await (async () => {
// if (downloadedData.length === 1) {
// return await this.downloadSingle(downloadedData)
// } else {
// return await this.downloadMultiple(downloadedData)
// }
// })()
// this.cleanUpOldBlobUrl()
// const blobUrl = URL.createObjectURL(blob)
const pack = new DownloadVideoPackage({ffmpeg: this.ffmpegOption})
const title = getFriendlyTitle()
downloadedData.forEach((data, index) => {
let filename: string
const fragment = this.fragments[index]
if (downloadedData.length > 1) {
filename = `${title} - ${index + 1}${this.extension(fragment)}`
} else {
return await this.downloadMultiple(downloadedData)
filename = `${title}${this.extension(fragment)}`
}
})()
this.cleanUpOldBlobUrl()
const blobUrl = URL.createObjectURL(blob)
pack.add(filename, new Blob(Array.isArray(data) ? data : [data], {
type: this.extension(fragment) === '.flv' ? 'video/x-flv' : 'video/mp4'
}))
})
const danmaku = await this.downloadDanmaku()
pack.add(
`${getFriendlyTitle()}.${this.danmakuOption === 'ASS' ? 'ass' : 'xml'}`,
danmaku
)
await pack.emit(title + '.zip')
this.progress && this.progress(0)
this.videoSpeed.stopMeasure()
return {
url: blobUrl,
filename: filename
}
// return {
// url: blobUrl,
// filename: filename
// }
}
}
class VideoSpeed {
@ -471,8 +515,11 @@ async function loadPageData() {
}
pageData.aid = aid
pageData.cid = cid
if (document.URL.indexOf('bangumi') !== -1) {
if (document.URL.includes('bangumi')) {
pageData.entity = new Bangumi()
} else if (document.URL.includes('cheese')) {
const match = document.URL.match(/cheese\/play\/ep([\d]+)/)!
pageData.entity = new Cheese(match[1])
} else {
pageData.entity = new Video()
}
@ -530,6 +577,10 @@ async function loadPanel() {
value: settings.downloadVideoDefaultDanmaku as DanmakuOption,
items: ['无', 'XML', 'ASS'] as DanmakuOption[]
},
ffmpegModel: {
value: settings.downloadVideoFfmpegSupport,
items: ['无', '文件列表', '文件列表+脚本']
},
progressPercent: 0,
size: '获取大小中' as number | string,
blobUrl: '',
@ -542,6 +593,7 @@ async function loadPanel() {
busy: false,
saveRpcSettingsText: '保存配置',
enableDash: settings.enableDashDownload,
lastDirectDownloadLink: '',
},
computed: {
displaySize() {
@ -665,7 +717,6 @@ async function loadPanel() {
return match.checked
}
const format = this.getFormat()
const { DownloadVideoPackage } = await import('./download-video-package')
if (this.danmakuModel.value !== '无') {
const danmakuToast = Toast.info('下载弹幕中...', '批量导出')
const pack = new DownloadVideoPackage()
@ -700,7 +751,8 @@ async function loadPanel() {
result = await this.batchExtractor.collectAria2(format, toast)
await DownloadVideoPackage.single(
getFriendlyTitle(false) + '.txt',
new Blob([result], { type: 'text/plain' })
new Blob([result], { type: 'text/plain' }),
{ ffmpeg: this.ffmpegOption }
)
return
case 'aria2RPC':
@ -715,7 +767,8 @@ async function loadPanel() {
result = await this.batchExtractor.collectData(format, toast)
await DownloadVideoPackage.single(
getFriendlyTitle(false) + '.json',
new Blob([result], { type: 'text/json' })
new Blob([result], { type: 'text/json' }),
{ ffmpeg: this.ffmpegOption }
)
return
default:
@ -767,16 +820,13 @@ async function loadPanel() {
const videoDownloader = await format.downloadInfo(this.dash)
videoDownloader.videoSpeed.speedUpdate = speed => this.speed = speed
videoDownloader.danmakuOption = this.danmakuModel.value
videoDownloader.ffmpegOption = this.ffmpegModel.value
videoDownloader.progress = percent => {
this.progressPercent = Math.trunc(percent * 100)
}
workingDownloader = videoDownloader
const result = await videoDownloader.download()
const completeLink = document.getElementById('video-complete') as HTMLAnchorElement
completeLink.setAttribute('href', result.url)
completeLink.setAttribute('download', result.filename)
completeLink.click()
Toast.success(/*html*/`下载完成: ${result.filename} <a class="link" href="${result.url}" download="${result.filename.replace(/"/g, '&quot;')}">再次保存</a>`, '下载视频')
await videoDownloader.download()
this.lastDirectDownloadLink = DownloadVideoPackage.lastPackageUrl
}
catch (error) {
if (error !== 'canceled') {
@ -836,9 +886,13 @@ async function loadPanel() {
panel.aid = pageData.aid
panel.cid = pageData.cid
const videoInfo = new VideoInfo(pageData.aid)
await videoInfo.fetchInfo()
panel.coverUrl = videoInfo.coverUrl.replace('http:', 'https:')
try {
const videoInfo = new VideoInfo(pageData.aid)
await videoInfo.fetchInfo()
panel.coverUrl = videoInfo.coverUrl.replace('http:', 'https:')
} catch (error) {
panel.coverUrl = EmptyImageUrl
}
formats = await VideoFormat.getAvailableFormats();
[selectedFormat] = formats

View File

@ -51,8 +51,8 @@ export const getDashInfo = async (api: string, quality: number) => {
codecId: d.codecid,
bandWidth: d.bandwidth,
frameRate: d.frameRate,
backupUrls: d.backupUrl,
downloadUrl: d.baseUrl,
backupUrls: (d.backupUrl || d.backup_url || '').replace('http:', 'https:'),
downloadUrl: (d.baseUrl || d.base_url || '').replace('http:', 'https:'),
duration,
}
return dash
@ -62,8 +62,8 @@ export const getDashInfo = async (api: string, quality: number) => {
bandWidth: d.bandwidth,
codecs: d.codecs,
codecId: d.codecid,
backupUrls: d.backupUrl,
downloadUrl: d.baseUrl,
backupUrls: (d.backupUrl || d.backup_url || '').replace('http:', 'https:'),
downloadUrl: (d.baseUrl || d.base_url || '').replace('http:', 'https:'),
duration,
}
})