diff --git a/.vscode/settings.json b/.vscode/settings.json index 586e80f5f1..39e44277ea 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -43,6 +43,7 @@ "durl", "epid", "esbuild", + "ffmetadata", "flac", "Fullscreen", "githubusercontent", diff --git a/registry/lib/components/utils/view-cover/index.ts b/registry/lib/components/utils/view-cover/index.ts index b56af3d7c0..4c5fef9af1 100644 --- a/registry/lib/components/utils/view-cover/index.ts +++ b/registry/lib/components/utils/view-cover/index.ts @@ -1,5 +1,5 @@ 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 { videoAndBangumiUrls } from '@/core/utils/urls' import { Toast } from '@/core/toast' @@ -55,6 +55,26 @@ export const component = defineComponentMetadata({ toast.message = `获取完成. 成功 ${success.length} 个, 失败 ${fail.length} 个.` 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), }) }) diff --git a/registry/lib/components/video/download/DownloadVideo.vue b/registry/lib/components/video/download/DownloadVideo.vue index 1e66bc16ad..6a1a637cd8 100644 --- a/registry/lib/components/video/download/DownloadVideo.vue +++ b/registry/lib/components/video/download/DownloadVideo.vue @@ -330,19 +330,15 @@ export default Vue.extend({ }) } 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() + assets.forEach(a => { + const assetsType = a?.getUrls ? action.extraOnlineAssets : action.extraAssets + assetsType.push({ + asset: a, + instance: this.$refs.assetsOptions.find((c: any) => c.$attrs.name === a.name), + }) + }) await output.runAction(action, instance) + await action.downloadExtraAssets() } catch (error) { logError(error) } finally { diff --git a/registry/lib/components/video/download/types.ts b/registry/lib/components/video/download/types.ts index 9f68a33ae8..676c7a5d8f 100644 --- a/registry/lib/components/video/download/types.ts +++ b/registry/lib/components/video/download/types.ts @@ -77,11 +77,19 @@ export interface DownloadVideoApi extends WithName { /** 表示下载时额外附带的产物, 如弹幕 / 字幕等 */ export interface DownloadVideoAssets extends VueInstanceInput, WithName { getAssets: (infos: DownloadVideoInfo[], instance: AssetsParameter) => Promise + /** 获取可直接下载的链接 */ + getUrls?: ( + infos: DownloadVideoInfo[], + instance: AssetsParameter, + ) => Promise<{ name: string; url: string }[]> } /** 表示视频的下载信息以及携带的额外产物 */ -export class DownloadVideoAction { +export class DownloadVideoAction { readonly inputs: DownloadVideoInputItem[] = [] - extraAssets: PackageEntry[] = [] + /** 可调用处理的asset和对应的参数 */ + extraAssets: { asset: DownloadVideoAssets; instance: AssetsParameter }[] = [] + /** 可直接下载的asset和对应的参数 */ + extraOnlineAssets: { asset: DownloadVideoAssets; instance: AssetsParameter }[] = [] constructor(public infos: DownloadVideoInfo[]) { this.inputs = infos.map(it => it.input) @@ -92,7 +100,15 @@ export class DownloadVideoAction { async downloadExtraAssets() { console.log('[downloadExtraAssets]', this.extraAssets) 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) } } /** 下载视频的最终输出处理 */ diff --git a/registry/lib/components/video/metadata/Plugin.vue b/registry/lib/components/video/metadata/Plugin.vue new file mode 100644 index 0000000000..6165a7e297 --- /dev/null +++ b/registry/lib/components/video/metadata/Plugin.vue @@ -0,0 +1,44 @@ + + diff --git a/registry/lib/components/video/metadata/SaveMetadata.vue b/registry/lib/components/video/metadata/SaveMetadata.vue new file mode 100644 index 0000000000..1704d86c0d --- /dev/null +++ b/registry/lib/components/video/metadata/SaveMetadata.vue @@ -0,0 +1,48 @@ + + + diff --git a/registry/lib/components/video/metadata/index.ts b/registry/lib/components/video/metadata/index.ts new file mode 100644 index 0000000000..bcdbdecea1 --- /dev/null +++ b/registry/lib/components/video/metadata/index.ts @@ -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), + }) + }) + }, + }, +}) diff --git a/registry/lib/components/video/metadata/metadata.ts b/registry/lib/components/video/metadata/metadata.ts new file mode 100644 index 0000000000..6f30f52cab --- /dev/null +++ b/registry/lib/components/video/metadata/metadata.ts @@ -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(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 + switch (type) { + case 'ogm': + method = generateChapterFile + break + default: + case 'ffmetadata': + method = generateFFMetadata + break + } + return method(aid, cid) +} diff --git a/registry/lib/plugins/video/download/aria2-output/RpcConfig.vue b/registry/lib/plugins/video/download/aria2-output/RpcConfig.vue index 067638faa3..52e9e2f01a 100644 --- a/registry/lib/plugins/video/download/aria2-output/RpcConfig.vue +++ b/registry/lib/plugins/video/download/aria2-output/RpcConfig.vue @@ -1,5 +1,9 @@