mirror of
https://github.com/the1812/Bilibili-Evolved.git
synced 2025-11-04 21:22:45 +08:00
Merge branch 'preview-features' of https://github.com/the1812/Bilibili-Evolved into preview-features
This commit is contained in:
commit
a1df99ac12
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
@ -43,6 +43,7 @@
|
|||||||
"durl",
|
"durl",
|
||||||
"epid",
|
"epid",
|
||||||
"esbuild",
|
"esbuild",
|
||||||
|
"ffmetadata",
|
||||||
"flac",
|
"flac",
|
||||||
"Fullscreen",
|
"Fullscreen",
|
||||||
"githubusercontent",
|
"githubusercontent",
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { defineComponentMetadata } from '@/components/define'
|
import { defineComponentMetadata } from '@/components/define'
|
||||||
import { getBlobByAid } from '@/components/video/video-cover'
|
import { getVideoCoverUrlByAid, getBlobByAid } from '@/components/video/video-cover'
|
||||||
import { PackageEntry } from '@/core/download'
|
import { PackageEntry } from '@/core/download'
|
||||||
import { videoAndBangumiUrls } from '@/core/utils/urls'
|
import { videoAndBangumiUrls } from '@/core/utils/urls'
|
||||||
import { Toast } from '@/core/toast'
|
import { Toast } from '@/core/toast'
|
||||||
@ -55,6 +55,26 @@ export const component = defineComponentMetadata({
|
|||||||
toast.message = `获取完成. 成功 ${success.length} 个, 失败 ${fail.length} 个.`
|
toast.message = `获取完成. 成功 ${success.length} 个, 失败 ${fail.length} 个.`
|
||||||
return success.map(it => it.value)
|
return success.map(it => it.value)
|
||||||
},
|
},
|
||||||
|
getUrls: async (
|
||||||
|
infos,
|
||||||
|
instance: {
|
||||||
|
type: CoverDownloadType
|
||||||
|
enabled: boolean
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
const { type, enabled } = instance
|
||||||
|
if (!enabled) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return Promise.all(
|
||||||
|
infos.map(async info => {
|
||||||
|
return {
|
||||||
|
name: `${info.input.title}.${type}`,
|
||||||
|
url: await getVideoCoverUrlByAid(info.input.aid),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
component: () => import('./Plugin.vue').then(m => m.default),
|
component: () => import('./Plugin.vue').then(m => m.default),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -330,19 +330,15 @@ export default Vue.extend({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
const action = new DownloadVideoAction(videoInfos)
|
const action = new DownloadVideoAction(videoInfos)
|
||||||
const extraAssets = (
|
assets.forEach(a => {
|
||||||
await Promise.all(
|
const assetsType = a?.getUrls ? action.extraOnlineAssets : action.extraAssets
|
||||||
assets.map(a =>
|
assetsType.push({
|
||||||
a.getAssets(
|
asset: a,
|
||||||
videoInfos,
|
instance: this.$refs.assetsOptions.find((c: any) => c.$attrs.name === a.name),
|
||||||
this.$refs.assetsOptions.find((c: any) => c.$attrs.name === a.name),
|
})
|
||||||
),
|
})
|
||||||
),
|
|
||||||
)
|
|
||||||
).flat()
|
|
||||||
action.extraAssets.push(...extraAssets)
|
|
||||||
await action.downloadExtraAssets()
|
|
||||||
await output.runAction(action, instance)
|
await output.runAction(action, instance)
|
||||||
|
await action.downloadExtraAssets()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError(error)
|
logError(error)
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@ -77,11 +77,19 @@ export interface DownloadVideoApi extends WithName {
|
|||||||
/** 表示下载时额外附带的产物, 如弹幕 / 字幕等 */
|
/** 表示下载时额外附带的产物, 如弹幕 / 字幕等 */
|
||||||
export interface DownloadVideoAssets<AssetsParameter = any> extends VueInstanceInput, WithName {
|
export interface DownloadVideoAssets<AssetsParameter = any> extends VueInstanceInput, WithName {
|
||||||
getAssets: (infos: DownloadVideoInfo[], instance: AssetsParameter) => Promise<PackageEntry[]>
|
getAssets: (infos: DownloadVideoInfo[], instance: AssetsParameter) => Promise<PackageEntry[]>
|
||||||
|
/** 获取可直接下载的链接 */
|
||||||
|
getUrls?: (
|
||||||
|
infos: DownloadVideoInfo[],
|
||||||
|
instance: AssetsParameter,
|
||||||
|
) => Promise<{ name: string; url: string }[]>
|
||||||
}
|
}
|
||||||
/** 表示视频的下载信息以及携带的额外产物 */
|
/** 表示视频的下载信息以及携带的额外产物 */
|
||||||
export class DownloadVideoAction {
|
export class DownloadVideoAction<AssetsParameter = any> {
|
||||||
readonly inputs: DownloadVideoInputItem[] = []
|
readonly inputs: DownloadVideoInputItem[] = []
|
||||||
extraAssets: PackageEntry[] = []
|
/** 可调用处理的asset和对应的参数 */
|
||||||
|
extraAssets: { asset: DownloadVideoAssets; instance: AssetsParameter }[] = []
|
||||||
|
/** 可直接下载的asset和对应的参数 */
|
||||||
|
extraOnlineAssets: { asset: DownloadVideoAssets; instance: AssetsParameter }[] = []
|
||||||
|
|
||||||
constructor(public infos: DownloadVideoInfo[]) {
|
constructor(public infos: DownloadVideoInfo[]) {
|
||||||
this.inputs = infos.map(it => it.input)
|
this.inputs = infos.map(it => it.input)
|
||||||
@ -92,7 +100,15 @@ export class DownloadVideoAction {
|
|||||||
async downloadExtraAssets() {
|
async downloadExtraAssets() {
|
||||||
console.log('[downloadExtraAssets]', this.extraAssets)
|
console.log('[downloadExtraAssets]', this.extraAssets)
|
||||||
const filename = `${getFriendlyTitle(false)}.zip`
|
const filename = `${getFriendlyTitle(false)}.zip`
|
||||||
await new DownloadPackage(this.extraAssets).emit(filename)
|
const { infos } = this
|
||||||
|
const extraAssetsBlob = (
|
||||||
|
await Promise.all(
|
||||||
|
[...this.extraAssets, ...this.extraOnlineAssets].map(({ asset, instance }) =>
|
||||||
|
asset.getAssets(infos, instance),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
).flat()
|
||||||
|
await new DownloadPackage(extraAssetsBlob).emit(filename)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/** 下载视频的最终输出处理 */
|
/** 下载视频的最终输出处理 */
|
||||||
|
|||||||
44
registry/lib/components/video/metadata/Plugin.vue
Normal file
44
registry/lib/components/video/metadata/Plugin.vue
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
<template>
|
||||||
|
<div class="download-video-config-section">
|
||||||
|
<div class="download-video-config-item">
|
||||||
|
<div>元数据:</div>
|
||||||
|
<VDropdown v-model="type" :items="items">
|
||||||
|
<template #item="{ item }">
|
||||||
|
{{ item }}
|
||||||
|
</template>
|
||||||
|
</VDropdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script lang="ts">
|
||||||
|
import { VDropdown } from '@/ui'
|
||||||
|
import { getComponentSettings } from '@/core/settings'
|
||||||
|
import { MetadataType } from './metadata'
|
||||||
|
|
||||||
|
interface Options {
|
||||||
|
metadataType: MetadataType | '无'
|
||||||
|
}
|
||||||
|
const options = getComponentSettings('downloadVideo').options as Options
|
||||||
|
|
||||||
|
export default Vue.extend({
|
||||||
|
components: {
|
||||||
|
VDropdown,
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
type: options.metadataType ?? '无',
|
||||||
|
items: ['无', 'ffmetadata', 'ogm'],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
enabled() {
|
||||||
|
return this.type !== '无'
|
||||||
|
},
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
type(value: MetadataType) {
|
||||||
|
options.metadataType = value
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
</script>
|
||||||
48
registry/lib/components/video/metadata/SaveMetadata.vue
Normal file
48
registry/lib/components/video/metadata/SaveMetadata.vue
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
<template>
|
||||||
|
<div class="multiple-widgets">
|
||||||
|
<DefaultWidget
|
||||||
|
ref="button"
|
||||||
|
:disabled="disabled"
|
||||||
|
name="保存视频元数据"
|
||||||
|
icon="mdi-download"
|
||||||
|
@click="run('ffmetadata')"
|
||||||
|
></DefaultWidget>
|
||||||
|
<DefaultWidget
|
||||||
|
:disabled="disabled"
|
||||||
|
name="保存视频章节"
|
||||||
|
icon="mdi-download"
|
||||||
|
@click="run('ogm')"
|
||||||
|
></DefaultWidget>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { DefaultWidget } from '@/ui'
|
||||||
|
import { logError } from '@/core/utils/log'
|
||||||
|
import { DownloadPackage } from '@/core/download'
|
||||||
|
import { getFriendlyTitle } from '@/core/utils/title'
|
||||||
|
import { MetadataType, generateByType } from './metadata'
|
||||||
|
|
||||||
|
export default Vue.extend({
|
||||||
|
components: {
|
||||||
|
DefaultWidget,
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
disabled: false,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async run(type: MetadataType) {
|
||||||
|
try {
|
||||||
|
this.disabled = true
|
||||||
|
DownloadPackage.single(`${getFriendlyTitle(true)}.${type}.txt`, await generateByType(type))
|
||||||
|
} catch (error) {
|
||||||
|
logError(error)
|
||||||
|
} finally {
|
||||||
|
this.disabled = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
</script>
|
||||||
72
registry/lib/components/video/metadata/index.ts
Normal file
72
registry/lib/components/video/metadata/index.ts
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import { defineComponentMetadata } from '@/components/define'
|
||||||
|
import { PackageEntry } from '@/core/download'
|
||||||
|
import { hasVideo } from '@/core/spin-query'
|
||||||
|
import { Toast } from '@/core/toast'
|
||||||
|
import { videoUrls } from '@/core/utils/urls'
|
||||||
|
import { DownloadVideoAssets } from '../download/types'
|
||||||
|
import { generateByType, MetadataType } from './metadata'
|
||||||
|
|
||||||
|
export const title = '保存视频元数据'
|
||||||
|
export const name = 'saveVideoMetadata'
|
||||||
|
|
||||||
|
const author = [
|
||||||
|
{
|
||||||
|
name: 'WakelessSloth56',
|
||||||
|
link: 'https://github.com/WakelessSloth56',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'LainIO24',
|
||||||
|
link: 'https://github.com/LainIO24',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const component = defineComponentMetadata({
|
||||||
|
name,
|
||||||
|
displayName: title,
|
||||||
|
description: '保存视频元数据(标题、描述、UP、章节等)',
|
||||||
|
author,
|
||||||
|
tags: [componentsTags.video],
|
||||||
|
entry: none,
|
||||||
|
urlInclude: videoUrls,
|
||||||
|
widget: {
|
||||||
|
condition: hasVideo,
|
||||||
|
component: () => import('./SaveMetadata.vue').then(m => m.default),
|
||||||
|
},
|
||||||
|
plugin: {
|
||||||
|
displayName: `下载视频 - ${title}支持`,
|
||||||
|
author,
|
||||||
|
setup: ({ addData }) => {
|
||||||
|
addData('downloadVideo.assets', async (assets: DownloadVideoAssets[]) => {
|
||||||
|
assets.push({
|
||||||
|
name,
|
||||||
|
displayName: title,
|
||||||
|
getAssets: async (
|
||||||
|
infos,
|
||||||
|
instance: {
|
||||||
|
type: MetadataType
|
||||||
|
enabled: boolean
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
const { type, enabled } = instance
|
||||||
|
if (enabled) {
|
||||||
|
const toast = Toast.info('获取视频元数据中...', title)
|
||||||
|
const result: PackageEntry[] = []
|
||||||
|
for (const info of infos) {
|
||||||
|
result.push({
|
||||||
|
name: `${info.input.title}.${type}.txt`,
|
||||||
|
data: await generateByType(type, info.input.aid, info.input.cid),
|
||||||
|
options: {},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
toast.message = '完成!'
|
||||||
|
toast.duration = 1000
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
},
|
||||||
|
component: () => import('./Plugin.vue').then(m => m.default),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
152
registry/lib/components/video/metadata/metadata.ts
Normal file
152
registry/lib/components/video/metadata/metadata.ts
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
import { VideoInfo, VideoPageInfo } from '@/components/video/video-info'
|
||||||
|
import { VideoQuality } from '@/components/video/video-quality'
|
||||||
|
import { bilibiliApi, getJsonWithCredentials } from '@/core/ajax'
|
||||||
|
import { meta } from '@/core/meta'
|
||||||
|
import { Toast } from '@/core/toast'
|
||||||
|
import { title as pluginTitle } from '.'
|
||||||
|
|
||||||
|
export type MetadataType = 'ffmetadata' | 'ogm'
|
||||||
|
|
||||||
|
function escape(s: string) {
|
||||||
|
return s.replace(/[=;#\\\n]/g, r => `\\${r}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ViewPoint {
|
||||||
|
content: string
|
||||||
|
from: number
|
||||||
|
to: number
|
||||||
|
image: string
|
||||||
|
}
|
||||||
|
|
||||||
|
class VideoMetadata {
|
||||||
|
#aid: string
|
||||||
|
#cid: number | string
|
||||||
|
|
||||||
|
basic: VideoInfo
|
||||||
|
|
||||||
|
viewPoints: ViewPoint[]
|
||||||
|
page: VideoPageInfo
|
||||||
|
quality?: VideoQuality
|
||||||
|
|
||||||
|
constructor(aid: string, cid: number | string) {
|
||||||
|
this.#aid = aid
|
||||||
|
this.#cid = cid
|
||||||
|
this.basic = new VideoInfo(aid)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetch() {
|
||||||
|
await this.basic.fetchInfo()
|
||||||
|
this.page = this.basic.pages.filter(p => p.cid === parseInt(<any>this.#cid))[0]
|
||||||
|
|
||||||
|
const playInfo = await bilibiliApi(
|
||||||
|
getJsonWithCredentials(
|
||||||
|
`https://api.bilibili.com/x/player/wbi/v2?aid=${this.#aid}&cid=${this.#cid}`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
this.viewPoints = lodash.get(playInfo, 'view_points', []) as ViewPoint[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchMetadata(aid: string = unsafeWindow.aid, cid: string = unsafeWindow.cid) {
|
||||||
|
const data = new VideoMetadata(aid, cid)
|
||||||
|
await data.fetch()
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
function ff(key: string, value: any, prefix = true) {
|
||||||
|
return `${prefix ? 'bilibili_' : ''}${key}=${escape(lodash.toString(value))}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateFFMetadata(aid: string = unsafeWindow.aid, cid: string = unsafeWindow.cid) {
|
||||||
|
const data = await fetchMetadata(aid, cid)
|
||||||
|
const info = data.basic
|
||||||
|
|
||||||
|
const lines = [
|
||||||
|
';FFMETADATA1',
|
||||||
|
`;generated by Bilibili-Evolved v${meta.compilationInfo.version}`,
|
||||||
|
`;generated on ${new Date().toLocaleString()}`,
|
||||||
|
// Standard fields
|
||||||
|
ff('title', `${info.title} - ${data.page.title}`, false),
|
||||||
|
ff('description', info.description, false),
|
||||||
|
ff('artist', info.up.name, false),
|
||||||
|
// Custom fields
|
||||||
|
ff('title', info.title),
|
||||||
|
ff('description', info.description),
|
||||||
|
ff('publish_date', new Date(info.pubdate * 1000).toLocaleString()),
|
||||||
|
ff('aid', info.aid),
|
||||||
|
ff('bvid', info.bvid),
|
||||||
|
ff('cid', data.page.cid),
|
||||||
|
ff('category_id', info.tagId),
|
||||||
|
ff('category_name', info.tagName),
|
||||||
|
ff('page_title', data.page.title),
|
||||||
|
ff('page', data.page.pageNumber),
|
||||||
|
ff('pages', info.pages.length),
|
||||||
|
ff('up_name', info.up.name),
|
||||||
|
ff('up_uid', info.up.uid),
|
||||||
|
]
|
||||||
|
|
||||||
|
if (data.quality) {
|
||||||
|
lines.push(ff('quality', data.quality.value))
|
||||||
|
lines.push(ff('quality_label', data.quality.name))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.viewPoints.length > 0) {
|
||||||
|
for (const chapter of data.viewPoints) {
|
||||||
|
lines.push(
|
||||||
|
...[
|
||||||
|
'[CHAPTER]',
|
||||||
|
'TIMEBASE=1/1',
|
||||||
|
ff('START', chapter.from, false),
|
||||||
|
ff('END', chapter.to, false),
|
||||||
|
ff('title', chapter.content, false),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = lines.join('\n')
|
||||||
|
|
||||||
|
console.debug(result)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateChapterFile(aid: string = unsafeWindow.aid, cid: string = unsafeWindow.cid) {
|
||||||
|
const { viewPoints } = await fetchMetadata(aid, cid)
|
||||||
|
console.debug(viewPoints)
|
||||||
|
if (viewPoints.length > 0) {
|
||||||
|
const result = viewPoints
|
||||||
|
.reduce((p, v, i) => {
|
||||||
|
const n = `${i + 1}`.padStart(3, '0')
|
||||||
|
return [
|
||||||
|
...p,
|
||||||
|
`CHAPTER${n}=${new Date(v.from * 1000).toISOString().slice(11, -1)}`,
|
||||||
|
`CHAPTER${n}NAME=${v.content}`,
|
||||||
|
]
|
||||||
|
}, [])
|
||||||
|
.join('\n')
|
||||||
|
|
||||||
|
console.debug(result)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
Toast.info('此视频没有章节', pluginTitle, 3000)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateByType(
|
||||||
|
type: MetadataType,
|
||||||
|
aid: string = unsafeWindow.aid,
|
||||||
|
cid: string = unsafeWindow.cid,
|
||||||
|
) {
|
||||||
|
let method: (aid, cid) => Promise<string>
|
||||||
|
switch (type) {
|
||||||
|
case 'ogm':
|
||||||
|
method = generateChapterFile
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
case 'ffmetadata':
|
||||||
|
method = generateFFMetadata
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return method(aid, cid)
|
||||||
|
}
|
||||||
@ -1,5 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="rpc-config download-video-config-section">
|
<div class="rpc-config download-video-config-section">
|
||||||
|
<div>
|
||||||
|
<div>aria2下载附属资源(若支持):</div>
|
||||||
|
<SwitchBox v-model="isPluginDownloadAssets" @change="saveSettings" />
|
||||||
|
</div>
|
||||||
<div v-if="isRenaming" class="profile-select">
|
<div v-if="isRenaming" class="profile-select">
|
||||||
<div class="profile-item-name">重命名 RPC 预设:</div>
|
<div class="profile-item-name">重命名 RPC 预设:</div>
|
||||||
<TextBox ref="renameInput" v-model="profileRename" />
|
<TextBox ref="renameInput" v-model="profileRename" />
|
||||||
@ -69,17 +73,19 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { getComponentSettings } from '@/core/settings'
|
import { getComponentSettings } from '@/core/settings'
|
||||||
import { Toast } from '@/core/toast'
|
import { Toast } from '@/core/toast'
|
||||||
import { TextBox, VButton, VIcon, VDropdown, TextArea } from '@/ui'
|
import { TextBox, VButton, VIcon, VDropdown, TextArea, SwitchBox } from '@/ui'
|
||||||
import { Aria2RpcProfile, defaultProfile } from './rpc-profiles'
|
import { Aria2RpcProfile, defaultProfile } from './rpc-profiles'
|
||||||
|
|
||||||
interface Options {
|
interface Options {
|
||||||
rpcProfiles: Aria2RpcProfile[]
|
rpcProfiles: Aria2RpcProfile[]
|
||||||
selectedRpcProfileName: string
|
selectedRpcProfileName: string
|
||||||
|
isPluginDownloadAssets: boolean
|
||||||
}
|
}
|
||||||
const { options: storedOptions } = getComponentSettings('downloadVideo')
|
const { options: storedOptions } = getComponentSettings('downloadVideo')
|
||||||
const defaultOptions: Options = {
|
const defaultOptions: Options = {
|
||||||
rpcProfiles: [defaultProfile],
|
rpcProfiles: [defaultProfile],
|
||||||
selectedRpcProfileName: defaultProfile.name,
|
selectedRpcProfileName: defaultProfile.name,
|
||||||
|
isPluginDownloadAssets: false,
|
||||||
}
|
}
|
||||||
const options = { ...defaultOptions, ...storedOptions }
|
const options = { ...defaultOptions, ...storedOptions }
|
||||||
const handleMissingProfile = () => {
|
const handleMissingProfile = () => {
|
||||||
@ -99,6 +105,7 @@ export default Vue.extend({
|
|||||||
VIcon,
|
VIcon,
|
||||||
VDropdown,
|
VDropdown,
|
||||||
TextArea,
|
TextArea,
|
||||||
|
SwitchBox,
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@ -106,12 +113,14 @@ export default Vue.extend({
|
|||||||
profileRename: '',
|
profileRename: '',
|
||||||
rpcProfiles: options.rpcProfiles,
|
rpcProfiles: options.rpcProfiles,
|
||||||
selectedRpcProfile: lastSelectedProfile,
|
selectedRpcProfile: lastSelectedProfile,
|
||||||
|
isPluginDownloadAssets: options.isPluginDownloadAssets,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
saveSettings() {
|
saveSettings() {
|
||||||
options.selectedRpcProfileName = this.selectedRpcProfile.name
|
options.selectedRpcProfileName = this.selectedRpcProfile.name
|
||||||
options.rpcProfiles = this.rpcProfiles
|
options.rpcProfiles = this.rpcProfiles
|
||||||
|
options.isPluginDownloadAssets = this.isPluginDownloadAssets
|
||||||
Object.assign(storedOptions, options)
|
Object.assign(storedOptions, options)
|
||||||
},
|
},
|
||||||
async startRename() {
|
async startRename() {
|
||||||
|
|||||||
@ -131,35 +131,59 @@ export const aria2Rpc: DownloadVideoOutput = {
|
|||||||
action,
|
action,
|
||||||
instance: Vue & {
|
instance: Vue & {
|
||||||
selectedRpcProfile: Aria2RpcProfile
|
selectedRpcProfile: Aria2RpcProfile
|
||||||
|
isPluginDownloadAssets?: boolean
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
const { infos } = action
|
const { infos, extraOnlineAssets } = action
|
||||||
const { selectedRpcProfile } = instance
|
const { selectedRpcProfile, isPluginDownloadAssets } = instance
|
||||||
const { secretKey, dir, other } = selectedRpcProfile
|
const { secretKey, dir, other } = selectedRpcProfile
|
||||||
const referer = document.URL.replace(window.location.search, '')
|
const referer = document.URL.replace(window.location.search, '')
|
||||||
const totalParams = infos
|
const ariaParamsGenerator = (url: string, title: string) => {
|
||||||
|
const singleInfoParams = []
|
||||||
|
if (secretKey) {
|
||||||
|
singleInfoParams.push(`token:${secretKey}`)
|
||||||
|
}
|
||||||
|
singleInfoParams.push([url])
|
||||||
|
singleInfoParams.push({
|
||||||
|
referer,
|
||||||
|
'user-agent': UserAgent,
|
||||||
|
out: title,
|
||||||
|
dir: dir || undefined,
|
||||||
|
...parseRpcOptions(other),
|
||||||
|
})
|
||||||
|
const id = encodeURIComponent(title)
|
||||||
|
return {
|
||||||
|
params: singleInfoParams,
|
||||||
|
id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handle video params
|
||||||
|
const videoParams = infos
|
||||||
.map(info =>
|
.map(info =>
|
||||||
info.titledFragments.map(fragment => {
|
info.titledFragments.map(fragment => {
|
||||||
const singleInfoParams = []
|
const { url, title } = fragment
|
||||||
if (secretKey) {
|
return ariaParamsGenerator(url, title)
|
||||||
singleInfoParams.push(`token:${secretKey}`)
|
|
||||||
}
|
|
||||||
singleInfoParams.push([fragment.url])
|
|
||||||
singleInfoParams.push({
|
|
||||||
referer,
|
|
||||||
'user-agent': UserAgent,
|
|
||||||
out: fragment.title,
|
|
||||||
dir: dir || undefined,
|
|
||||||
...parseRpcOptions(other),
|
|
||||||
})
|
|
||||||
const id = encodeURIComponent(fragment.title)
|
|
||||||
return {
|
|
||||||
params: singleInfoParams,
|
|
||||||
id,
|
|
||||||
}
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.flat()
|
.flat()
|
||||||
|
|
||||||
|
// handle assets
|
||||||
|
const assetsAriaParams = []
|
||||||
|
const extraAssetsForBrowerDownload = []
|
||||||
|
for (const { asset, instance: assetInstance } of extraOnlineAssets) {
|
||||||
|
if (isPluginDownloadAssets && 'getUrls' in asset) {
|
||||||
|
// get asset from aria2
|
||||||
|
const results = await asset.getUrls(infos, assetInstance)
|
||||||
|
assetsAriaParams.push(...results.map(({ name, url }) => ariaParamsGenerator(url, name)))
|
||||||
|
} else {
|
||||||
|
// remain asset in `extraOnlineAssets`
|
||||||
|
extraAssetsForBrowerDownload.push({ asset, instance: assetInstance })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
action.extraOnlineAssets = extraAssetsForBrowerDownload
|
||||||
|
|
||||||
|
const totalParams = [...videoParams, ...assetsAriaParams]
|
||||||
const results = await sendRpc(selectedRpcProfile, totalParams)
|
const results = await sendRpc(selectedRpcProfile, totalParams)
|
||||||
console.table(results)
|
console.table(results)
|
||||||
if (results.length === 1) {
|
if (results.length === 1) {
|
||||||
|
|||||||
41
registry/lib/plugins/video/download/wasm-output/Config.vue
Normal file
41
registry/lib/plugins/video/download/wasm-output/Config.vue
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="shouldShow" class="download-video-config-item" style="flex-wrap: wrap">
|
||||||
|
<div class="download-video-config-title">写入元数据:</div>
|
||||||
|
<SwitchBox v-model="muxWithMetadata" @change="saveOptions" />
|
||||||
|
<div class="download-video-config-description" style="width: 100%">
|
||||||
|
仅支持元数据类型「ffmetadata」
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { SwitchBox } from '@/ui'
|
||||||
|
import { isComponentEnabled, getComponentSettings } from '@/core/settings'
|
||||||
|
|
||||||
|
interface Options {
|
||||||
|
muxWithMetadata: boolean
|
||||||
|
}
|
||||||
|
const defaultOptions: Options = {
|
||||||
|
muxWithMetadata: false,
|
||||||
|
}
|
||||||
|
const { options: storedOptions } = getComponentSettings('downloadVideo')
|
||||||
|
const options: Options = { ...defaultOptions, ...storedOptions }
|
||||||
|
export default Vue.extend({
|
||||||
|
components: {
|
||||||
|
SwitchBox,
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
const shouldShow = isComponentEnabled('saveVideoMetadata')
|
||||||
|
return {
|
||||||
|
shouldShow,
|
||||||
|
muxWithMetadata: shouldShow && options.muxWithMetadata,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
saveOptions() {
|
||||||
|
options.muxWithMetadata = this.muxExtraAssets
|
||||||
|
Object.assign(storedOptions, options)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { DownloadPackage } from '@/core/download'
|
import { DownloadPackage, PackageEntry } from '@/core/download'
|
||||||
import { meta } from '@/core/meta'
|
import { meta } from '@/core/meta'
|
||||||
import { getComponentSettings } from '@/core/settings'
|
import { getComponentSettings } from '@/core/settings'
|
||||||
import { Toast } from '@/core/toast'
|
import { Toast } from '@/core/toast'
|
||||||
@ -46,7 +46,8 @@ async function single(
|
|||||||
name: string,
|
name: string,
|
||||||
videoUrl: string,
|
videoUrl: string,
|
||||||
audioUrl: string,
|
audioUrl: string,
|
||||||
isFlac: boolean,
|
ffmetadata: string,
|
||||||
|
outputMkv: boolean,
|
||||||
pageIndex = 1,
|
pageIndex = 1,
|
||||||
totalPages = 1,
|
totalPages = 1,
|
||||||
) {
|
) {
|
||||||
@ -55,43 +56,60 @@ async function single(
|
|||||||
ffmpeg.writeFile('video', await httpGet(videoUrl, toastProgress(toast, '正在下载视频流')))
|
ffmpeg.writeFile('video', await httpGet(videoUrl, toastProgress(toast, '正在下载视频流')))
|
||||||
ffmpeg.writeFile('audio', await httpGet(audioUrl, toastProgress(toast, '正在下载音频流')))
|
ffmpeg.writeFile('audio', await httpGet(audioUrl, toastProgress(toast, '正在下载音频流')))
|
||||||
|
|
||||||
toast.message = '混流中……'
|
const args = ['-i', 'video', '-i', 'audio']
|
||||||
const outputExt = isFlac ? 'mkv' : 'mp4'
|
|
||||||
name = name.replace(/.[^/.]+$/, `.${outputExt}`)
|
|
||||||
await ffmpeg.exec([
|
|
||||||
'-i',
|
|
||||||
'video',
|
|
||||||
'-i',
|
|
||||||
'audio',
|
|
||||||
'-c:v',
|
|
||||||
'copy',
|
|
||||||
'-c:a',
|
|
||||||
'copy',
|
|
||||||
'-f',
|
|
||||||
isFlac ? 'matroska' : 'mp4',
|
|
||||||
`output.${outputExt}`,
|
|
||||||
])
|
|
||||||
|
|
||||||
const output = await ffmpeg.readFile(`output.${outputExt}`)
|
if (ffmetadata) {
|
||||||
|
ffmpeg.writeFile('ffmetadata', new TextEncoder().encode(ffmetadata))
|
||||||
|
args.push('-i', 'ffmetadata', '-map_metadata', '2')
|
||||||
|
if (!outputMkv) {
|
||||||
|
args.push('-movflags', '+use_metadata_tags')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
args.push('-codec', 'copy', '-f', outputMkv ? 'matroska' : 'mp4', 'output')
|
||||||
|
|
||||||
|
console.debug('FFmpeg commandline args:', args.join(' '))
|
||||||
|
|
||||||
|
toast.message = '混流中……'
|
||||||
|
await ffmpeg.exec(args)
|
||||||
|
|
||||||
|
const output = await ffmpeg.readFile('output')
|
||||||
const outputBlob = new Blob([output], {
|
const outputBlob = new Blob([output], {
|
||||||
type: isFlac ? 'video/x-matroska' : 'video/mp4',
|
type: outputMkv ? 'video/x-matroska' : 'video/mp4',
|
||||||
})
|
})
|
||||||
|
|
||||||
toast.message = '完成!'
|
toast.message = '完成!'
|
||||||
toast.duration = 1000
|
toast.duration = 1000
|
||||||
|
|
||||||
await DownloadPackage.single(name, outputBlob)
|
await DownloadPackage.single(
|
||||||
|
name.replace(/.[^/.]+$/, `.${outputMkv ? 'mkv' : 'mp4'}`),
|
||||||
|
outputBlob,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function run(action: DownloadVideoAction) {
|
export async function run(action: DownloadVideoAction, muxWithMetadata: boolean) {
|
||||||
if (!ffmpeg.loaded) {
|
if (!ffmpeg.loaded) {
|
||||||
await loadFFmpeg()
|
await loadFFmpeg()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { infos: pages, extraAssets } = action
|
||||||
|
|
||||||
|
let ffmetadata: PackageEntry[]
|
||||||
|
if (muxWithMetadata) {
|
||||||
|
const extraAssetsForBrowser = []
|
||||||
|
for (const { asset, instance } of extraAssets) {
|
||||||
|
if (!ffmetadata && asset.name === 'saveVideoMetadata' && instance.type === 'ffmetadata') {
|
||||||
|
ffmetadata = await asset.getAssets(pages, instance)
|
||||||
|
} else {
|
||||||
|
extraAssetsForBrowser.push({ asset, instance })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
action.extraAssets = extraAssetsForBrowser
|
||||||
|
}
|
||||||
|
|
||||||
const { dashAudioExtension, dashFlacAudioExtension, dashVideoExtension } =
|
const { dashAudioExtension, dashFlacAudioExtension, dashVideoExtension } =
|
||||||
getComponentSettings<Options>('downloadVideo').options
|
getComponentSettings<Options>('downloadVideo').options
|
||||||
|
|
||||||
const pages = action.infos
|
|
||||||
for (let i = 0; i < pages.length; i++) {
|
for (let i = 0; i < pages.length; i++) {
|
||||||
const page = pages[i]
|
const page = pages[i]
|
||||||
const [video, audio] = page.titledFragments
|
const [video, audio] = page.titledFragments
|
||||||
@ -109,6 +127,7 @@ export async function run(action: DownloadVideoAction) {
|
|||||||
video.title,
|
video.title,
|
||||||
video.url,
|
video.url,
|
||||||
audio.url,
|
audio.url,
|
||||||
|
<string>ffmetadata?.[i]?.data,
|
||||||
audio.extension === dashFlacAudioExtension,
|
audio.extension === dashFlacAudioExtension,
|
||||||
i + 1,
|
i + 1,
|
||||||
pages.length,
|
pages.length,
|
||||||
|
|||||||
@ -19,14 +19,15 @@ export const plugin: PluginMetadata = {
|
|||||||
outputs.push({
|
outputs.push({
|
||||||
name: 'wasm',
|
name: 'wasm',
|
||||||
displayName: 'WASM',
|
displayName: 'WASM',
|
||||||
description: `${desc},运行过程中请勿关闭页面,初次使用或清除缓存后需要加载约 30 MB 的 WASM 文件`,
|
description: `${desc}。运行过程中请勿关闭页面,初次使用或清除缓存后需要加载约 30 MB 的 WASM 文件。`,
|
||||||
runAction: async action => {
|
runAction: async (action, instance) => {
|
||||||
try {
|
try {
|
||||||
await run(action)
|
await run(action, instance.muxWithMetadata)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Toast.error(String(error), title)
|
Toast.error(String(error), title)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
component: () => import('./Config.vue').then(m => m.default),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,5 +1,17 @@
|
|||||||
import { getJsonWithCredentials, getText } from '@/core/ajax'
|
import { getJsonWithCredentials, getText } from '@/core/ajax'
|
||||||
|
|
||||||
|
export interface UpInfo {
|
||||||
|
uid: number
|
||||||
|
name: string
|
||||||
|
faceUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VideoPageInfo {
|
||||||
|
cid: number
|
||||||
|
title: string
|
||||||
|
pageNumber: number
|
||||||
|
}
|
||||||
|
|
||||||
export class VideoInfo {
|
export class VideoInfo {
|
||||||
aid: string
|
aid: string
|
||||||
bvid: string
|
bvid: string
|
||||||
@ -13,16 +25,8 @@ export class VideoInfo {
|
|||||||
tagName: string
|
tagName: string
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
up: {
|
up: UpInfo
|
||||||
uid: number
|
pages: VideoPageInfo[]
|
||||||
name: string
|
|
||||||
faceUrl: string
|
|
||||||
}
|
|
||||||
pages: {
|
|
||||||
cid: number
|
|
||||||
title: string
|
|
||||||
pageNumber: number
|
|
||||||
}[]
|
|
||||||
|
|
||||||
constructor(id: string, bvid = false) {
|
constructor(id: string, bvid = false) {
|
||||||
if (bvid) {
|
if (bvid) {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user