diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index 0dd1017..da6141f 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -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 = () => {

各平台下载设置

- {/* 哔哩哔哩 */} - - {/* CC直播 */} - - {/* 抖音 */} - - {/* 斗鱼 */} - - {/* 虎牙 */} - - {/* 克拉克拉 */} - - {/* 老鼠台 */} - - {/* Twitcasting */} - - {/* 油管 */} - - {/* 饼干 */} - + {Object.entries(plugins) + .filter(([key]) => key !== 'Cookie') + .map(([key, Plugin]) => ( + + ))} + diff --git a/app/lib/api-streamer.ts b/app/lib/api-streamer.ts index 305b2f0..147e385 100644 --- a/app/lib/api-streamer.ts +++ b/app/lib/api-streamer.ts @@ -107,6 +107,7 @@ export interface LiveStreamerEntity { downloaded_processor?: Record<'run', string>[]; postprocessor?: (Record<'run' | 'mv', string> | 'rm')[]; opt_args?: string[]; + override?: Record; } export interface BiliType { diff --git a/app/streamers/page.tsx b/app/streamers/page.tsx index 3ccb960..dfbf2a9 100644 --- a/app/streamers/page.tsx +++ b/app/streamers/page.tsx @@ -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() { + + + + Promise +} + +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 = {} + 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 = ({ 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 + } + } + // console.log('未匹配到平台') + return null + } + + const api = useRef() + + const { biliUsers } = useBiliUsers() + const list = biliUsers?.map(item => { + return { + value: item.value, + label: ( + <> + + {item.name} + + ), + } + }) + + 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(child)) { + return React.cloneElement(child, { + onClick: () => { + showDialog() + child.props.onClick?.() + }, + }) + } + }) + + const downloadSettings = ( + +
+ 请到 + + 空间配置 + + 查看选项说明 +
+ + streamlink(hls多线程下载) + ffmpeg + stream-gears(默认) + sync-downloader(边录边传) + + + + + + + + + +
+ ) + + return ( + <> + {childrenWithProps} + +
(api.current = formApi)}> + { + if (!value) return true + try { + JSON.parse(value) + return true + } catch (e) { + return false + } + }, + message: '请输入有效的 JSON 格式', + }, + ]} + /> + + + {downloadSettings} + {(() => { + const Plugin = platformSetting() + return Plugin ? ( + + ) : null + })()} + + + +
+ + ) +} + +export default OverrideModal diff --git a/app/ui/TemplateFields.tsx b/app/ui/TemplateFields.tsx index b7a5b47..7ec78b3 100644 --- a/app/ui/TemplateFields.tsx +++ b/app/ui/TemplateFields.tsx @@ -183,36 +183,7 @@ const TemplateFields: React.FC> 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, 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> } ]} /> - {/* { - 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 }) => ( - - - { - arrayFields.map(({ field, key, remove }, i) => ( -
- { - 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; - } - } - ]} - /> - -
- ))} -
- )} -
*/}
= ({ children, entity, onOk }) - - } const Bilibili: React.FC = 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 ( <> diff --git a/app/ui/plugins/cc.tsx b/app/ui/plugins/cc.tsx index dfab5ca..34e0aa0 100644 --- a/app/ui/plugins/cc.tsx +++ b/app/ui/plugins/cc.tsx @@ -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 +} + +const CC: React.FC = 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 ( <> diff --git a/app/ui/plugins/douyin.tsx b/app/ui/plugins/douyin.tsx index 363e05a..34842b7 100644 --- a/app/ui/plugins/douyin.tsx +++ b/app/ui/plugins/douyin.tsx @@ -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 } const Douyin: React.FC = 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 ( <> diff --git a/app/ui/plugins/douyu.tsx b/app/ui/plugins/douyu.tsx index e2b5b86..a61b66c 100644 --- a/app/ui/plugins/douyu.tsx +++ b/app/ui/plugins/douyu.tsx @@ -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 +} + +const Douyu: React.FC = 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 ( <> diff --git a/app/ui/plugins/huya.tsx b/app/ui/plugins/huya.tsx index a38ed51..4bc5c71 100644 --- a/app/ui/plugins/huya.tsx +++ b/app/ui/plugins/huya.tsx @@ -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 } const Huya: React.FC = 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 ( <> diff --git a/app/ui/plugins/index.ts b/app/ui/plugins/index.ts new file mode 100644 index 0000000..9899ea1 --- /dev/null +++ b/app/ui/plugins/index.ts @@ -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 diff --git a/app/ui/plugins/kilakila.tsx b/app/ui/plugins/kilakila.tsx index f69c3e8..f8df11d 100644 --- a/app/ui/plugins/kilakila.tsx +++ b/app/ui/plugins/kilakila.tsx @@ -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 } -const Kilakila: React.FC = () => { +const Kilakila: React.FC = 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 ( <> diff --git a/app/ui/plugins/twitcasting.tsx b/app/ui/plugins/twitcasting.tsx index 07d00ba..9c331d2 100644 --- a/app/ui/plugins/twitcasting.tsx +++ b/app/ui/plugins/twitcasting.tsx @@ -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 +} + +const TwitCasting: React.FC = 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 ( <> diff --git a/app/ui/plugins/twitch.tsx b/app/ui/plugins/twitch.tsx index 8fcb7b5..71a895f 100644 --- a/app/ui/plugins/twitch.tsx +++ b/app/ui/plugins/twitch.tsx @@ -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 } const Twitch: React.FC = 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 ( <> diff --git a/app/ui/plugins/youtube.tsx b/app/ui/plugins/youtube.tsx index 5c2440d..82bcb66 100644 --- a/app/ui/plugins/youtube.tsx +++ b/app/ui/plugins/youtube.tsx @@ -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 } const YouTube: React.FC = 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 ( <> diff --git a/biliup/database/models.py b/biliup/database/models.py index bfade42..c182b95 100644 --- a/biliup/database/models.py +++ b/biliup/database/models.py @@ -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) # 准备上传直播时触发 diff --git a/biliup/downloader.py b/biliup/downloader.py index 837c1f3..d09d13f 100644 --- a/biliup/downloader.py +++ b/biliup/downloader.py @@ -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() diff --git a/biliup/engine/download.py b/biliup/engine/download.py index af72556..f5cbd83 100644 --- a/biliup/engine/download.py +++ b/biliup/engine/download.py @@ -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() diff --git a/biliup/plugins/bilibili.py b/biliup/plugins/bilibili.py index 867f81f..a366736 100644 --- a/biliup/plugins/bilibili.py +++ b/biliup/plugins/bilibili.py @@ -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)) \ No newline at end of file +def normalize_url(url: str) -> str: + return url if url.startswith(('http://', 'https://')) else 'http://' + url \ No newline at end of file diff --git a/biliup/plugins/cc.py b/biliup/plugins/cc.py index fdb5cac..2834ba3 100644 --- a/biliup/plugins/cc.py +++ b/biliup/plugins/cc.py @@ -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} diff --git a/biliup/plugins/douyin.py b/biliup/plugins/douyin.py index e013c5b..d8c7152 100644 --- a/biliup/plugins/douyin.py +++ b/biliup/plugins/douyin.py @@ -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: diff --git a/biliup/plugins/douyu.py b/biliup/plugins/douyu.py index 28aea53..ab5cc72 100644 --- a/biliup/plugins/douyu.py +++ b/biliup/plugins/douyu.py @@ -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) diff --git a/biliup/plugins/huya.py b/biliup/plugins/huya.py index 4b688a8..b146171 100644 --- a/biliup/plugins/huya.py +++ b/biliup/plugins/huya.py @@ -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 diff --git a/biliup/plugins/kilakila.py b/biliup/plugins/kilakila.py index f9d331c..28869da 100644 --- a/biliup/plugins/kilakila.py +++ b/biliup/plugins/kilakila.py @@ -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'] diff --git a/biliup/plugins/twitch.py b/biliup/plugins/twitch.py index 5f3e993..3a7deb7 100644 --- a/biliup/plugins/twitch.py +++ b/biliup/plugins/twitch.py @@ -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[0-9_a-zA-Z]+)' +VALID_URL_BASE = r'https?://(?:(?:www|go|m)\.)?twitch\.tv/(?P[0-9_a-zA-Z]+)' VALID_URL_VIDEOS = r'https?://(?:(?:www|go|m)\.)?twitch\.tv/(?P[^/]+)/(?:videos|profile|clips)' _CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko'