import van, { State } from 'vanjs-core' import { VanComponent, formatSeconds } from '../../mixin' import { PageInParseResult, PlayInfo, VideoFormat } from '../type' import { WorkRoute } from '..' import { createTask, getPlayInfo } from '../data' import PQueue from 'p-queue' const { a, button, div, input, select, option, label, span } = van.tags type Option = { workRoute: WorkRoute } const videoFormatMap: Record = { 127: "超高清 8K", 126: "杜比视界", 125: "真彩 HDR", 120: "超清 4K", 116: "高清 1080P60", 112: "高清 1080P+", 80: "高清 1080P", 74: "高清 720P60", 64: "高清 720P", 32: "清晰 480P", 16: "流畅 360P", 6: "极速 240P", } const codecMap: Record<12 | 7 | 13, string> = { 12: "HEVC (hev1)", 7: "AVC (avc1)", 13: "AV1 (av01)", } export class ParseModalComp implements VanComponent { element: HTMLElement totalCount: State finishCount = van.state(0) abortControllers: AbortController[] = [] currentController?: AbortController allPlayInfo: State<{ page: PageInParseResult info: PlayInfo | null selected: State formatIndex: State }[]> = van.state([]) /** 下载状态 */ isDownloading = van.state(false) /** 下载类型:audio 仅音频,video 仅视频,merge 音视频合并 */ downloadType = van.state<'audio' | 'video' | 'merge'>('merge') /** 优先视频编码格式:12 hev1, 7 avc1, 13 av01 */ preferredCodec = van.state<12 | 7 | 13>(12) /** 是否优先使用HiRes(flac)音频 */ preferHiResAudio = van.state(true) errorList: State = van.state([]) constructor(public option: Option) { this.totalCount = van.derive(() => option.workRoute.selectedPages.val.length) const allFinish = van.derive(() => this.totalCount.val == this.finishCount.val) this.element = div({ class: `modal fade`, tabIndex: -1 }, div({ class: () => `modal-dialog modal-xl modal-fullscreen-xl-down ${(this.totalCount.val + this.errorList.val.length) < 10 ? '' : 'modal-dialog-scrollable'}` }, div({ class: `modal-content` }, div({ class: `modal-header` }, div({ class: `h5 modal-title` }, () => this.isDownloading.val ? '正在创建下载任务...' : (allFinish.val ? '批量下载' : '批量解析')), button({ class: `btn-close`, 'data-bs-dismiss': `modal` }) ), // 进度条固定在头部下方 this.StickyProgress(), // 下载时的加载动画 div({ class: 'text-center p-5', hidden: () => !this.isDownloading.val }, div({ class: 'spinner-border text-primary', role: 'status' }, span({ class: 'visually-hidden' }, '加载中...') ), div({ class: 'mt-3' }, '正在创建下载任务...') ), div({ class: `modal-body vstack gap-3`, tabIndex: -1, style: 'outline: none;' }, div({ class: 'vstack gap-2', hidden: () => this.errorList.val.length == 0 || !allFinish.val || this.isDownloading.val }, div({ class: 'text-danger' }, () => `以下 ${this.errorList.val.length} 个视频解析失败`), () => div({ class: 'list-group' }, this.errorList.val.map(error => div({ class: 'list-group-item disabled' }, error)) ) ), this.ListGroup() ), this.ModalFooter() ) ) ) this.element.addEventListener('hidden.bs.modal', () => { this.allPlayInfo.val = [] this.finishCount.val = this.totalCount.val for (const controller of this.abortControllers) { controller.abort() } this.abortControllers = [] }) this.element.addEventListener('show.bs.modal', () => { this.start() }) } /** 开始解析 */ async start() { this.finishCount.val = 0 this.errorList.val = [] const queue = new PQueue({ concurrency: 10 }) for (const page of this.option.workRoute.selectedPages.val) { queue.add(async () => { if (this.totalCount.val == this.finishCount.val) return const controller = new AbortController() this.abortControllers.push(controller) const playInfo = await getPlayInfo(page.bvid, page.cid, controller) playInfo.accept_quality = [...new Set(playInfo.dash.video.map(video => video.id))].sort((a, b) => b - a) this.allPlayInfo.val = this.allPlayInfo.val.concat({ page, info: playInfo, selected: van.state(true), formatIndex: van.state(0), }) this.finishCount.val++ }).catch(() => { this.finishCount.val++ const badgeNotNum = !page.badge.match(/^\d+$/) this.errorList.val = this.errorList.val.concat(`${page.part}${badgeNotNum ? ` - ${page.badge}` : ''}`) }) } await queue.onIdle() } download() { const selectedPlayInfos = this.allPlayInfo.val.filter(info => info.selected.val) const workRoute = this.option.workRoute this.isDownloading.val = true // 需要传递给服务器,需要创建下载任务的数据列表 createTask(selectedPlayInfos.map((info, index) => { const badgeNotNum = !info.page.badge.match(/^\d+$/) const isVideoMode = workRoute.videoInfoCardMode.val == 'video' const cardTitle = workRoute.videoInfoCardData.val.title const owner = workRoute.videoInfoCardData.val.staff.length > 0 ? workRoute.videoInfoCardData.val.staff[0].split("[")[0].trim() : workRoute.videoInfoCardData.val.owner.name.trim() const activeVideoInfo = getActiveFormatVideo(info.info!, info.info!.accept_quality[info.formatIndex.val], this.preferredCodec.val) const pagesLength = workRoute.videoInfoCardData.val.pages.length // 合集标题,用于创建子目录 // season 模式(番剧)使用视频标题 // video 模式有 collectionTitle(视频合集)时使用合集名称 const collectionTitle = workRoute.videoInfoCardData.val.collectionTitle const seasonTitle = workRoute.videoInfoCardMode.val == 'season' ? cardTitle.trim() : (isVideoMode && collectionTitle ? collectionTitle.trim() : undefined) return ({ bvid: info.page.bvid, cid: info.page.cid, cover: workRoute.videoInfoCardData.val.cover, title: (badgeNotNum ? [ info.page.part.trim(), `[${info.page.badge.trim()}]`, seasonTitle ? '' : `[${cardTitle.trim()}]`, `[${videoFormatMap[info.info!.accept_quality[info.formatIndex.val]]}]`, `[${formatSeconds(info.info!.dash.duration)}]` ] : [ pagesLength == 1 ? workRoute.allSection.val[workRoute.sectionTabsActiveIndex.val].title : (seasonTitle ? '' : `[${cardTitle.trim()}]`), workRoute.sectionPages.val.length == 1 ? '' : `[${info.page.badge.trim()}]`, info.page.part.trim(), isVideoMode ? `[${owner}]` : '', `[${videoFormatMap[info.info!.accept_quality[info.formatIndex.val]]}]`, `[${formatSeconds(info.info!.dash.duration)}]` ]).filter(p => p).join(' '), format: info.info!.accept_quality[info.formatIndex.val], owner, audio: getAudioURL(info.info!, this.preferHiResAudio.val), duration: info.info!.dash.duration, downloadType: this.downloadType.val, seasonTitle, ...activeVideoInfo }) })).then(() => { workRoute.parseModal.hide() }).catch(error => { alert(error.message) }).finally(() => { this.isDownloading.val = false }) } ParseProgress() { return div({ class: 'vstack gap-3', hidden: () => this.totalCount.val == this.finishCount.val || this.isDownloading.val }, div({ class: 'text-center fs-5' }, () => `正在解析,剩余 ${this.totalCount.val - this.finishCount.val} 项`), div({ class: 'progress' }, div({ class: 'progress-bar progress-bar-striped progress-bar-animated', style: () => `width: ${this.finishCount.val / this.totalCount.val * 100}%` },)), ) } /** 固定在顶部的进度条(解析进度) */ StickyProgress() { return div({ class: 'sticky-top bg-body p-3 border-bottom', hidden: () => this.totalCount.val == this.finishCount.val, style: 'z-index: 10;' }, this.ParseProgress(), ) } ListGroup() { return () => div({ class: 'list-group', hidden: () => this.totalCount.val != this.finishCount.val || this.isDownloading.val }, this.allPlayInfo.val.filter(info => info.info) .sort((a, b) => a.page.page - b.page.page) .map(info => { const badgeNotNum = !info.page.badge.match(/^\d+$/) return div({ class: () => `list-group-item user-select-none py-0 ${info.info ? '' : 'disabled'}`, role: 'button', onclick(event) { if ((event.target as HTMLElement).getAttribute('class')?.match(/dropdown-?/)) return info.selected.val = !info.selected.val } }, div({ class: 'hstack gap-2' }, div({ class: 'hstack gap-3 flex-fill py-1' }, input({ class: 'form-check-input', type: 'checkbox', checked: info.selected, }), div({}, div((badgeNotNum ? '' : `${info.page.badge}. `) + info.page.part), badgeNotNum ? div({ class: info.page.part ? 'small text-secondary' : '' }, info.page.badge) : '' ), ), div({ class: 'dropdown' }, div({ class: 'dropdown-toggle py-2 text-primary', 'data-bs-toggle': 'dropdown' }, () => videoFormatMap[info.info!.accept_quality[info.formatIndex.val]] ), () => { return div({ class: 'dropdown-menu shadow' }, info.info!.accept_quality.map((formatID, index) => { return div({ class: () => `dropdown-item ${info.formatIndex.val == index ? 'active' : ''}`, onclick() { info.formatIndex.val = index } }, videoFormatMap[formatID as VideoFormat]) }) ) } ) ) ) }) ) } ModalFooter() { const _that = this const selectedCount = van.derive(() => this.allPlayInfo.val.filter(info => info.selected.val).length) const totalCount = van.derive(() => this.allPlayInfo.val.length) /** 解析完成列表全部选中 */ const allSelected = van.derive(() => selectedCount.val == totalCount.val) /** 是否全部解析完成 */ const allFinish = van.derive(() => this.totalCount.val == this.finishCount.val) return div({ class: `modal-footer` }, div({ class: 'me-auto', hidden: () => !allFinish.val || totalCount.val == 0 }, div({ class: 'hstack gap-3 text-nowrap' }, () => `已选择 (${selectedCount.val}/${totalCount.val}) 项`, select({ class: 'form-select form-select-sm', value: _that.downloadType, oninput: (e) => _that.downloadType.val = (e.target as HTMLSelectElement).value as 'audio' | 'video' | 'merge' }, option({ value: 'merge' }, '音视频合并'), option({ value: 'audio' }, '仅音频'), option({ value: 'video' }, '仅视频') ), select({ class: 'form-select form-select-sm', value: _that.preferredCodec, oninput: (e) => _that.preferredCodec.val = Number((e.target as HTMLSelectElement).value) as 12 | 7 | 13 }, option({ value: '12' }, 'HEVC (hev1)'), option({ value: '7' }, 'AVC (avc1)'), option({ value: '13' }, 'AV1 (av01)') ), div({ class: 'form-check form-check-inline' }, input({ type: 'checkbox', class: 'form-check-input', id: 'preferHiResAudio', checked: _that.preferHiResAudio, oninput: (e) => _that.preferHiResAudio.val = (e.target as HTMLInputElement).checked }), label({ class: 'form-check-label', for: 'preferHiResAudio' }, 'Hi-Res') ) ) ), button({ class: `btn btn-secondary`, 'data-bs-dismiss': `modal`, hidden: allFinish }, '取消解析'), button({ class: 'btn btn-secondary', hidden: () => !allFinish.val || allSelected.val || totalCount.val == 0, onclick() { _that.allPlayInfo.val.forEach(info => info.selected.val = true) } }, '全选'), button({ class: 'btn btn-warning', hidden: () => !allFinish.val || !allSelected.val || totalCount.val == 0, onclick() { _that.allPlayInfo.val.forEach(info => info.selected.val = false) } }, '全不选'), button({ class: `btn btn-primary`, onclick() { _that.download() }, hidden: () => !allFinish.val, disabled: () => selectedCount.val <= 0 || _that.isDownloading.val }, '开始下载'), ) } } const getAudioURL = (playInfo: PlayInfo, preferHiRes: boolean = true): string => { if (preferHiRes && playInfo.dash.flac) { return playInfo.dash.flac.audio.baseUrl } else { return playInfo.dash.audio.sort((a, b) => b.id - a.id)[0].baseUrl } } const getActiveFormatVideo = (playInfo: PlayInfo, format: VideoFormat, preferredCodec: 12 | 7 | 13 = 12): { video: string, width: number, height: number } => { // 优先级顺序:用户首选编码格式,然后按默认优先级 12 > 7 > 13 const codecOrder = [preferredCodec, 12, 7, 13].filter((value, index, self) => self.indexOf(value) === index) for (const code of codecOrder) { for (const item of playInfo.dash.video) { if (item.id == format && item.codecid == code) { return { video: item.baseUrl, width: item.width, height: item.height } } } } throw new Error('未找到对应视频分辨率格式') }