68944da29c
创建合集子目录后,视频文件名不再重复包含合集标题。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
369 lines
16 KiB
TypeScript
369 lines
16 KiB
TypeScript
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<VideoFormat, string> = {
|
||
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<number>
|
||
finishCount = van.state(0)
|
||
|
||
abortControllers: AbortController[] = []
|
||
|
||
currentController?: AbortController
|
||
|
||
allPlayInfo: State<{
|
||
page: PageInParseResult
|
||
info: PlayInfo | null
|
||
selected: State<boolean>
|
||
formatIndex: State<number>
|
||
}[]> = 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<string[]> = 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('未找到对应视频分辨率格式')
|
||
}
|