feat: Add override configuration support for streamers (#1151)
* feat: Add override configuration support for streamers * refactor: Simplify plugin imports and platform matching * refactor: 支持插件配置覆写 * refactor: 更新 User 块覆写 * fix: type error
This commit is contained in:
1 parent
3b246974ec
commit
9404dc4a5c
26 files changed
+714
-324
No files matched your search
+8
-30
@@ -26,18 +26,10 @@ import { useBiliUsers } from '../lib/use-streamers'
|
||||
import styles from '../styles/dashboard.module.scss'
|
||||
|
||||
// 注册各平台组件
|
||||
import plugins from '../ui/plugins'
|
||||
import Global from '../ui/plugins/global'
|
||||
import Cookie from '../ui/plugins/cookie'
|
||||
import Bilibili from '../ui/plugins/bilibili'
|
||||
import CC from '../ui/plugins/cc'
|
||||
import Douyin from '../ui/plugins/douyin'
|
||||
import Douyu from '../ui/plugins/douyu'
|
||||
import Huya from '../ui/plugins/huya'
|
||||
import Twitch from '../ui/plugins/twitch'
|
||||
import YouTube from '../ui/plugins/youtube'
|
||||
import Developer from '../ui/plugins/developer'
|
||||
import Twitcasting from '@/app/ui/plugins/twitcasting'
|
||||
import Kilakila from '@/app/ui/plugins/kilakila'
|
||||
|
||||
|
||||
const Dashboard: React.FC = () => {
|
||||
const { Header, Content } = Layout
|
||||
@@ -196,26 +188,12 @@ const Dashboard: React.FC = () => {
|
||||
<p className={styles.meegoSharedWebSettin}>各平台下载设置</p>
|
||||
</div>
|
||||
<Collapse keepDOM style={{ width: '100%' }}>
|
||||
{/* 哔哩哔哩 */}
|
||||
<Bilibili entity={entity} list={list} />
|
||||
{/* CC直播 */}
|
||||
<CC />
|
||||
{/* 抖音 */}
|
||||
<Douyin entity={entity} />
|
||||
{/* 斗鱼 */}
|
||||
<Douyu />
|
||||
{/* 虎牙 */}
|
||||
<Huya entity={entity} />
|
||||
{/* 克拉克拉 */}
|
||||
<Kilakila />
|
||||
{/* 老鼠台 */}
|
||||
<Twitch entity={entity} />
|
||||
{/* Twitcasting */}
|
||||
<Twitcasting />
|
||||
{/* 油管 */}
|
||||
<YouTube entity={entity} />
|
||||
{/* 饼干 */}
|
||||
<Cookie entity={entity} list={list} />
|
||||
{Object.entries(plugins)
|
||||
.filter(([key]) => key !== 'Cookie')
|
||||
.map(([key, Plugin]) => (
|
||||
<Plugin entity={entity} list={list} />
|
||||
))}
|
||||
<plugins.Cookie entity={entity} list={list} />
|
||||
</Collapse>
|
||||
</div>
|
||||
</TabPane>
|
||||
|
||||
@@ -107,6 +107,7 @@ export interface LiveStreamerEntity {
|
||||
downloaded_processor?: Record<'run', string>[];
|
||||
postprocessor?: (Record<'run' | 'mv', string> | 'rm')[];
|
||||
opt_args?: string[];
|
||||
override?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface BiliType {
|
||||
|
||||
@@ -15,11 +15,13 @@ import {
|
||||
IconVideoListStroked,
|
||||
IconEdit2Stroked,
|
||||
IconDeleteStroked,
|
||||
IconWrench,
|
||||
} from '@douyinfe/semi-icons'
|
||||
import { List, ButtonGroup } from '@douyinfe/semi-ui'
|
||||
import React, { useState } from 'react'
|
||||
import useStreamers from '../lib/use-streamers'
|
||||
import TemplateModal from '../ui/TemplateModal'
|
||||
import OverrideModal from '../ui/OverrideModal'
|
||||
import { LiveStreamerEntity, put, requestDelete, sendRequest } from '../lib/api-streamer'
|
||||
import useSWRMutation from 'swr/mutation'
|
||||
|
||||
@@ -92,7 +94,7 @@ export default function Home() {
|
||||
}
|
||||
|
||||
const handleUpdate = async (values: any) => {
|
||||
// console.log(values);
|
||||
console.log(values);
|
||||
delete values.status
|
||||
if (values?.postprocessor) {
|
||||
values.postprocessor = values.postprocessor.map(
|
||||
@@ -246,6 +248,10 @@ export default function Home() {
|
||||
<Button theme="borderless" icon={<IconEdit2Stroked />}></Button>
|
||||
</TemplateModal>
|
||||
<span className="semi-button-group-line semi-button-group-line-borderless semi-button-group-line-primary"></span>
|
||||
<OverrideModal onOk={handleUpdate} entity={item}>
|
||||
<Button theme="borderless" icon={<IconWrench />}></Button>
|
||||
</OverrideModal>
|
||||
<span className="semi-button-group-line semi-button-group-line-borderless semi-button-group-line-primary"></span>
|
||||
<Popconfirm
|
||||
title="确定是否要删除?"
|
||||
content="此操作将不可逆"
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
import {
|
||||
Form,
|
||||
Modal,
|
||||
Notification,
|
||||
Collapse,
|
||||
Select,
|
||||
Avatar,
|
||||
} from '@douyinfe/semi-ui'
|
||||
import { FormApi } from '@douyinfe/semi-ui/lib/es/form'
|
||||
import React, { useRef } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { LiveStreamerEntity } from '../lib/api-streamer'
|
||||
import { SupportedPlatforms } from '@/app/ui/plugins'
|
||||
import { useBiliUsers } from '../lib/use-streamers'
|
||||
|
||||
type PluginProps = {
|
||||
entity?: LiveStreamerEntity
|
||||
list?: { value: number; label: React.ReactNode }[]
|
||||
initValues?: any
|
||||
}
|
||||
|
||||
type TemplateModalProps = {
|
||||
visible?: boolean
|
||||
entity?: LiveStreamerEntity
|
||||
children?: React.ReactNode
|
||||
onOk: (e: any) => Promise<void>
|
||||
}
|
||||
|
||||
const removeCircularReferences = (obj: any, seen = new WeakSet()): any => {
|
||||
// 处理 null 或非对象类型
|
||||
if (obj === null || typeof obj !== 'object') return obj
|
||||
|
||||
// 检测循环引用
|
||||
if (seen.has(obj)) return '[Circular Reference]'
|
||||
seen.add(obj)
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map((item: any) => removeCircularReferences(item, seen))
|
||||
}
|
||||
|
||||
const result: Record<string, any> = {}
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
// 跳过 React 相关的属性
|
||||
if (key === '_context' || key === 'Provider' || key === 'Consumer') continue
|
||||
result[key] = removeCircularReferences(value, seen)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const OverrideModal: React.FC<TemplateModalProps> = ({ children, entity, onOk }) => {
|
||||
const [isOpen, setOpen] = useState(false)
|
||||
|
||||
const toggle = () => {
|
||||
setOpen(!isOpen)
|
||||
}
|
||||
|
||||
const platformSetting = () => {
|
||||
for (const [pattern, Plugin] of Object.entries(SupportedPlatforms)) {
|
||||
if (entity?.url.match(new RegExp(pattern))) {
|
||||
// console.log('匹配到平台:', pattern)
|
||||
return Plugin as React.ComponentType<PluginProps>
|
||||
}
|
||||
}
|
||||
// console.log('未匹配到平台')
|
||||
return null
|
||||
}
|
||||
|
||||
const api = useRef<FormApi>()
|
||||
|
||||
const { biliUsers } = useBiliUsers()
|
||||
const list = biliUsers?.map(item => {
|
||||
return {
|
||||
value: item.value,
|
||||
label: (
|
||||
<>
|
||||
<Avatar size="extra-small" src={item.face} />
|
||||
<span style={{ marginLeft: 8 }}>{item.name}</span>
|
||||
</>
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
const [visible, setVisible] = useState(false)
|
||||
const showDialog = () => {
|
||||
setVisible(true)
|
||||
}
|
||||
const handleOk = async () => {
|
||||
let values = await api.current?.validate()
|
||||
// 从 LiveStreamerEntity 接口定义中获取所有字段
|
||||
const entityFields = new Set([
|
||||
'id',
|
||||
'url',
|
||||
'remark',
|
||||
'filename',
|
||||
'split_time',
|
||||
'split_size',
|
||||
'upload_id',
|
||||
'status',
|
||||
'format',
|
||||
'time_range',
|
||||
'preprocessor',
|
||||
'segment_processor',
|
||||
'downloaded_processor',
|
||||
'postprocessor',
|
||||
'opt_args',
|
||||
'override',
|
||||
])
|
||||
|
||||
if (values) {
|
||||
// 处理 override_text
|
||||
if (values.override_text) {
|
||||
try {
|
||||
values.override = JSON.parse(values.override_text)
|
||||
delete values.override_text
|
||||
} catch (e) {
|
||||
Notification.error({
|
||||
title: '错误',
|
||||
content: '配置格式不正确,请检查 JSON 格式',
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const overrideConfig = { ...(values.override || {}) }
|
||||
Object.keys(values).forEach(key => {
|
||||
console.log(key, values[key])
|
||||
if (!entityFields.has(key)) {
|
||||
if (values[key] != undefined && values[key] != null) {
|
||||
overrideConfig[key] = values[key]
|
||||
}
|
||||
delete values[key]
|
||||
}
|
||||
})
|
||||
values.override = overrideConfig
|
||||
|
||||
// 处理循环引用
|
||||
const cleanValues = removeCircularReferences(values)
|
||||
await onOk(cleanValues)
|
||||
setVisible(false)
|
||||
return
|
||||
}
|
||||
setVisible(false)
|
||||
}
|
||||
const handleCancel = () => {
|
||||
setVisible(false)
|
||||
}
|
||||
|
||||
const childrenWithProps = React.Children.map(children, child => {
|
||||
if (React.isValidElement<any>(child)) {
|
||||
return React.cloneElement(child, {
|
||||
onClick: () => {
|
||||
showDialog()
|
||||
child.props.onClick?.()
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const downloadSettings = (
|
||||
<Collapse.Panel header="下载设置" itemKey="download">
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
请到
|
||||
<a href="/dashboard" style={{ textDecoration: 'none', color: 'var(--semi-color-primary)' }}>
|
||||
空间配置
|
||||
</a>
|
||||
查看选项说明
|
||||
</div>
|
||||
<Form.Select
|
||||
label="下载插件(downloader)"
|
||||
field="downloader"
|
||||
placeholder="stream-gears(默认)"
|
||||
style={{ width: '100%' }}
|
||||
fieldStyle={{
|
||||
alignSelf: 'stretch',
|
||||
padding: 0,
|
||||
}}
|
||||
showClear={true}
|
||||
>
|
||||
<Select.Option value="streamlink">streamlink(hls多线程下载)</Select.Option>
|
||||
<Select.Option value="ffmpeg">ffmpeg</Select.Option>
|
||||
<Select.Option value="stream-gears">stream-gears(默认)</Select.Option>
|
||||
<Select.Option value="sync-downloader">sync-downloader(边录边传)</Select.Option>
|
||||
</Form.Select>
|
||||
|
||||
<Form.InputNumber
|
||||
label="视频分段大小(file_size)"
|
||||
field="file_size"
|
||||
placeholder=""
|
||||
suffix={'Byte'}
|
||||
style={{ width: '100%' }}
|
||||
fieldStyle={{
|
||||
alignSelf: 'stretch',
|
||||
padding: 0,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Form.Input
|
||||
field="segment_time"
|
||||
label="视频分段时长(segment_time)"
|
||||
placeholder="01:00:00"
|
||||
style={{ width: '100%' }}
|
||||
fieldStyle={{
|
||||
alignSelf: 'stretch',
|
||||
padding: 0,
|
||||
}}
|
||||
showClear={true}
|
||||
rules={[
|
||||
{
|
||||
pattern: /^[^:]*$/,
|
||||
message: '请使用英文冒号',
|
||||
},
|
||||
{
|
||||
pattern: /^[0-9:]*$/,
|
||||
message: '只接受数字和英文冒号',
|
||||
},
|
||||
{
|
||||
pattern: /^[0-9]{2,4}:[0-5][0-9]:[0-5][0-9]$/,
|
||||
message: '分或秒不符合规范',
|
||||
},
|
||||
]}
|
||||
stopValidateWithError={true}
|
||||
/>
|
||||
|
||||
<Form.Input
|
||||
field="filename_prefix"
|
||||
label="文件名模板(filename_prefix)"
|
||||
placeholder="{streamer}%Y-%m-%dT%H_%M_%S"
|
||||
/>
|
||||
|
||||
<Form.InputNumber
|
||||
field="filtering_threshold"
|
||||
label="碎片过滤(filtering_threshold)"
|
||||
suffix={'MB'}
|
||||
style={{ width: '100%' }}
|
||||
fieldStyle={{
|
||||
alignSelf: 'stretch',
|
||||
padding: 0,
|
||||
}}
|
||||
showClear={true}
|
||||
/>
|
||||
</Collapse.Panel>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{childrenWithProps}
|
||||
<Modal
|
||||
title="配置覆写"
|
||||
visible={visible}
|
||||
onOk={handleOk}
|
||||
style={{ width: 600 }}
|
||||
onCancel={handleCancel}
|
||||
bodyStyle={{
|
||||
overflow: 'auto',
|
||||
maxHeight: 'calc(100vh - 320px)',
|
||||
paddingLeft: 10,
|
||||
paddingRight: 10,
|
||||
}}
|
||||
>
|
||||
<Form initValues={entity} getFormApi={formApi => (api.current = formApi)}>
|
||||
<Form.TextArea
|
||||
field="override_text"
|
||||
label="配置覆写"
|
||||
placeholder="请输入 JSON 格式的配置"
|
||||
style={{ marginBottom: 12 }}
|
||||
initValue={entity?.override ? JSON.stringify(entity.override, null, 2) : ''}
|
||||
rules={[
|
||||
{ required: false },
|
||||
{
|
||||
validator: (rule, value) => {
|
||||
if (!value) return true
|
||||
try {
|
||||
JSON.parse(value)
|
||||
return true
|
||||
} catch (e) {
|
||||
return false
|
||||
}
|
||||
},
|
||||
message: '请输入有效的 JSON 格式',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Form.Section>
|
||||
<Collapse defaultActiveKey={['plugin']}>
|
||||
{downloadSettings}
|
||||
{(() => {
|
||||
const Plugin = platformSetting()
|
||||
return Plugin ? (
|
||||
<Plugin entity={entity} list={list} initValues={entity?.override} />
|
||||
) : null
|
||||
})()}
|
||||
</Collapse>
|
||||
</Form.Section>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default OverrideModal
|
||||
@@ -183,36 +183,7 @@ const TemplateFields: React.FC<FormFCChild<StudioEntity & { isDtime: boolean }>>
|
||||
setSelectedMinutes(Math.floor(minutes / 5) * 5)
|
||||
}
|
||||
}
|
||||
}, [formApi]);
|
||||
|
||||
// useEffect(() => {
|
||||
// const extraFieldsList = formApi.getValue('extra_fields_list') as Array<{key: string; value: string}>;
|
||||
// if (!extraFieldsList?.length) return;
|
||||
|
||||
// const extraFieldsObj = extraFieldsList.reduce((tempObj: Record<string, string | number>, item) => {
|
||||
// if (item?.key && item?.value !== undefined) {
|
||||
// tempObj[item.key] = !isNaN(Number(item.value)) ? Number(item.value) : item.value;
|
||||
// }
|
||||
// return tempObj;
|
||||
// }, {});
|
||||
// formApi.setValue('extra_fields', JSON.stringify(extraFieldsObj));
|
||||
// }, []);
|
||||
|
||||
// useEffect(() => {
|
||||
// const extraFields = formApi.getValue('extra_fields');
|
||||
// if (extraFields) {
|
||||
// try {
|
||||
// const parsedFields = JSON.parse(extraFields);
|
||||
// const fieldsList = Object.entries(parsedFields).map(([key, value]) => ({
|
||||
// key,
|
||||
// value: String(value)
|
||||
// }));
|
||||
// formApi.setValue('extra_fields_list', fieldsList);
|
||||
// } catch (e) {
|
||||
// console.error('解析 extra_fields 失败:', e);
|
||||
// }
|
||||
// }
|
||||
// }, []);
|
||||
}, [formApi])
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -344,68 +315,6 @@ const TemplateFields: React.FC<FormFCChild<StudioEntity & { isDtime: boolean }>>
|
||||
}
|
||||
]}
|
||||
/>
|
||||
{/* <ArrayField
|
||||
field='extra_fields_list'
|
||||
initValue={(() => {
|
||||
const extraFields = formApi.getValue('extra_fields');
|
||||
if (!extraFields) return [];
|
||||
try {
|
||||
const parsedFields = JSON.parse(extraFields);
|
||||
return Object.entries(parsedFields).map(([key, value]) => ({
|
||||
key,
|
||||
value: String(value)
|
||||
}));
|
||||
} catch (e) {
|
||||
console.error('解析 extra_fields 失败:', e);
|
||||
return [];
|
||||
}
|
||||
})()}
|
||||
>
|
||||
{({ add, arrayFields }) => (
|
||||
<React.Fragment>
|
||||
<Button onClick={add} icon={<IconPlusCircle />} theme='light'>Add new line</Button>
|
||||
{
|
||||
arrayFields.map(({ field, key, remove }, i) => (
|
||||
<div key={key} style={{ width: 1000, display: 'flex' }}>
|
||||
<Input
|
||||
field={`${field}.key`}
|
||||
label={`${field}.key`}
|
||||
style={{ width: 200, marginRight: 16 }}
|
||||
rules={[
|
||||
{
|
||||
validator: (rule, value) => {
|
||||
if (!value) return true;
|
||||
const currentFields = (formApi.getValue('extra_fields_list') || []) as Array<{key: string; value: string}>;
|
||||
const keyCount = currentFields.filter(
|
||||
(item: any, index: number) =>
|
||||
item.key === value &&
|
||||
`extra_fields_list.${index}.key` !== field + '.key'
|
||||
).length;
|
||||
if (keyCount > 1) {
|
||||
throw new Error('键名不能重复');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
]}
|
||||
/>
|
||||
<Input
|
||||
field={`${field}.value`}
|
||||
label={`${field}.value`}
|
||||
style={{ width: 200, marginRight: 16 }}
|
||||
/>
|
||||
<Button
|
||||
type='danger'
|
||||
theme='borderless'
|
||||
icon={<IconMinusCircle />}
|
||||
onClick={remove}
|
||||
style={{ margin: 12 }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</React.Fragment>
|
||||
)}
|
||||
</ArrayField> */}
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', color: 'var(--semi-color-tertiary)' }}>
|
||||
<Switch
|
||||
|
||||
@@ -202,13 +202,6 @@ const TemplateModal: React.FC<TemplateModalProps> = ({ children, entity, onOk })
|
||||
|
||||
<Collapse keepDOM>
|
||||
<Collapse.Panel header="更多设置" itemKey="processors">
|
||||
<Form.Input
|
||||
field="filename_prefix"
|
||||
label={{ text: '文件名模板', optional: true }}
|
||||
// initValue='./video/%Y-%m-%d/%H_%M_%S{title}'
|
||||
placeholder="{streamer}%Y-%m-%dT%H_%M_%S"
|
||||
/>
|
||||
|
||||
<Form.Input
|
||||
field="time_range"
|
||||
extraText={
|
||||
|
||||
@@ -1,15 +1,25 @@
|
||||
'use client'
|
||||
import React from 'react'
|
||||
import { Form, Select, Collapse } from '@douyinfe/semi-ui'
|
||||
import React, { useEffect } from 'react'
|
||||
import { Form, Select, Collapse, useFormApi } from '@douyinfe/semi-ui'
|
||||
|
||||
type Props = {
|
||||
entity: any
|
||||
list: any
|
||||
initValues?: Record<string, any>
|
||||
}
|
||||
|
||||
const Bilibili: React.FC<Props> = props => {
|
||||
const entity = props.entity
|
||||
const list = props.list
|
||||
const { entity, list, initValues } = props
|
||||
const formApi = useFormApi()
|
||||
|
||||
useEffect(() => {
|
||||
if (initValues) {
|
||||
Object.entries(initValues).forEach(([key, value]) => {
|
||||
formApi.setValue(key, value)
|
||||
})
|
||||
}
|
||||
}, [initValues, formApi])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Collapse.Panel header="哔哩哔哩" itemKey="bilibili">
|
||||
|
||||
+20
-3
@@ -1,8 +1,25 @@
|
||||
'use client'
|
||||
import React from 'react'
|
||||
import { Form, Select, Collapse } from '@douyinfe/semi-ui'
|
||||
import React, { useEffect } from 'react'
|
||||
import { Form, Select, Collapse, useFormApi } from '@douyinfe/semi-ui'
|
||||
|
||||
type Props = {
|
||||
entity: any
|
||||
list: any
|
||||
initValues?: Record<string, any>
|
||||
}
|
||||
|
||||
const CC: React.FC<Props> = props => {
|
||||
const { entity, list, initValues } = props
|
||||
const formApi = useFormApi()
|
||||
|
||||
useEffect(() => {
|
||||
if (initValues) {
|
||||
Object.entries(initValues).forEach(([key, value]) => {
|
||||
formApi.setValue(key, value)
|
||||
})
|
||||
}
|
||||
}, [initValues, formApi])
|
||||
|
||||
const CC: React.FC = () => {
|
||||
return (
|
||||
<>
|
||||
<Collapse.Panel header="CC" itemKey="cc">
|
||||
|
||||
@@ -1,13 +1,25 @@
|
||||
'use client'
|
||||
import React from 'react'
|
||||
import { Form, Select, Collapse } from '@douyinfe/semi-ui'
|
||||
import React, { useEffect } from 'react'
|
||||
import { Form, Select, Collapse, useFormApi } from '@douyinfe/semi-ui'
|
||||
|
||||
type Props = {
|
||||
entity: any
|
||||
list: any
|
||||
initValues?: Record<string, any>
|
||||
}
|
||||
|
||||
const Douyin: React.FC<Props> = props => {
|
||||
const entity = props.entity
|
||||
const { entity, list, initValues } = props
|
||||
const formApi = useFormApi()
|
||||
|
||||
useEffect(() => {
|
||||
if (initValues) {
|
||||
Object.entries(initValues).forEach(([key, value]) => {
|
||||
formApi.setValue(key, value)
|
||||
})
|
||||
}
|
||||
}, [initValues, formApi])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Collapse.Panel header="抖音" itemKey="douyin">
|
||||
|
||||
@@ -1,8 +1,25 @@
|
||||
'use client'
|
||||
import React from 'react'
|
||||
import { Form, Select, Collapse } from '@douyinfe/semi-ui'
|
||||
import React, { useEffect } from 'react'
|
||||
import { Form, Select, Collapse, useFormApi } from '@douyinfe/semi-ui'
|
||||
|
||||
type Props = {
|
||||
entity: any
|
||||
list: any
|
||||
initValues?: Record<string, any>
|
||||
}
|
||||
|
||||
const Douyu: React.FC<Props> = props => {
|
||||
const { entity, list, initValues } = props
|
||||
const formApi = useFormApi()
|
||||
|
||||
useEffect(() => {
|
||||
if (initValues) {
|
||||
Object.entries(initValues).forEach(([key, value]) => {
|
||||
formApi.setValue(key, value)
|
||||
})
|
||||
}
|
||||
}, [initValues, formApi])
|
||||
|
||||
const Douyu: React.FC = () => {
|
||||
return (
|
||||
<>
|
||||
<Collapse.Panel header="斗鱼" itemKey="douyu">
|
||||
|
||||
+15
-3
@@ -1,13 +1,25 @@
|
||||
'use client'
|
||||
import React from 'react'
|
||||
import { Form, Select, Collapse } from '@douyinfe/semi-ui'
|
||||
import React, { useEffect } from 'react'
|
||||
import { Form, Select, Collapse, useFormApi } from '@douyinfe/semi-ui'
|
||||
|
||||
type Props = {
|
||||
entity: any
|
||||
list: any
|
||||
initValues?: Record<string, any>
|
||||
}
|
||||
|
||||
const Huya: React.FC<Props> = props => {
|
||||
const entity = props.entity
|
||||
const { entity, list, initValues } = props
|
||||
const formApi = useFormApi()
|
||||
|
||||
useEffect(() => {
|
||||
if (initValues) {
|
||||
Object.entries(initValues).forEach(([key, value]) => {
|
||||
formApi.setValue(key, value)
|
||||
})
|
||||
}
|
||||
}, [initValues, formApi])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Collapse.Panel header="虎牙" itemKey="huya">
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import Bilibili from './bilibili'
|
||||
import CC from './cc'
|
||||
import Cookie from './cookie'
|
||||
import Douyin from './douyin'
|
||||
import Douyu from './douyu'
|
||||
import Huya from './huya'
|
||||
import Kilakila from './kilakila'
|
||||
import Twitcasting from './twitcasting'
|
||||
import Twitch from './twitch'
|
||||
import Youtube from './youtube'
|
||||
|
||||
export {
|
||||
Bilibili,
|
||||
CC,
|
||||
Cookie,
|
||||
Douyin,
|
||||
Douyu,
|
||||
Huya,
|
||||
Kilakila,
|
||||
Twitcasting,
|
||||
Twitch,
|
||||
Youtube,
|
||||
SupportedPlatforms,
|
||||
}
|
||||
|
||||
// 导出所有插件
|
||||
const plugins = {
|
||||
Bilibili,
|
||||
CC,
|
||||
Cookie,
|
||||
Douyin,
|
||||
Douyu,
|
||||
Huya,
|
||||
Kilakila,
|
||||
Twitcasting,
|
||||
Twitch,
|
||||
Youtube,
|
||||
}
|
||||
|
||||
const SupportedPlatforms = {
|
||||
'https?:\/\/(b23\.tv|live\.bilibili\.com)': Bilibili,
|
||||
'https?:\/\/(cc\.163\.com)': CC,
|
||||
'https?:\/\/(?:(?:www|m|live|v)\.)?douyin\.com': Douyin,
|
||||
'https?:\/\/(?:(?:www|m)\.)?douyu\.com': Douyu,
|
||||
'https?:\/\/(?:(?:www|m)\.)?huya\.com': Huya,
|
||||
'https?:\/\/(live\.kilakila\.cn|www\.hongdoufm\.com)': Kilakila,
|
||||
'https?:\/\/twitcasting\.tv': Twitcasting,
|
||||
'https?:\/\/(?:(?:www|go|m)\.)?twitch\.tv': Twitch,
|
||||
'https?:\/\/(?:(?:www|m)\.)?youtube\.com': Youtube,
|
||||
}
|
||||
|
||||
export default plugins
|
||||
@@ -1,12 +1,25 @@
|
||||
'use client'
|
||||
import React from 'react'
|
||||
import { Form, Select, Collapse } from '@douyinfe/semi-ui'
|
||||
import React, { useEffect } from 'react'
|
||||
import { Form, Select, Collapse, useFormApi } from '@douyinfe/semi-ui'
|
||||
|
||||
type Props = {
|
||||
entity: any
|
||||
list: any
|
||||
initValues?: Record<string, any>
|
||||
}
|
||||
|
||||
const Kilakila: React.FC = () => {
|
||||
const Kilakila: React.FC<Props> = props => {
|
||||
const { entity, list, initValues } = props
|
||||
const formApi = useFormApi()
|
||||
|
||||
useEffect(() => {
|
||||
if (initValues) {
|
||||
Object.entries(initValues).forEach(([key, value]) => {
|
||||
formApi.setValue(key, value)
|
||||
})
|
||||
}
|
||||
}, [initValues, formApi])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Collapse.Panel header="克拉克拉" itemKey="kilakila">
|
||||
|
||||
@@ -1,8 +1,25 @@
|
||||
'use client'
|
||||
import React from 'react'
|
||||
import { Form, Collapse } from '@douyinfe/semi-ui'
|
||||
import React, { useEffect } from 'react'
|
||||
import { Form, Select, Collapse, useFormApi } from '@douyinfe/semi-ui'
|
||||
|
||||
type Props = {
|
||||
entity: any
|
||||
list: any
|
||||
initValues?: Record<string, any>
|
||||
}
|
||||
|
||||
const TwitCasting: React.FC<Props> = props => {
|
||||
const { entity, list, initValues } = props
|
||||
const formApi = useFormApi()
|
||||
|
||||
useEffect(() => {
|
||||
if (initValues) {
|
||||
Object.entries(initValues).forEach(([key, value]) => {
|
||||
formApi.setValue(key, value)
|
||||
})
|
||||
}
|
||||
}, [initValues, formApi])
|
||||
|
||||
const TwitCasting: React.FC = () => {
|
||||
return (
|
||||
<>
|
||||
<Collapse.Panel header="TwitCasting" itemKey="twitcasting">
|
||||
|
||||
@@ -1,13 +1,25 @@
|
||||
'use client'
|
||||
import React from 'react'
|
||||
import { Form, Select, Collapse } from '@douyinfe/semi-ui'
|
||||
import React, { useEffect } from 'react'
|
||||
import { Form, Select, Collapse, useFormApi } from '@douyinfe/semi-ui'
|
||||
|
||||
type Props = {
|
||||
entity: any
|
||||
list: any
|
||||
initValues?: Record<string, any>
|
||||
}
|
||||
|
||||
const Twitch: React.FC<Props> = props => {
|
||||
const entity = props.entity
|
||||
const { entity, list, initValues } = props
|
||||
const formApi = useFormApi()
|
||||
|
||||
useEffect(() => {
|
||||
if (initValues) {
|
||||
Object.entries(initValues).forEach(([key, value]) => {
|
||||
formApi.setValue(key, value)
|
||||
})
|
||||
}
|
||||
}, [initValues, formApi])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Collapse.Panel header="Twitch" itemKey="twitch">
|
||||
|
||||
@@ -1,13 +1,25 @@
|
||||
'use client'
|
||||
import React from 'react'
|
||||
import { Form, Select, Collapse } from '@douyinfe/semi-ui'
|
||||
import React, { useEffect } from 'react'
|
||||
import { Form, Select, Collapse, useFormApi } from '@douyinfe/semi-ui'
|
||||
|
||||
type Props = {
|
||||
entity: any
|
||||
list: any
|
||||
initValues?: Record<string, any>
|
||||
}
|
||||
|
||||
const YouTube: React.FC<Props> = props => {
|
||||
const entity = props.entity
|
||||
const { entity, list, initValues } = props
|
||||
const formApi = useFormApi()
|
||||
|
||||
useEffect(() => {
|
||||
if (initValues) {
|
||||
Object.entries(initValues).forEach(([key, value]) => {
|
||||
formApi.setValue(key, value)
|
||||
})
|
||||
}
|
||||
}, [initValues, formApi])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Collapse.Panel header="YouTube" itemKey="youtube">
|
||||
|
||||
@@ -147,6 +147,7 @@ class LiveStreamers(BaseModel):
|
||||
upload_streamers_id = mapped_column(ForeignKey("uploadstreamers.id", ondelete="CASCADE"), nullable=True)
|
||||
uploadstreamers: Mapped[UploadStreamers] = relationship(back_populates="livestreamers")
|
||||
format: Mapped[str] = mapped_column(nullable=True) # 视频格式
|
||||
override = mapped_column(JSON(), nullable=True) # 覆写配置
|
||||
preprocessor = mapped_column(JSON(), nullable=True) # 开始下载直播时触发
|
||||
segment_processor = mapped_column(JSON(), nullable=True) # 分段时触发
|
||||
downloaded_processor = mapped_column(JSON(), nullable=True) # 准备上传直播时触发
|
||||
|
||||
+16
-1
@@ -9,16 +9,31 @@ logger = logging.getLogger('biliup')
|
||||
|
||||
def download(fname, url, **kwargs):
|
||||
pg = None
|
||||
override = kwargs.get('override', {})
|
||||
for plugin in Plugin.download_plugins:
|
||||
if re.match(plugin.VALID_URL_BASE, url):
|
||||
pg = plugin(fname, url)
|
||||
for k in pg.__dict__:
|
||||
if kwargs.get(k):
|
||||
pg.__dict__[k] = kwargs.get(k)
|
||||
break
|
||||
if not pg:
|
||||
pg = general.__plugin__(fname, url)
|
||||
logger.warning(f'Not found plugin for {fname} -> {url} This may cause problems')
|
||||
if override:
|
||||
if pg.__class__ in Plugin.download_plugins:
|
||||
# 单独适配的plugin允许全覆写
|
||||
pg.__dict__.update(override)
|
||||
if override.get('user'):
|
||||
pg.__dict__.pop('user')
|
||||
pg.__dict__.update(override.get('user'))
|
||||
else:
|
||||
# print("Override General plugin")
|
||||
# 通用插件只允许覆写插件存在的值
|
||||
for k in pg.__dict__:
|
||||
if k in override:
|
||||
pg.__dict__[k] = override[k]
|
||||
# print(override)
|
||||
del override
|
||||
return pg.start()
|
||||
|
||||
|
||||
|
||||
@@ -343,6 +343,8 @@ class DownloadBase(ABC):
|
||||
ret = self.run()
|
||||
except Exception:
|
||||
logger.warning(f"{self.plugin_msg}: 下载失败", exc_info=True)
|
||||
# TODO: 重试等待时间
|
||||
time.sleep(3)
|
||||
finally:
|
||||
self.close()
|
||||
|
||||
|
||||
+133
-128
@@ -14,7 +14,7 @@ from ..engine.download import DownloadBase
|
||||
OFFICIAL_API = "https://api.live.bilibili.com"
|
||||
STREAM_NAME_REGEXP = r"/live-bvc/\d+/(live_[^/\.]+)"
|
||||
|
||||
@Plugin.download(regexp=r'(?:https?://)?(b23\.tv|live\.bilibili\.com)')
|
||||
@Plugin.download(regexp=r'https?://(b23\.tv|live\.bilibili\.com)')
|
||||
class Bililive(DownloadBase):
|
||||
def __init__(self, fname, url, suffix='flv'):
|
||||
super().__init__(fname, url, suffix)
|
||||
@@ -23,19 +23,22 @@ class Bililive(DownloadBase):
|
||||
self.bilibili_danmaku_detail = config.get('bilibili_danmaku_detail', False)
|
||||
self.__real_room_id = None
|
||||
self.__login_mid = 0
|
||||
if config.get('user', {}).get('bili_cookie'):
|
||||
self.fake_headers['cookie'] = config.get('user', {}).get('bili_cookie')
|
||||
if config.get('user', {}).get('bili_cookie_file'):
|
||||
cookie_file_name = config.get('user', {}).get('bili_cookie_file')
|
||||
try:
|
||||
with open(cookie_file_name, encoding='utf-8') as stream:
|
||||
cookies = json.load(stream)["cookie_info"]["cookies"]
|
||||
cookies_str = ''
|
||||
for i in cookies:
|
||||
cookies_str += f"{i['name']}={i['value']};"
|
||||
self.fake_headers['cookie'] = cookies_str
|
||||
except Exception:
|
||||
logger.exception("load_cookies error")
|
||||
self.bili_cookie = config.get('user', {}).get('bili_cookie')
|
||||
self.bili_cookie_file = config.get('user', {}).get('bili_cookie_file')
|
||||
self.bili_qn = int(config.get('bili_qn', 20000))
|
||||
self.bili_protocol = config.get('bili_protocol', 'stream')
|
||||
self.bili_cdn = config.get('bili_cdn', [])
|
||||
self.bili_hls_timeout = config.get('bili_hls_transcode_timeout', 60)
|
||||
self.bili_api_list = [
|
||||
normalize_url(config.get('bili_liveapi', OFFICIAL_API)).rstrip('/'),
|
||||
normalize_url(config.get('bili_fallback_api', OFFICIAL_API)).rstrip('/'),
|
||||
]
|
||||
self.bili_force_source = config.get('bili_force_source', False)
|
||||
self.bili_anonymous_origin = config.get('bili_anonymous_origin', False)
|
||||
self.bili_ov2cn = config.get('bili_ov2cn', False)
|
||||
self.bili_normalize_cn204 = config.get('bili_normalize_cn204', False)
|
||||
self.cn01_sids = config.get('bili_replace_cn01', [])
|
||||
self.bili_cdn_fallback = config.get('bili_cdn_fallback', False)
|
||||
|
||||
async def acheck_stream(self, is_check=False):
|
||||
|
||||
@@ -53,7 +56,18 @@ class Bililive(DownloadBase):
|
||||
return False
|
||||
|
||||
room_id = match1(self.url, r'bilibili.com/(\d+)')
|
||||
quality_number = int(config.get('bili_qn', 10000))
|
||||
if self.bili_cookie:
|
||||
self.fake_headers['cookie'] = self.bili_cookie
|
||||
if self.bili_cookie_file:
|
||||
try:
|
||||
with open(self.bili_cookie_file, encoding='utf-8') as stream:
|
||||
cookies = json.load(stream)["cookie_info"]["cookies"]
|
||||
cookies_str = ''
|
||||
for i in cookies:
|
||||
cookies_str += f"{i['name']}={i['value']};"
|
||||
self.fake_headers['cookie'] = cookies_str
|
||||
except Exception:
|
||||
logger.exception("load_cookies error")
|
||||
self.fake_headers['referer'] = self.url
|
||||
|
||||
# 获取直播状态与房间标题
|
||||
@@ -93,7 +107,7 @@ class Bililive(DownloadBase):
|
||||
# 复用原画 m3u8 流
|
||||
if self.raw_stream_url is not None \
|
||||
and ".m3u8" in self.raw_stream_url \
|
||||
and quality_number >= 10000 \
|
||||
and self.bili_qn >= 10000 \
|
||||
and not is_new_live:
|
||||
url = await self.acheck_url_healthy(self.raw_stream_url)
|
||||
if url is not None:
|
||||
@@ -102,36 +116,26 @@ class Bililive(DownloadBase):
|
||||
else:
|
||||
self.raw_stream_url = None
|
||||
|
||||
protocol = config.get('bili_protocol', 'stream')
|
||||
perf_cdn = config.get('bili_perfCDN')
|
||||
perf_cdn = config.get('bili_cdn', perf_cdn.split(',') if isinstance(perf_cdn, str) else [])
|
||||
self.__hls_timeout = config.get('bili_hls_transcode_timeout', 60)
|
||||
|
||||
normalize_url = lambda a: a if a.startswith(('http://', 'https://')) else 'http://' + a
|
||||
self.__api_list = [
|
||||
normalize_url(config.get('bili_liveapi', OFFICIAL_API)).rstrip('/'),
|
||||
normalize_url(config.get('bili_fallback_api', OFFICIAL_API)).rstrip('/'),
|
||||
]
|
||||
|
||||
stream_urls = await self.aget_stream(quality_number, protocol, special_type)
|
||||
stream_urls = await self.aget_stream(self.bili_qn, self.bili_protocol, special_type)
|
||||
if not stream_urls:
|
||||
if protocol == 'hls_fmp4':
|
||||
if int(time.time()) - live_start_time <= self.__hls_timeout:
|
||||
if self.bili_protocol == 'hls_fmp4':
|
||||
if int(time.time()) - live_start_time <= self.bili_hls_timeout:
|
||||
logger.warning(f"{self.plugin_msg}: 暂未提供 hls_fmp4 流,等待下一次检测")
|
||||
return False
|
||||
else:
|
||||
# 回退首个可用格式
|
||||
stream_urls = await self.aget_stream(quality_number, 'stream', special_type)
|
||||
stream_urls = await self.aget_stream(self.bili_qn, 'stream', special_type)
|
||||
else:
|
||||
logger.error(f"{self.plugin_msg}: 获取{protocol}流失败")
|
||||
logger.error(f"{self.plugin_msg}: 获取{self.bili_protocol}流失败")
|
||||
return False
|
||||
|
||||
target_quality_stream = stream_urls.get(
|
||||
quality_number, next(iter(stream_urls.values()))
|
||||
str(self.bili_qn), next(iter(stream_urls.values()))
|
||||
)
|
||||
stream_url = {}
|
||||
if perf_cdn is not None:
|
||||
for cdn in perf_cdn:
|
||||
if self.bili_cdn is not None:
|
||||
for cdn in self.bili_cdn:
|
||||
stream_info = target_quality_stream.get(cdn)
|
||||
if stream_info is not None:
|
||||
current_cdn = cdn
|
||||
@@ -142,15 +146,15 @@ class Bililive(DownloadBase):
|
||||
stream_url = stream_info['url']
|
||||
logger.debug(f"{self.plugin_msg}: 使用 {current_cdn} 流")
|
||||
|
||||
self.raw_stream_url = normalize_cn204(
|
||||
self.raw_stream_url = self.normalize_cn204(
|
||||
f"{stream_url['host']}{stream_url['base_url']}{stream_url['extra']}"
|
||||
)
|
||||
|
||||
# 替换 cn-gotcha01 节点
|
||||
cn01_sids = config.get('bili_replace_cn01', [])
|
||||
if cn01_sids and "cn-gotcha01" in current_cdn:
|
||||
if isinstance(cn01_sids, str): cn01_sids = cn01_sids.split(',')
|
||||
for sid in cn01_sids:
|
||||
if self.cn01_sids and "cn-gotcha01" in current_cdn:
|
||||
if isinstance(self.cn01_sids, str):
|
||||
self.cn01_sids = self.cn01_sids.split(',')
|
||||
for sid in self.cn01_sids:
|
||||
new_url = f"https://{sid}.bilivideo.com{stream_url['base_url']}{stream_url['extra']}"
|
||||
new_url = await self.acheck_url_healthy(new_url)
|
||||
if new_url is not None:
|
||||
@@ -160,13 +164,13 @@ class Bililive(DownloadBase):
|
||||
logger.debug(f"{self.plugin_msg}: {sid} is not available")
|
||||
|
||||
# 强制原画
|
||||
if quality_number <= 10000 and config.get('bili_force_source'):
|
||||
if self.bili_qn <= 10000 and self.bili_force_source:
|
||||
# 不处理 qn 20000
|
||||
if stream_info['suffix'] not in {'origin', 'uhd', 'maxhevc'}:
|
||||
__base_url = stream_url['base_url'].replace(f"_{stream_info['suffix']}", "")
|
||||
__sk = match1(stream_url['extra'], r'sk=([^&]+)')
|
||||
__extra = stream_url['extra'].replace(__sk, __sk[:32])
|
||||
__url = normalize_cn204(f"{stream_url['host']}{__base_url}{__extra}")
|
||||
__url = self.normalize_cn204(f"{stream_url['host']}{__base_url}{__extra}")
|
||||
if (await self.acheck_url_healthy(__url)) is not None:
|
||||
self.raw_stream_url = __url
|
||||
logger.info(f"{self.plugin_msg}: force_source 处理 {current_cdn} 成功 {stream_info['stream_name']}")
|
||||
@@ -175,13 +179,13 @@ class Bililive(DownloadBase):
|
||||
f"{self.plugin_msg}: force_source 处理 {current_cdn} 失败 {stream_info['stream_name']}"
|
||||
)
|
||||
|
||||
|
||||
if config.get('bili_cdn_fallback'):
|
||||
# 回退
|
||||
if self.bili_cdn_fallback:
|
||||
__url = await self.acheck_url_healthy(self.raw_stream_url)
|
||||
if __url is None:
|
||||
for cdn, stream_info in target_quality_stream.items():
|
||||
stream_url = stream_info['url']
|
||||
__fallback_url = normalize_cn204(
|
||||
__fallback_url = self.normalize_cn204(
|
||||
f"{stream_url['host']}{stream_url['base_url']}{stream_url['extra']}"
|
||||
)
|
||||
try:
|
||||
@@ -256,7 +260,7 @@ class Bililive(DownloadBase):
|
||||
full_url, params=params, headers=self.fake_headers
|
||||
)
|
||||
if m3u8_res.status_code == 200 and m3u8_res.text.startswith("#EXTM3U"):
|
||||
return parse_master_m3u8(m3u8_res.text)
|
||||
return self.parse_master_m3u8(m3u8_res.text)
|
||||
except Exception as e:
|
||||
logger.error(f"{self.plugin_msg}: {api} 获取 m3u8 失败 -> {e}", exc_info=True)
|
||||
return {}
|
||||
@@ -269,14 +273,14 @@ class Bililive(DownloadBase):
|
||||
:return: 流信息
|
||||
"""
|
||||
stream_urls = {}
|
||||
for api in self.__api_list:
|
||||
for api in self.bili_api_list:
|
||||
play_info = await self.get_play_info(api, qn)
|
||||
if not play_info or check_areablock(play_info):
|
||||
# logger.error(f"{self.plugin_msg}: {api} 返回内容错误: {play_info}")
|
||||
continue
|
||||
streams = play_info['playurl_info']['playurl']['stream']
|
||||
if protocol == 'hls_fmp4':
|
||||
if config.get('bili_anonymous_origin'):
|
||||
if self.bili_anonymous_origin:
|
||||
if special_type in play_info['all_special_types'] and not self.__login_mid:
|
||||
logger.warn(f"{self.plugin_msg}: 特殊直播{special_type}")
|
||||
else:
|
||||
@@ -287,14 +291,14 @@ class Bililive(DownloadBase):
|
||||
stream = streams[1] if len(streams) > 1 else streams[0]
|
||||
for format in stream['format']:
|
||||
if format['format_name'] == 'fmp4':
|
||||
stream_urls = parse_stream_url(format['codec'][0])
|
||||
stream_urls = self.parse_stream_url(format['codec'][0])
|
||||
# fmp4 可能没有原画
|
||||
if qn == 10000 and qn in stream_urls.keys():
|
||||
break
|
||||
else:
|
||||
stream_urls = {}
|
||||
else:
|
||||
stream_urls = parse_stream_url(streams[0]['format'][0]['codec'][0])
|
||||
stream_urls = self.parse_stream_url(streams[0]['format'][0]['codec'][0])
|
||||
if stream_urls:
|
||||
break
|
||||
# 空字典照常返回,重试交给上层方法处理
|
||||
@@ -317,6 +321,84 @@ class Bililive(DownloadBase):
|
||||
logger.error(f"{self.plugin_msg}: 登录态校验失败 {e}")
|
||||
return 0
|
||||
|
||||
def normalize_cn204(self, url: str) -> str:
|
||||
if self.bili_normalize_cn204 and "cn-gotcha204" in url:
|
||||
return re.sub(r"(?<=cn-gotcha204)-[1-4]", "", url, 1)
|
||||
return url
|
||||
|
||||
def parse_stream_url(self, *args) -> dict:
|
||||
suffix_regexp = r'suffix=([^&]+)'
|
||||
if isinstance(args[0], str):
|
||||
url = args[0]
|
||||
host = "https://" + match1(url, r'https?://([^/]+)')
|
||||
stream_url = {
|
||||
'host': host if not self.bili_ov2cn else host.replace("ov-", "cn-"),
|
||||
'base_url': url.split("?")[0].split(host)[1] + "?",
|
||||
'extra': url.split("?")[1]
|
||||
}
|
||||
return {
|
||||
'url': stream_url,
|
||||
'stream_name': match1(url, STREAM_NAME_REGEXP),
|
||||
'suffix': match1(url, suffix_regexp)
|
||||
}
|
||||
elif isinstance(args[0], dict):
|
||||
streams = {}
|
||||
current_qn = args[0]['current_qn']
|
||||
streams.setdefault(current_qn, {})
|
||||
base_url = args[0]['base_url']
|
||||
for info in args[0]['url_info']:
|
||||
cdn_name = match1(info['extra'], r'cdn=([^&]+)')
|
||||
stream_url = {
|
||||
'host': info['host'] if not self.bili_ov2cn else info['host'].replace("ov-", "cn-"),
|
||||
'base_url': base_url,
|
||||
'extra': info['extra']
|
||||
}
|
||||
streams[current_qn].setdefault(cdn_name, {
|
||||
'url': stream_url,
|
||||
'stream_name': match1(base_url, STREAM_NAME_REGEXP),
|
||||
'suffix': match1(info['extra'], suffix_regexp)
|
||||
})
|
||||
return streams
|
||||
|
||||
|
||||
def parse_master_m3u8(self, m3u8_content: str) -> dict:
|
||||
"""
|
||||
Returns:
|
||||
{
|
||||
"qn值": {
|
||||
"cdn名称": {
|
||||
"url": parsed_stream_url,
|
||||
"stream_name": "流名称",
|
||||
"suffix": "二压后缀"
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
lines = m3u8_content.strip().splitlines()
|
||||
current_qn = None
|
||||
result = {}
|
||||
|
||||
if not lines[0].startswith('#EXTM3U'):
|
||||
raise ValueError('Invalid m3u8 file')
|
||||
|
||||
for line in lines:
|
||||
if line.startswith('#EXT-X-STREAM-INF:'):
|
||||
codec = match1(line, r'CODECS="([^"]+)"')
|
||||
current_qn = match1(line, r'BILI-QN=(\d+)')
|
||||
|
||||
if codec and current_qn:
|
||||
if 'avc' in codec.lower():
|
||||
result.setdefault(current_qn, {})
|
||||
else:
|
||||
current_qn = None
|
||||
|
||||
elif line.startswith('http') and current_qn is not None:
|
||||
cdn_name = match1(line, r'cdn=([^&]+)')
|
||||
if cdn_name:
|
||||
result[current_qn].setdefault(cdn_name, self.parse_stream_url(line))
|
||||
|
||||
return dict(sorted(result.items(), key=lambda x: int(x[0]), reverse=True))
|
||||
|
||||
# Copy from room-player.js
|
||||
def check_areablock(data):
|
||||
'''
|
||||
@@ -328,82 +410,5 @@ def check_areablock(data):
|
||||
return True
|
||||
return False
|
||||
|
||||
def normalize_cn204(url: str) -> str:
|
||||
if config.get('bili_normalize_cn204') and "cn-gotcha204" in url:
|
||||
return re.sub(r"(?<=cn-gotcha204)-[1-4]", "", url, 1)
|
||||
return url
|
||||
|
||||
|
||||
def parse_stream_url(*args) -> dict:
|
||||
ov2cn = config.get('bili_ov2cn', False)
|
||||
suffix_regexp = r'suffix=([^&]+)'
|
||||
if isinstance(args[0], str):
|
||||
url = args[0]
|
||||
host = "https://" + match1(url, r'https?://([^/]+)')
|
||||
stream_url = {
|
||||
'host': host if not ov2cn else host.replace("ov-", "cn-"),
|
||||
'base_url': url.split("?")[0].split(host)[1] + "?",
|
||||
'extra': url.split("?")[1]
|
||||
}
|
||||
return {
|
||||
'url': stream_url,
|
||||
'stream_name': match1(url, STREAM_NAME_REGEXP),
|
||||
'suffix': match1(url, suffix_regexp)
|
||||
}
|
||||
elif isinstance(args[0], dict):
|
||||
streams = {}
|
||||
current_qn = args[0]['current_qn']
|
||||
streams.setdefault(current_qn, {})
|
||||
base_url = args[0]['base_url']
|
||||
for info in args[0]['url_info']:
|
||||
cdn_name = match1(info['extra'], r'cdn=([^&]+)')
|
||||
stream_url = {
|
||||
'host': info['host'] if not ov2cn else info['host'].replace("ov-", "cn-"),
|
||||
'base_url': base_url,
|
||||
'extra': info['extra']
|
||||
}
|
||||
streams[current_qn].setdefault(cdn_name, {
|
||||
'url': stream_url,
|
||||
'stream_name': match1(base_url, STREAM_NAME_REGEXP),
|
||||
'suffix': match1(info['extra'], suffix_regexp)
|
||||
})
|
||||
return streams
|
||||
|
||||
|
||||
def parse_master_m3u8(m3u8_content: str) -> dict:
|
||||
"""
|
||||
Returns:
|
||||
{
|
||||
"qn值": {
|
||||
"cdn名称": {
|
||||
"url": parsed_stream_url,
|
||||
"stream_name": "流名称",
|
||||
"suffix": "二压后缀"
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
lines = m3u8_content.strip().splitlines()
|
||||
current_qn = None
|
||||
result = {}
|
||||
|
||||
if not lines[0].startswith('#EXTM3U'):
|
||||
raise ValueError('Invalid m3u8 file')
|
||||
|
||||
for line in lines:
|
||||
if line.startswith('#EXT-X-STREAM-INF:'):
|
||||
codec = match1(line, r'CODECS="([^"]+)"')
|
||||
current_qn = match1(line, r'BILI-QN=(\d+)')
|
||||
|
||||
if codec and current_qn:
|
||||
if 'avc' in codec.lower():
|
||||
result.setdefault(current_qn, {})
|
||||
else:
|
||||
current_qn = None
|
||||
|
||||
elif line.startswith('http') and current_qn is not None:
|
||||
cdn_name = match1(line, r'cdn=([^&]+)')
|
||||
if cdn_name:
|
||||
result[current_qn].setdefault(cdn_name, parse_stream_url(line))
|
||||
|
||||
return dict(sorted(result.items(), key=lambda x: int(x[0]), reverse=True))
|
||||
def normalize_url(url: str) -> str:
|
||||
return url if url.startswith(('http://', 'https://')) else 'http://' + url
|
||||
@@ -6,10 +6,11 @@ from ..engine.decorators import Plugin
|
||||
from ..engine.download import DownloadBase
|
||||
|
||||
|
||||
@Plugin.download(regexp=r'(?:https?://)?cc\.163\.com')
|
||||
@Plugin.download(regexp=r'https?://cc\.163\.com')
|
||||
class CC(DownloadBase):
|
||||
def __init__(self, fname, url, suffix='flv'):
|
||||
super().__init__(fname, url, suffix)
|
||||
self.cc_protocol = config.get('cc_protocol', 'hls')
|
||||
|
||||
async def acheck_stream(self, is_check=False):
|
||||
rid = match1(self.url, r"(\d{4,})")
|
||||
@@ -33,7 +34,7 @@ class CC(DownloadBase):
|
||||
headers=self.fake_headers
|
||||
)).json()["data"][0]
|
||||
self.room_title = channel_info["title"]
|
||||
if config.get("cc_protocol", "hls") == "hls":
|
||||
if self.cc_protocol == "hls":
|
||||
self.raw_stream_url = channel_info["sharefile"]
|
||||
else:
|
||||
original = {"vbr": 0}
|
||||
|
||||
@@ -13,7 +13,7 @@ from ..engine.decorators import Plugin
|
||||
from ..engine.download import DownloadBase
|
||||
|
||||
|
||||
@Plugin.download(regexp=r'(?:https?://)?(?:(?:www|m|live|v)\.)?douyin\.com')
|
||||
@Plugin.download(regexp=r'https?://(?:(?:www|m|live|v)\.)?douyin\.com')
|
||||
class Douyin(DownloadBase):
|
||||
def __init__(self, fname, url, suffix='flv'):
|
||||
super().__init__(fname, url, suffix)
|
||||
@@ -21,6 +21,9 @@ class Douyin(DownloadBase):
|
||||
self.fake_headers['user-agent'] = DouyinUtils.DOUYIN_USER_AGENT
|
||||
self.fake_headers['referer'] = "https://live.douyin.com/"
|
||||
self.fake_headers['cookie'] = config.get('user', {}).get('douyin_cookie', '')
|
||||
self.douyin_quality = config.get('douyin_quality', 'origin')
|
||||
self.douyin_protocol = config.get('douyin_protocol', 'hls')
|
||||
self.douyin_double_screen = config.get('douyin_double_screen', False)
|
||||
self.__web_rid = None # 网页端房间号 或 抖音号
|
||||
self.__room_id = None # 单场直播的直播房间
|
||||
self.__sec_uid = None
|
||||
@@ -81,6 +84,8 @@ class Douyin(DownloadBase):
|
||||
_room_info = await self.get_web_room_info(self.__web_rid)
|
||||
if _room_info:
|
||||
if not _room_info['data'].get('user'):
|
||||
if _room_info['data'].get('prompts', '') == '直播已结束':
|
||||
return False
|
||||
# 可能是用户被封禁
|
||||
raise Exception(f"{str(_room_info)}")
|
||||
self.__sec_uid = _room_info['data']['user']['sec_uid']
|
||||
@@ -111,7 +116,7 @@ class Douyin(DownloadBase):
|
||||
|
||||
try:
|
||||
pull_data = room_info['stream_url']['live_core_sdk_data']['pull_data']
|
||||
if room_info['stream_url'].get('pull_datas') and config.get('douyin_double_screen', False):
|
||||
if room_info['stream_url'].get('pull_datas') and self.douyin_double_screen:
|
||||
pull_data = next(iter(room_info['stream_url']['pull_datas'].values()))
|
||||
stream_data = json.loads(pull_data['stream_data'])['data']
|
||||
except:
|
||||
@@ -120,7 +125,7 @@ class Douyin(DownloadBase):
|
||||
|
||||
# 原画origin 蓝光uhd 超清hd 高清sd 标清ld 流畅md 仅音频ao
|
||||
quality_items = ['origin', 'uhd', 'hd', 'sd', 'ld', 'md']
|
||||
quality = config.get('douyin_quality', 'origin')
|
||||
quality = self.douyin_quality
|
||||
if quality not in quality_items:
|
||||
quality = quality_items[0]
|
||||
try:
|
||||
@@ -151,7 +156,7 @@ class Douyin(DownloadBase):
|
||||
else:
|
||||
quality = optional_quality_items[optional_quality_index - 1]
|
||||
|
||||
protocol = 'hls' if config.get('douyin_protocol') == 'hls' else 'flv'
|
||||
protocol = 'hls' if self.douyin_protocol == 'hls' else 'flv'
|
||||
self.raw_stream_url = stream_data[quality]['main'][protocol].replace('http://', 'https://')
|
||||
self.room_title = room_info['title']
|
||||
except:
|
||||
|
||||
@@ -11,12 +11,15 @@ from ..engine.download import DownloadBase
|
||||
from ..plugins import logger, match1, random_user_agent
|
||||
|
||||
|
||||
@Plugin.download(regexp=r'(?:https?://)?(?:(?:www|m)\.)?douyu\.com')
|
||||
@Plugin.download(regexp=r'https?://(?:(?:www|m)\.)?douyu\.com')
|
||||
class Douyu(DownloadBase):
|
||||
def __init__(self, fname, url, suffix='flv'):
|
||||
super().__init__(fname, url, suffix)
|
||||
self.__room_id = match1(url, r'rid=(\d+)')
|
||||
self.douyu_danmaku = config.get('douyu_danmaku', False)
|
||||
self.douyu_disable_interactive_game = config.get('douyu_disable_interactive_game', False)
|
||||
self.douyu_cdn = config.get('douyu_cdn', 'tct-h5')
|
||||
self.douyu_rate = config.get('douyu_rate', 0)
|
||||
|
||||
async def acheck_stream(self, is_check=False):
|
||||
if len(self.url.split("douyu.com/")) < 2:
|
||||
@@ -44,7 +47,7 @@ class Douyu(DownloadBase):
|
||||
if room_info['videoLoop'] != 0:
|
||||
logger.debug(f"{self.plugin_msg}: 正在放录播")
|
||||
return False
|
||||
if config.get('douyu_disable_interactive_game', False):
|
||||
if self.douyu_disable_interactive_game:
|
||||
gift_info = (
|
||||
await client.get(f"https://www.douyu.com/api/interactive/web/v2/list?rid={self.__room_id}",
|
||||
headers=self.fake_headers)
|
||||
@@ -90,9 +93,8 @@ class Douyu(DownloadBase):
|
||||
logger.exception(f"{self.plugin_msg}: 获取签名参数异常")
|
||||
return False
|
||||
|
||||
params['cdn'] = config.get('douyucdn', 'tct-h5')
|
||||
params['cdn'] = config.get('douyu_cdn', params['cdn'])
|
||||
params['rate'] = config.get('douyu_rate', 0)
|
||||
params['cdn'] = self.douyu_cdn
|
||||
params['rate'] = int(self.douyu_rate)
|
||||
|
||||
try:
|
||||
live_data = await self.get_play_info(self.__room_id, params)
|
||||
|
||||
+20
-23
@@ -14,7 +14,7 @@ from ..engine.download import DownloadBase
|
||||
from ..plugins import logger, match1, random_user_agent
|
||||
|
||||
|
||||
@Plugin.download(regexp=r'(?:https?://)?(?:(?:www|m)\.)?huya\.com')
|
||||
@Plugin.download(regexp=r'https?://(?:(?:www|m)\.)?huya\.com')
|
||||
class Huya(DownloadBase):
|
||||
def __init__(self, fname, url, suffix='flv'):
|
||||
super().__init__(fname, url, suffix)
|
||||
@@ -22,12 +22,15 @@ class Huya(DownloadBase):
|
||||
self.fake_headers['cookie'] = config.get('user', {}).get('huya_cookie', '')
|
||||
self.__room_id = url.split('huya.com/')[1].split('?')[0]
|
||||
self.huya_danmaku = config.get('huya_danmaku', False)
|
||||
|
||||
self.huya_max_ratio = config.get('huya_max_ratio', 0)
|
||||
self.huya_cdn = config.get('huya_cdn', "").upper() # 不填写时使用主播的CDN优先级
|
||||
self.huya_protocol = 'Hls' if config.get('huya_protocol') == 'Hls' else 'Flv'
|
||||
self.huya_imgplus = config.get('huya_imgplus', True)
|
||||
self.huya_cdn_fallback = config.get('huya_cdn_fallback', False)
|
||||
self.huya_mobile_api = config.get('huya_mobile_api', False)
|
||||
|
||||
async def acheck_stream(self, is_check=False):
|
||||
try:
|
||||
if self.fake_headers.get('cookie'):
|
||||
await self.verify_cookie()
|
||||
if not self.__room_id.isdigit():
|
||||
self.__room_id = _get_real_rid(self.url)
|
||||
room_profile = await self.get_room_profile(use_api=True)
|
||||
@@ -53,9 +56,9 @@ class Huya(DownloadBase):
|
||||
if is_check:
|
||||
return True
|
||||
|
||||
huya_max_ratio = config.get('huya_max_ratio', 0)
|
||||
if huya_max_ratio:
|
||||
if self.huya_max_ratio:
|
||||
try:
|
||||
self.huya_max_ratio = int(self.huya_max_ratio)
|
||||
# 最大码率(不含hdr)
|
||||
# max_ratio = html_info['data'][0]['gameLiveInfo']['bitRate']
|
||||
max_ratio = room_profile['liveData']['bitRate']
|
||||
@@ -64,7 +67,7 @@ class Huya(DownloadBase):
|
||||
# 码率信息
|
||||
ratio_items = [r.get('iBitRate', 0) if r.get('iBitRate', 0) != 0 else max_ratio for r in live_rate_info]
|
||||
# 符合条件的码率
|
||||
ratio_in_items = [x for x in ratio_items if x <= huya_max_ratio]
|
||||
ratio_in_items = [x for x in ratio_items if x <= self.huya_max_ratio]
|
||||
# 录制码率
|
||||
if ratio_in_items:
|
||||
record_ratio = max(ratio_in_items)
|
||||
@@ -76,43 +79,37 @@ class Huya(DownloadBase):
|
||||
|
||||
is_xingxiu = room_profile['liveData']['gid'] == 1663
|
||||
|
||||
perf_cdn = config.get('huya_cdn', "").upper() # 不填写时使用主播的CDN优先级
|
||||
protocol = 'Hls' if config.get('huya_protocol') == 'Hls' else 'Flv'
|
||||
allow_imgplus = config.get('huya_imgplus', True)
|
||||
cdn_fallback = config.get('huya_cdn_fallback', False)
|
||||
use_api = config.get('huya_mobile_api', False)
|
||||
|
||||
try:
|
||||
stream_urls = await self.get_stream_urls(protocol, use_api, allow_imgplus, is_xingxiu)
|
||||
stream_urls = await self.get_stream_urls(self.huya_protocol, self.huya_mobile_api, self.huya_imgplus, is_xingxiu)
|
||||
except Exception as e:
|
||||
logger.error(f"{self.plugin_msg}: 没有可用的链接 {e}")
|
||||
return False
|
||||
|
||||
cdn_name = list(stream_urls.keys())
|
||||
if not perf_cdn or perf_cdn not in cdn_name:
|
||||
perf_cdn = cdn_name[0]
|
||||
if not self.huya_cdn or self.huya_cdn not in cdn_name:
|
||||
self.huya_cdn = cdn_name[0]
|
||||
|
||||
# 虎牙直播流只允许连接一次
|
||||
if cdn_fallback:
|
||||
_url = await self.acheck_url_healthy(stream_urls[perf_cdn])
|
||||
if self.huya_cdn_fallback:
|
||||
_url = await self.acheck_url_healthy(stream_urls[self.huya_cdn])
|
||||
if _url is None:
|
||||
logger.info(f"{self.plugin_msg}: cdn_fallback 顺序尝试 {cdn_name}")
|
||||
for cdn in cdn_name:
|
||||
logger.info(f"{self.plugin_msg}: cdn_fallback-{cdn}")
|
||||
if (await self.acheck_url_healthy(stream_urls[cdn])) is None:
|
||||
continue
|
||||
perf_cdn = cdn
|
||||
logger.info(f"{self.plugin_msg}: cdn_fallback 回退到 {perf_cdn}")
|
||||
self.huya_cdn = cdn
|
||||
logger.info(f"{self.plugin_msg}: cdn_fallback 回退到 {self.huya_cdn}")
|
||||
break
|
||||
else:
|
||||
logger.error(f"{self.plugin_msg}: cdn_fallback 所有链接无法使用")
|
||||
return False
|
||||
stream_urls = await self.get_stream_urls(protocol, use_api, allow_imgplus, is_xingxiu)
|
||||
stream_urls = await self.get_stream_urls(self.huya_protocol, self.huya_mobile_api, self.huya_imgplus, is_xingxiu)
|
||||
|
||||
self.room_title = room_profile['liveData']['introduction']
|
||||
self.raw_stream_url = stream_urls[perf_cdn]
|
||||
self.raw_stream_url = stream_urls[self.huya_cdn]
|
||||
|
||||
if huya_max_ratio and record_ratio != max_ratio:
|
||||
if self.huya_max_ratio and record_ratio != max_ratio:
|
||||
self.raw_stream_url += f"&ratio={record_ratio}"
|
||||
return True
|
||||
|
||||
|
||||
@@ -7,10 +7,11 @@ from ..engine.decorators import Plugin
|
||||
from ..engine.download import DownloadBase
|
||||
|
||||
|
||||
@Plugin.download(regexp=r'(?:https?://)?(live\.kilakila\.cn|www\.hongdoufm\.com)')
|
||||
@Plugin.download(regexp=r'https?://(live\.kilakila\.cn|www\.hongdoufm\.com)')
|
||||
class Kilakila(DownloadBase):
|
||||
def __init__(self, fname, url, suffix='flv'):
|
||||
self._room_id: str = match1(url, r'(\d+)')
|
||||
self.kila_protocol = config.get('kila_protocol', 'hls')
|
||||
super().__init__(fname, url, suffix)
|
||||
|
||||
async def acheck_stream(self, is_check=False):
|
||||
@@ -55,7 +56,7 @@ class Kilakila(DownloadBase):
|
||||
# self.background_pic_url = r['b']['defaultBackgroundPicUrl']
|
||||
self.room_title = r['b']['title']
|
||||
self.live_cover_url = r['b']['backPic']
|
||||
if config.get('kila_protocol') == 'flv':
|
||||
if self.kila_protocol == 'flv':
|
||||
self.raw_stream_url = r['b']['flvPlayUrl']
|
||||
else:
|
||||
self.raw_stream_url = r['b']['hlsPlayUrl']
|
||||
|
||||
@@ -16,7 +16,7 @@ from . import logger
|
||||
from ..engine.decorators import Plugin
|
||||
from ..engine.download import DownloadBase, BatchCheck
|
||||
|
||||
VALID_URL_BASE = r'(?:https?://)?(?:(?:www|go|m)\.)?twitch\.tv/(?P<id>[0-9_a-zA-Z]+)'
|
||||
VALID_URL_BASE = r'https?://(?:(?:www|go|m)\.)?twitch\.tv/(?P<id>[0-9_a-zA-Z]+)'
|
||||
VALID_URL_VIDEOS = r'https?://(?:(?:www|go|m)\.)?twitch\.tv/(?P<id>[^/]+)/(?:videos|profile|clips)'
|
||||
_CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko'
|
||||
|
||||
|
||||
Reference in new issue
Block a user