import { BangumiInfo, 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 { getComponentSettings } from '@/core/settings' import { Toast } from '@/core/toast' import { name as componentName, title as pluginTitle } from '.' import { FieldsMode, Options } from './options' import { MetadataType, Tag, ViewPoint } from './types' import { bangumiSkipToViewPoints, escape, findCidInUgcSeason, fixBgmTag, formatStaffs, formatTime, tagWithId, } from './utils' class VideoMetadata { private readonly aid: number private readonly cid: number basic: VideoInfo viewPoints: ViewPoint[] tags: { tag?: Tag[] topic?: Tag[] bgm?: Tag[] } = {} page: VideoPageInfo quality?: VideoQuality bangumi?: BangumiInfo constructor(aid: string, cid: number | string) { this.aid = parseInt(aid) this.cid = parseInt(cid) this.basic = new VideoInfo(aid) } async fetch() { await this.basic.fetchInfo() this.page = this.basic.pages.filter(p => p.cid === this.cid)[0] const playInfo = await bilibiliApi( getJsonWithCredentials(`//api.bilibili.com/x/player/wbi/v2?aid=${this.aid}&cid=${this.cid}`), ) this.viewPoints = lodash.get(playInfo, 'view_points', []) as ViewPoint[] const tags = await bilibiliApi( getJsonWithCredentials( `//api.bilibili.com/x/web-interface/view/detail/tag?aid=${this.aid}&cid=${this.cid}`, ), ) const groupedTags = lodash.groupBy(tags, 'tag_type') this.tags.tag = groupedTags.old_channel this.tags.topic = groupedTags.topic this.tags.bgm = groupedTags.bgm if (this.basic.redirectUrl) { const epid = parseInt(this.basic.redirectUrl.match(/ep(\d+)/)?.[1] ?? '') if (epid) { this.bangumi = await BangumiInfo.byEpisodeId(epid).fetchInfo() if ( getComponentSettings(componentName).options.convertBangumiSkips && this.viewPoints.length === 0 ) { this.viewPoints = bangumiSkipToViewPoints( this.bangumi.episode.skip, this.bangumi.episode.duration, ) } } } } } 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}=${ Array.isArray(value) ? value.map(escape).join(',') : escape(value) }` } async function generateFFMetadata(aid: string = unsafeWindow.aid, cid: string = unsafeWindow.cid) { const data = await fetchMetadata(aid, cid) const { basic } = data console.debug(data) const { options: { fieldsMode, timeFormat, includeStat }, } = getComponentSettings(componentName) const generated = new Date() const lines = [ ';FFMETADATA1', `;generated by Bilibili-Evolved v${meta.compilationInfo.version}`, `;generated on ${generated.toLocaleDateString()} at ${generated.toLocaleTimeString( navigator.language, { timeZoneName: 'short' }, )}`, // Standard fields ff( 'title', `${basic.title}${basic.title !== data.page.title ? ` - ${data.page.title}` : ''}`, false, ), ff('description', basic.description, false), ff('artist', basic.up.name, false), ] if (fieldsMode === FieldsMode.ALL) { // Custom fields lines.push( ff('metadata_generated', formatTime(generated, timeFormat)), ff('title', basic.title), ff('description', basic.description), ff('publish_date', formatTime(new Date(basic.pubdate * 1000), timeFormat)), ff('aid', basic.aid), ff('bvid', basic.bvid), ff('cid', data.page.cid), ff('up_name', basic.up.name), ff('up_uid', basic.up.uid), ) if (basic.staffs) { lines.push(ff('staffs', formatStaffs(basic.staffs))) } lines.push( ff('page_title', data.page.title), ff('pages', basic.pages.length), ff('page', data.page.pageNumber), ff('category_id', basic.tagId), ff('category_name', basic.tagName), ) if (basic.ugcSeason) { const d = findCidInUgcSeason(basic.ugcSeason, data.page.cid) if (d) { lines.push(ff('collection_id', d.seasonId)) lines.push(ff('collection_title', d.seasonTitle)) lines.push(ff('collection_section_id', d.sectionId)) lines.push(ff('collection_section_title', d.sectionTitle)) } } if (data.tags.tag) { lines.push(ff('tags', tagWithId(data.tags.tag))) } if (data.tags.topic) { lines.push(ff('topic', tagWithId(data.tags.topic))) } if (data.tags.bgm) { lines.push(ff('bgm', fixBgmTag(data.tags.bgm))) } if (data.bangumi) { const d = data.bangumi lines.push( ff('bangumi_media_id', d.mediaId), ff('bangumi_season_id', d.seasonId), ff('bangumi_season_title', d.seasonTitle), ff('bangumi_series_id', d.seriesId), ff('bangumi_series_title', d.seriesTitle), ff('bangumi_section_title', d.episode.section), ff('bangumi_episode_id', d.episode.epid), ff('bangumi_episode_title', d.episode.title), ff( 'bangumi_area', d.areas.map(x => x.name), ), ) } if (includeStat) { const s = basic.stat lines.push( ff('stat_view', s.view), ff('stat_like', s.like), ff('stat_coin', s.coin), ff('stat_favorite', s.favorite), ff('stat_share', s.share), ff('stat_danmaku', s.danmaku), ff('stat_reply', s.reply), ) if (s.his_rank > 0) { lines.push(ff('stat_highest_rank', s.his_rank)) } } 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) }