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:
XuanRan_Nyan authored and GitHub committed 2025-02-06 12:07:40 +00:00
1 parent 3b246974ec
commit 9404dc4a5c
26 files changed
+714 -324

No files matched your search

+8 -30
View File
@@ -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>
+1
View File
@@ -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 {
+7 -1
View File
@@ -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="此操作将不可逆"
+300
View File
@@ -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
+1 -92
View File
@@ -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
-7
View File
@@ -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={
+14 -4
View File
@@ -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
View File
@@ -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">
+15 -3
View File
@@ -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">
+20 -3
View File
@@ -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
View File
@@ -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">
+52
View File
@@ -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
+16 -3
View File
@@ -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">
+20 -3
View File
@@ -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">
+15 -3
View File
@@ -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">
+15 -3
View File
@@ -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">
+1
View File
@@ -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
View File
@@ -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()
+2
View File
@@ -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
View File
@@ -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
+3 -2
View File
@@ -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}
+9 -4
View File
@@ -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:
+7 -5
View File
@@ -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
View File
@@ -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
+3 -2
View File
@@ -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']
+1 -1
View File
@@ -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'