mirror of
https://github.com/the1812/Bilibili-Evolved.git
synced 2025-11-04 21:22:45 +08:00
445 lines
12 KiB
Vue
445 lines
12 KiB
Vue
<template>
|
|
<VPopup
|
|
v-model="open"
|
|
class="download-video-panel"
|
|
:trigger-element="triggerElement"
|
|
>
|
|
<div class="download-video-panel-header">
|
|
<VIcon icon="mdi-download" />
|
|
<div class="title">
|
|
下载视频
|
|
</div>
|
|
<VButton type="transparent" title="关闭" @click="open = false">
|
|
<VIcon icon="mdi-close" :size="20" />
|
|
</VButton>
|
|
</div>
|
|
<div
|
|
v-if="selectedInput"
|
|
class="download-video-config-item"
|
|
>
|
|
<div class="download-video-config-title">
|
|
输入源:
|
|
</div>
|
|
<VDropdown
|
|
v-model="selectedInput"
|
|
:items="inputs"
|
|
/>
|
|
</div>
|
|
<div
|
|
v-if="inputs.length === 0"
|
|
class="download-video-config-item error"
|
|
>
|
|
没有匹配的输入源, 请确保安装了适合此页面的插件.
|
|
</div>
|
|
<component
|
|
:is="selectedInput.component"
|
|
v-if="selectedInput && selectedInput.component"
|
|
ref="inputOptions"
|
|
/>
|
|
<div
|
|
v-if="selectedApi"
|
|
class="download-video-config-item"
|
|
>
|
|
<div class="download-video-config-title">
|
|
格式:
|
|
</div>
|
|
<VDropdown
|
|
v-model="selectedApi"
|
|
:items="apis"
|
|
/>
|
|
</div>
|
|
<div
|
|
v-if="selectedApi && selectedApi.description"
|
|
class="download-video-config-description"
|
|
v-html="selectedApi.description"
|
|
>
|
|
</div>
|
|
<div
|
|
v-if="selectedQuality"
|
|
class="download-video-config-item"
|
|
>
|
|
<div class="download-video-config-title">
|
|
清晰度:
|
|
</div>
|
|
<VDropdown
|
|
v-model="selectedQuality"
|
|
:items="filteredQualities"
|
|
@change="saveSelectedQuality()"
|
|
/>
|
|
</div>
|
|
<template v-if="!testData.multiple && selectedQuality">
|
|
<div
|
|
v-if="testData.videoInfo"
|
|
class="download-video-config-description"
|
|
>
|
|
预计大小: {{ formatFileSize(testData.videoInfo.totalSize) }}
|
|
</div>
|
|
<div
|
|
v-if="testData.videoInfo === null"
|
|
class="download-video-config-description"
|
|
>
|
|
正在计算大小
|
|
</div>
|
|
</template>
|
|
<component
|
|
:is="a.component"
|
|
v-for="a of assetsWithOptions"
|
|
:key="a.name"
|
|
ref="assetsOptions"
|
|
:name="a.name"
|
|
/>
|
|
<div
|
|
v-if="selectedOutput"
|
|
class="download-video-config-item"
|
|
>
|
|
<div class="download-video-config-title">
|
|
输出方式:
|
|
</div>
|
|
<VDropdown
|
|
v-model="selectedOutput"
|
|
:items="outputs"
|
|
/>
|
|
</div>
|
|
<div
|
|
v-if="selectedOutput && selectedOutput.description"
|
|
class="download-video-config-description"
|
|
>
|
|
{{ selectedOutput.description }}
|
|
</div>
|
|
<component
|
|
:is="selectedOutput.component"
|
|
v-if="selectedOutput && selectedOutput.component"
|
|
ref="outputOptions"
|
|
/>
|
|
|
|
<div class="download-video-panel-footer">
|
|
<VButton
|
|
class="run-download"
|
|
type="primary"
|
|
:disabled="!canStartDownload"
|
|
@click="startDownload($refs.outputOptions, selectedOutput)"
|
|
>
|
|
开始
|
|
</VButton>
|
|
</div>
|
|
</VPopup>
|
|
</template>
|
|
<script lang="ts">
|
|
import { TestPattern } from '@/core/common-types'
|
|
import { getComponentSettings } from '@/core/settings'
|
|
import { matchUrlPattern } from '@/core/utils'
|
|
import { logError } from '@/core/utils/log'
|
|
import { formatFileSize } from '@/core/utils/formatters'
|
|
import {
|
|
VPopup, VButton, VDropdown, VIcon,
|
|
} from '@/ui'
|
|
import { registerAndGetData } from '@/plugins/data'
|
|
import { allQualities, VideoQuality } from '@/components/video/video-quality'
|
|
import { Toast } from '@/core/toast'
|
|
import { getFriendlyTitle } from '@/core/utils/title'
|
|
import { bangumiBatchInput } from './inputs/bangumi/batch'
|
|
import { videoBatchInput } from './inputs/video/batch'
|
|
import { videoSingleInput } from './inputs/video/input'
|
|
import {
|
|
videoDashAVC,
|
|
videoDashHEVC,
|
|
videoAudioDash,
|
|
} from './apis/dash'
|
|
import { videoFlv } from './apis/flv'
|
|
import { toastOutput } from './outputs/toast'
|
|
import {
|
|
DownloadVideoAction,
|
|
DownloadVideoApi,
|
|
DownloadVideoAssets,
|
|
DownloadVideoInput,
|
|
DownloadVideoInputItem,
|
|
DownloadVideoOutput,
|
|
} from './types'
|
|
|
|
const [inputs] = registerAndGetData(
|
|
'downloadVideo.inputs', [
|
|
videoSingleInput,
|
|
videoBatchInput,
|
|
bangumiBatchInput,
|
|
] as DownloadVideoInput[],
|
|
)
|
|
const [apis] = registerAndGetData(
|
|
'downloadVideo.apis', [
|
|
videoFlv,
|
|
videoDashAVC,
|
|
videoDashHEVC,
|
|
videoAudioDash,
|
|
] as DownloadVideoApi[],
|
|
)
|
|
const [assets] = registerAndGetData(
|
|
'downloadVideo.assets', [] as DownloadVideoAssets[],
|
|
)
|
|
const [outputs] = registerAndGetData(
|
|
'downloadVideo.outputs', [
|
|
toastOutput,
|
|
] as DownloadVideoOutput[],
|
|
)
|
|
const { basicConfig } = getComponentSettings('downloadVideo').options as {
|
|
basicConfig: {
|
|
api: string
|
|
quality: number
|
|
output: string
|
|
}
|
|
}
|
|
const filterData = <T extends { match?: TestPattern }> (items: T[]) => {
|
|
const matchedItems = items.filter(it => it.match?.some(p => matchUrlPattern(p)) ?? true)
|
|
return matchedItems
|
|
}
|
|
const getFallbackTestVideoInfo = () => ({
|
|
aid: unsafeWindow.aid,
|
|
cid: unsafeWindow.cid,
|
|
title: getFriendlyTitle(true),
|
|
} as DownloadVideoInputItem)
|
|
|
|
export default Vue.extend({
|
|
components: {
|
|
VPopup, VButton, VDropdown, VIcon,
|
|
},
|
|
props: {
|
|
triggerElement: {
|
|
required: true,
|
|
},
|
|
},
|
|
data() {
|
|
const lastOutput = basicConfig.output
|
|
return {
|
|
open: false,
|
|
busy: false,
|
|
// 测试 videoInfo, 用于获取清晰度列表和预计大小
|
|
testData: {
|
|
// undefined: 无法获取; null: 获取中;
|
|
videoInfo: null,
|
|
// 是否有多P
|
|
multiple: false,
|
|
},
|
|
assets,
|
|
qualities: [],
|
|
selectedQuality: undefined,
|
|
inputs: [],
|
|
selectedInput: undefined,
|
|
apis: [],
|
|
selectedApi: undefined,
|
|
outputs,
|
|
selectedOutput: outputs.find(it => it.name === lastOutput) || outputs[0],
|
|
}
|
|
},
|
|
computed: {
|
|
assetsWithOptions() {
|
|
return (this.assets as DownloadVideoAssets[]).filter(a => a.component)
|
|
},
|
|
filteredQualities() {
|
|
if (this.qualities.length === 0) {
|
|
return allQualities
|
|
}
|
|
return this.qualities
|
|
},
|
|
canStartDownload() {
|
|
if (this.busy || !this.open) {
|
|
return false
|
|
}
|
|
const isAnySelectionEmpty = Object.entries(this).filter(([key]) => key.startsWith('selected')).some(([, value]) => !value)
|
|
if (isAnySelectionEmpty) {
|
|
return false
|
|
}
|
|
return true
|
|
},
|
|
},
|
|
watch: {
|
|
selectedInput(input: DownloadVideoInput) {
|
|
if (input === undefined) {
|
|
return
|
|
}
|
|
this.updateTestVideoInfo()
|
|
},
|
|
selectedApi(api: DownloadVideoApi) {
|
|
if (api === undefined) {
|
|
return
|
|
}
|
|
this.updateTestVideoInfo()
|
|
basicConfig.api = api.name
|
|
},
|
|
selectedOutput(output: DownloadVideoOutput) {
|
|
if (output === undefined) {
|
|
return
|
|
}
|
|
basicConfig.output = output.name
|
|
},
|
|
},
|
|
mounted() {
|
|
coreApis.observer.videoChange(() => {
|
|
this.selectedInput = undefined
|
|
this.selectedApi = undefined
|
|
|
|
const matchedInputs = filterData(inputs)
|
|
this.inputs = matchedInputs
|
|
this.selectedInput = matchedInputs[0]
|
|
const matchedApis = filterData(apis)
|
|
this.apis = matchedApis
|
|
const lastApi = matchedApis.find(api => api.name === basicConfig.api)
|
|
if (lastApi) {
|
|
this.selectedApi = lastApi
|
|
} else {
|
|
this.selectedApi = matchedApis[0]
|
|
}
|
|
})
|
|
},
|
|
methods: {
|
|
formatFileSize,
|
|
// 只有手动选择的清晰度要记录, 因此不能直接 watch
|
|
saveSelectedQuality() {
|
|
const quality: VideoQuality = this.selectedQuality
|
|
if (quality === undefined) {
|
|
return
|
|
}
|
|
basicConfig.quality = quality.value
|
|
this.updateTestVideoInfo()
|
|
},
|
|
async getVideoItems() {
|
|
const input = this.selectedInput as DownloadVideoInput
|
|
const videoItems = await input.getInputs(this.$refs.inputOptions)
|
|
return videoItems
|
|
},
|
|
async updateTestVideoInfo() {
|
|
if (!this.selectedInput || !this.selectedApi) {
|
|
return
|
|
}
|
|
this.testData.videoInfo = null
|
|
const input = this.selectedInput as DownloadVideoInput
|
|
const testItem = input.getTestInput?.() ?? getFallbackTestVideoInfo()
|
|
console.log('[updateTestVideoInfo]', testItem)
|
|
this.testData.multiple = input.batch
|
|
const api = this.selectedApi as DownloadVideoApi
|
|
try {
|
|
const videoInfo = await api.downloadVideoInfo(testItem)
|
|
this.qualities = videoInfo.qualities
|
|
const isSelectedQualityOutdated = (
|
|
!this.selectedQuality
|
|
|| !(videoInfo.qualities.some(q => q.value === this.selectedQuality.value))
|
|
)
|
|
if (isSelectedQualityOutdated) {
|
|
this.selectedQuality = videoInfo.qualities[0]
|
|
if (basicConfig.quality) {
|
|
const [matchedQuality] = videoInfo.qualities.filter(q => q.value <= basicConfig.quality)
|
|
if (matchedQuality) {
|
|
this.selectedQuality = matchedQuality
|
|
}
|
|
}
|
|
}
|
|
// 填充 quality 后要再请求一次得到对应 quality 的统计数据
|
|
testItem.quality = this.selectedQuality
|
|
const qualityVideoInfo = await api.downloadVideoInfo(testItem)
|
|
this.testData.videoInfo = qualityVideoInfo
|
|
} catch (error) {
|
|
this.testData.videoInfo = undefined
|
|
}
|
|
},
|
|
async startDownload(instance: Vue, output: DownloadVideoOutput) {
|
|
try {
|
|
this.busy = true
|
|
const input = this.selectedInput as DownloadVideoInput
|
|
const api = this.selectedApi as DownloadVideoApi
|
|
const videoInputs = await input.getInputs(this.$refs.inputOptions)
|
|
if (videoInputs.length === 0) {
|
|
Toast.info('未接收到视频, 如果输入源支持批量, 请至少选择一个视频.', '下载视频', 3000)
|
|
return
|
|
}
|
|
videoInputs.forEach(item => {
|
|
item.quality = this.selectedQuality
|
|
})
|
|
const videoInfos = await Promise.all(videoInputs.map(i => api.downloadVideoInfo(i)))
|
|
if (videoInfos.length === 0 || lodash.sumBy(videoInfos, it => it.fragments.length) === 0) {
|
|
Toast.info('未接收到可下载数据, 请检查输入源和格式是否适用于当前视频.', '下载视频', 3000)
|
|
return
|
|
}
|
|
const action = new DownloadVideoAction(videoInfos)
|
|
const extraAssets = (await Promise.all(
|
|
assets.map(a => a.getAssets(
|
|
videoInfos,
|
|
this.$refs.assetsOptions.find((c: any) => c.$attrs.name === a.name),
|
|
)),
|
|
)).flat()
|
|
action.extraAssets.push(...extraAssets)
|
|
await action.downloadExtraAssets()
|
|
await output.runAction(action, instance)
|
|
} catch (error) {
|
|
logError(error)
|
|
} finally {
|
|
this.busy = false
|
|
}
|
|
},
|
|
},
|
|
})
|
|
</script>
|
|
<style lang="scss">
|
|
@import "common";
|
|
.download-video-panel {
|
|
font-size: 12px;
|
|
top: 100px;
|
|
left: 50%;
|
|
transform: translateX(-50%) scale(0.95);
|
|
transition: .2s ease-out;
|
|
z-index: 1000;
|
|
width: 320px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: flex-start;
|
|
padding: 12px;
|
|
|
|
@include card();
|
|
&.open {
|
|
transform: translateX(-50%);
|
|
}
|
|
> :not(:first-child) {
|
|
margin-top: 12px;
|
|
}
|
|
.be-textbox,
|
|
.be-textarea {
|
|
flex-grow: 1;
|
|
}
|
|
&-header {
|
|
@include h-center();
|
|
align-self: stretch;
|
|
|
|
.title {
|
|
font-size: 16px;
|
|
font-weight: bold;
|
|
flex-grow: 1;
|
|
margin: 0 8px;
|
|
}
|
|
.be-button {
|
|
padding: 4px;
|
|
}
|
|
}
|
|
.download-video-config-item {
|
|
@include h-center();
|
|
.download-video-config-title {
|
|
margin-right: 8px;
|
|
}
|
|
&.error {
|
|
color: #E57373;
|
|
}
|
|
}
|
|
.download-video-config-section {
|
|
align-self: stretch;
|
|
}
|
|
.download-video-config-description {
|
|
opacity: .5;
|
|
margin-top: 4px;
|
|
}
|
|
&-footer {
|
|
align-self: stretch;
|
|
justify-content: center;
|
|
@include h-center();
|
|
}
|
|
.run-download {
|
|
font-size: 13px;
|
|
padding: 6px 12px;
|
|
}
|
|
}
|
|
</style>
|