From 667d420499fd86dfe7a5154166116bad918d03be Mon Sep 17 00:00:00 2001 From: helloplhm-qwq Date: Sun, 14 Apr 2024 19:06:36 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0=E5=A4=AA=E5=A4=A7?= =?UTF-8?q?=E4=BA=86=E4=B8=8D=E6=83=B3=E6=80=BB=E7=BB=93=E8=87=AA=E5=B7=B1?= =?UTF-8?q?=E5=8E=BB=E7=9C=8B=E6=8F=90=E4=BA=A4=E8=AE=B0=E5=BD=95=E5=90=A7?= =?UTF-8?q?=EF=BC=88=E5=B7=B2=E7=9F=A5=E6=8A=8A=E9=85=8D=E7=BD=AE=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E6=8D=A2=E6=88=90=E4=BA=86yaml?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 6 +- build.py | 2 +- common/config.py | 334 ++++-------------------------------- common/default_config.py | 195 +++++++++++++++++++++ common/log.py | 80 ++++----- common/lx_script.py | 4 +- common/scheduler.py | 3 + common/utils.py | 13 +- common/variable.py | 22 ++- main.py | 149 ++++++++++------ modules/tx/refresh_login.py | 1 + script.py | 11 ++ setup.py | 24 +++ 13 files changed, 432 insertions(+), 412 deletions(-) create mode 100644 common/default_config.py create mode 100644 script.py create mode 100644 setup.py diff --git a/.gitignore b/.gitignore index b5c9d22..2c74f48 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ test.* */test.* logs config.json +config.yml /config/config.json /config/data.db *.log @@ -40,4 +41,7 @@ config.json *.un # temp script -lx-music-source-example.js \ No newline at end of file +lx-music-source-example.js + +# dumprecord +dumprecord_*.txt \ No newline at end of file diff --git a/build.py b/build.py index 1719f96..b3c72d9 100644 --- a/build.py +++ b/build.py @@ -30,7 +30,7 @@ def get_changelog(): noticeMsg = [] unknownMsg = [] for msg in res: - if (re.match('[a-f0-9]*.(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert|notice)\(?.*?\)?\:', msg[1:-1])): + if (re.match('[a-f0-9]*.(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert|notice).*?\(?.*?\)?\:', msg[1:-1])): msg = msg[1:-1] if msg[8:].startswith('notice'): noticeMsg.append(msg) diff --git a/common/config.py b/common/config.py index f77d5f8..a0a726c 100644 --- a/common/config.py +++ b/common/config.py @@ -14,8 +14,10 @@ import traceback import sys import sqlite3 import shutil +import ruamel.yaml as yaml from . import variable from .log import log +from . import default_config import threading logger = log('config_manager') @@ -45,302 +47,17 @@ class ConfigReadException(Exception): pass -default = { - "common": { - "host": "0.0.0.0", - "_host-desc": "服务器启动时所使用的HOST地址", - "ports": [ 9763 ], - "_ports-desc": "服务器启动时所使用的端口", - "ssl_info": { - "desc": "服务器https配置,is_https是这个服务器是否是https服务器,如果你使用了反向代理来转发这个服务器,如果它使用了https,也请将它设置为true", - "is_https": False, - "enable": False, - "ssl_ports": [ 443 ], - "path": { - "desc": "ssl证书的文件地址", - "cert": "/path/to/your/cer", - "privkey": "/path/to/your/private/key", - }, - }, - "reverse_proxy": { - "desc": "针对类似于nginx一类的反代的配置", - "allow_proxy": True, - "_allow_proxy-desc": "是否允许反代", - "proxy_whitelist_remote": [ - "反代时允许的ip来源列表,通常为127.0.0.1", - "127.0.0.1" - ], - "real_ip_header": 'X-Real-IP', - "_real_ip_header-desc": "反代来源ip的来源头,不懂请保持默认", - }, - "debug_mode": False, - "_debug_mode-desc": "是否开启调试模式", - "log_length_limit": 500, - "_log_length_limit-desc": "单条日志长度限制", - "fakeip": "1.0.1.114", - "_fakeip-desc": "服务器在海外时的IP伪装值", - "proxy": { - "enable": False, - "http_value": "http://127.0.0.1:7890", - "https_value": "http://127.0.0.1:7890", - }, - "_proxy-desc": "代理配置,HTTP与HTTPS协议需分开配置", - "log_file": True, - "_log_file-desc": "是否开启日志文件", - 'cookiepool': False, - '_cookiepool-desc': '是否开启cookie池,这将允许用户配置多个cookie并在请求时随机使用一个,启用后请在module.cookiepool中配置cookie,在user处配置的cookie会被忽略,cookiepool中格式统一为列表嵌套user处的cookie的字典', - "allow_download_script": True, - '_allow_download_script-desc': '是否允许直接从服务端下载脚本,开启后可以直接访问 /script?key=你的请求key 下载脚本', - "download_config": { - "desc": "源脚本的相关配置,dev为是否启用开发模式", - "name": "修改为你的源脚本名称", - "intro": "修改为你的源脚本描述", - "author": "修改为你的源脚本作者", - "version": "修改为你的源版本", - "filename": "lx-music-source.js", - "_filename-desc": "客户端保存脚本时的文件名(可能因浏览器不同出现不一样的情况)", - "dev": True, - "quality": { - "kw": ["128k"], - "kg": ["128k"], - "tx": ["128k"], - "wy": ["128k"], - "mg": ["128k"], - } - }, - "local_music": { - "desc": "服务器侧本地音乐相关配置,请确保你的带宽足够", - "audio_path": "./audio", - "temp_path": "./temp", - } - }, - "security": { - "rate_limit": { - "global": 0, - "ip": 0, - "desc": "请求速率限制,global为全局,ip为单个ip,填入的值为至少间隔多久才能进行一次请求,单位:秒,不限制请填为0" - }, - "key": { - "enable": False, - "_enable-desc": "是否开启请求key,开启后只有请求头中包含key,且值一样时可以访问API", - "ban": True, - "values": ["114514"], - }, - "whitelist_host": [ - "localhost", - "0.0.0.0", - "127.0.0.1", - ], - "_whitelist_host-desc": "强制白名单HOST,不需要加端口号(即不受其他安全设置影响的HOST)", - "check_lxm": False, - "_check_lxm-desc": "是否检查lxm请求头(正常的LX Music请求时都会携带这个请求头)", - "lxm_ban": True, - "_lxm_ban-desc": "lxm请求头不存在或不匹配时是否将用户IP加入黑名单", - "allowed_host": { - "desc": "HOST允许列表,启用后只允许列表内的HOST访问服务器,不需要加端口号", - "enable": False, - "blacklist": { - "desc": "当用户访问的HOST并不在允许列表中时是否将请求IP加入黑名单,长度单位:秒", - "enable": False, - "length": 0, - }, - "list": [ - "localhost", - "0.0.0.0", - "127.0.0.1", - ], - }, - "banlist": { - "desc": "是否启用黑名单(全局设置,关闭后已存储的值并不受影响,但不会再检查)", - "enable": True, - "expire": { - "desc": "是否启用黑名单IP过期(关闭后其他地方的配置会失效)", - "enable": True, - "length": 86400 * 7, # 七天 - }, - } - }, - "module": { - "kg": { - "desc": "酷狗音乐相关配置", - "client": { - "desc": "客户端请求配置,不懂请保持默认,修改请统一为字符串格式", - "appid": "1005", - "_appid-desc": "酷狗音乐的appid,官方安卓为1005,官方PC为1001", - "signatureKey": "OIlwieks28dk2k092lksi2UIkp", - "_signatureKey-desc": "客户端signature采用的key值,需要与appid对应", - "clientver": "12029", - "_clientver-desc": "客户端versioncode,pidversionsecret可能随此值而变化", - "pidversionsecret": "57ae12eb6890223e355ccfcb74edf70d", - "_pidversionsecret-desc": "获取URL时所用的key值计算验证值", - "pid": "2", - }, - "tracker": { - "desc": "trackerapi请求配置,不懂请保持默认,修改请统一为字符串格式", - "host": "https://gateway.kugou.com", - "path": "/v5/url", - "version": "v5", - "x-router": { - "desc": "当host为gateway.kugou.com时需要追加此头,为tracker类地址时则不需要", - "enable": True, - "value": "tracker.kugou.com", - }, - "extra_params": {}, - "_extra_params-desc": "自定义添加的param,优先级大于默认,填写类型为普通的JSON数据,会自动转换为请求param", - }, - "user": { - "desc": "此处内容请统一抓包获取,需要vip账号来获取会员歌曲,如果没有请留为空值,mid必填,可以瞎填一段数字", - "token": "", - "userid": "0", - "mid": "114514", - "lite_sign_in": { - "desc": "是否启用概念版自动签到,仅在appid=3116时运行", - "enable": False, - "interval": 86400, - "mixsongmid": { - "desc": "mix_songmid的获取方式, 默认auto, 可以改成一个数字手动", - "value": "auto" - } - }, - "refresh_token": { - "desc": "酷狗token保活相关配置,30天不刷新token会失效,enable是否启动,interval刷新间隔。默认appid=1005时有效,3116需要更换signatureKey", - "enable": False, - "interval": 86000, - "login_url": "http://login.user.kugou.com/v4/login_by_token" - } - } - }, - "tx": { - "desc": "QQ音乐相关配置", - "vkeyserver": { - "desc": "请求官方api时使用的guid,uin等信息,不需要与cookie中信息一致", - "guid": "114514", - "uin": "10086", - }, - "user": { - "desc": "用户数据,可以通过浏览器获取,需要vip账号来获取会员歌曲,如果没有请留为空值,qqmusic_key可以从Cookie中/客户端的请求体中(comm.authst)获取", - "qqmusic_key": "", - "uin": "", - "_uin-desc": "key对应的QQ号", - 'refresh_login': { - 'desc': '刷新登录相关配置,enable是否启动,interval刷新间隔', - 'enable': False, - 'interval': 86000 - } - }, - "cdnaddr": "http://ws.stream.qqmusic.qq.com/", - }, - "wy": { - "desc": "网易云音乐相关配置", - "user": { - "desc": "账号cookie数据,可以通过浏览器获取,需要vip账号来获取会员歌曲,如果没有请留为空值", - "cookie": "" - }, - }, - "mg": { - "desc": "咪咕音乐相关配置", - "user": { - "desc": "研究不深,后两项自行抓包获取,网页端cookie", - "by": "", - "session": "", - "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.82 Safari/537.36", - "refresh_login": { - "enable": False, - "interval": 86400, - "desc": "进行cookie保活" - } - }, - }, - "kw": { - "desc": "酷我音乐相关配置,proto支持值:['bd-api', 'kuwodes']", - "proto": "bd-api", - "user": { - "uid": "0", - "token": "", - "device_id": "0", - }, - "des": { - "desc": "kuwodes接口(mobi, nmobi)一类的加密相关配置", - "f": "kuwo", - "need_encrypt": True, - "param填写注释": "{songId}为歌曲id, {map_quality}为map后的歌曲音质(酷我规范), {raw_quality}为请求时的歌曲音质(LX规范), {ext}为歌曲文件扩展名", - "params": "type=convert_url_with_sign&rid={songId}&quality={map_quality}&ext={ext}", - "host": "nmobi.kuwo.cn", - "path": "mobi.s", - "response_types": ['这里是reponse_type的所有支持值,当设置为json时会使用到下面的两个值来获取url/bitrate,如果为text,则为传统的逐行解析方式', 'json', 'text'], - "response_type": "json", - "url_json_path": "data.url", - "bitrate_json_path": "data.bitrate", - "headers": { - "User-Agent": 'okhttp/3.10.0' - } - } - }, - 'cookiepool': { - 'kg': [ - { - 'userid': '0', - 'token': '', - 'mid': '114514', - "lite_sign_in": { - "desc": "是否启用概念版自动签到,仅在appid=3116时运行", - "enable": False, - "interval": 86400, - "mixsongmid": { - "desc": "mix_songmid的获取方式, 默认auto, 可以改成一个数字手动", - "value": "auto" - } - } - }, - ], - 'tx': [ - { - 'qqmusic_key': '', - 'uin': '', - 'refresh_login': { - 'desc': 'cookie池中对于此账号刷新登录的配置,账号间互不干扰', - 'enable': False, - 'interval': 86000, - } - } - ], - 'wy': [ - { - 'cookie': '', - } - ], - 'mg': [ - { - 'by': '', - 'session': '', - 'useragent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.82 Safari/537.36', - "refresh_login": { - "enable": False, - "interval": 86400 - } - } - ], - 'kw': [ - { - "uid": "0", - "token": "", - "device_id": "0", - }, - ] - }, - }, -} +default_str = default_config.default +default = yaml.safe_load(default_str) def handle_default_config(): - with open("./config/config.json", "w", encoding="utf-8") as f: - f.write(json.dumps(default, indent=2, ensure_ascii=False, - escape_forward_slashes=False)) - f.close() + with open("./config/config.yml", "w", encoding="utf-8") as f: + f.write(default_str) if (not os.getenv('build')): logger.info('首次启动或配置文件被删除,已创建默认配置文件') logger.info( - f'\n建议您到{variable.workdir + os.path.sep}config.json修改配置后重新启动服务器') + f'\n建议您到{variable.workdir + os.path.sep}config.yml修改配置后重新启动服务器') return default @@ -525,8 +242,8 @@ def push_to_list(key, obj): def write_config(key, value): config = None - with open('./config/config.json', 'r', encoding='utf-8') as f: - config = json.load(f) + with open('./config/config.yml', 'r', encoding='utf-8') as f: + config = yaml.YAML().load(f) keys = key.split('.') current = config @@ -536,11 +253,15 @@ def write_config(key, value): current = current[k] current[keys[-1]] = value - variable.config = config - with open('./config/config.json', 'w', encoding='utf-8') as f: - json.dump(config, f, indent=2, ensure_ascii=False, - escape_forward_slashes=False) - f.close() + + # 设置保留注释和空行的参数 + y = yaml.YAML() + y.preserve_quotes = True + y.preserve_blank_lines = True + + # 写入配置并保留注释和空行 + with open('./config/config.yml', 'w', encoding='utf-8') as f: + y.dump(config, f) def read_default_config(key): @@ -639,21 +360,26 @@ def initConfig(): shutil.move('config.json','./config') if (os.path.exists('./data.db')): shutil.move('./data.db','./config') + if (os.path.exists('./config/config.json')): + os.rename('./config/config.json', './config/config.json.bak') + handle_default_config() + logger.warning('json配置文件已不再使用,已将其重命名为config.json.bak') + logger.warning('配置文件不会自动更新(因为变化太大),请手动修改配置文件重启服务器') + sys.exit(0) try: - with open("./config/config.json", "r", encoding="utf-8") as f: + with open("./config/config.yml", "r", encoding="utf-8") as f: try: - variable.config = json.loads(f.read()) + variable.config = yaml.safe_load(f.read()) if (not isinstance(variable.config, dict)): logger.warning('配置文件并不是一个有效的字典,使用默认值') variable.config = default - with open("./config/config.json", "w", encoding="utf-8") as f: - f.write(json.dumps(variable.config, indent=2, - ensure_ascii=False, escape_forward_slashes=False)) + with open("./config/config.yml", "w", encoding="utf-8") as f: + yaml.dump(variable.config, f) f.close() except: - if os.path.getsize("./config/config.json") != 0: - logger.error("配置文件加载失败,请检查是否遵循JSON语法规范") + if os.path.getsize("./config/config.yml") != 0: + logger.error("配置文件加载失败,请检查是否遵循YAML语法规范") sys.exit(1) else: variable.config = handle_default_config() diff --git a/common/default_config.py b/common/default_config.py new file mode 100644 index 0000000..4ee3021 --- /dev/null +++ b/common/default_config.py @@ -0,0 +1,195 @@ +default = ''' +common: + hosts: # 服务器监听地址 + - 0.0.0.0 + # - '::' # 取消这一行的注释,启用 ipv6 监听 + ports: # 服务器启动时所使用的端口 + - 9763 + ssl_info: # 服务器https配置 + # 这个服务器是否是https服务器,如果你使用了反向代理来转发这个服务器,如果它使用了https,也请将它设置为true + is_https: false + # python原生https监听 + enable: false + ssl_ports: + - 443 + path: # ssl证书的文件地址 + cert: /path/to/your/cer + privkey: /path/to/your/private/key + reverse_proxy: # 针对类似于nginx一类的反代的配置 + allow_public_ip: false # 允许来自公网的转发 + allow_proxy: true # 是否允许反代 + real_ip_header: X-Real-IP # 反代来源ip的来源头,不懂请保持默认 + debug_mode: false # 是否开启调试模式 + log_length_limit: 500 # 单条日志长度限制 + fakeip: 1.0.1.114 # 服务器在海外时的IP伪装值 + proxy: # 代理配置,HTTP与HTTPS协议需分开配置 + enable: false + http_value: http://127.0.0.1:7890 + https_value: http://127.0.0.1:7890 + log_file: true # 是否存储日志文件 + cookiepool: false # 是否开启cookie池,这将允许用户配置多个cookie并在请求时随机使用一个,启用后请在module.cookiepool中配置cookie,在user处配置的cookie会被忽略,cookiepool中格式统一为列表嵌套user处的cookie的字典 + allow_download_script: true # 是否允许直接从服务端下载脚本,开启后可以直接访问 /script?key=你的请求key 下载脚本 + download_config: + desc: 源脚本的相关配置,dev为是否启用开发模式 + name: 修改为你的源脚本名称 + intro: 修改为你的源脚本描述 + author: 修改为你的源脚本作者 + version: 修改为你的源版本 + filename: lx-music-source.js # 客户端保存脚本时的文件名(可能因浏览器不同出现不一样的情况) + dev: true + quality: + kw: [128k] + kg: [128k] + tx: [128k] + wy: [128k] + mg: [128k] + local_music: # 服务器侧本地音乐相关配置,如果需要使用此功能请确保你的带宽足够 + audio_path: ./audio + temp_path: ./temp + +security: + rate_limit: # 请求速率限制 填入的值为至少间隔多久才能进行一次请求,单位:秒,不限制请填为0 + global: 0 # 全局 + ip: 0 # 单个IP + key: + enable: false # 是否开启请求key,开启后只有请求头中包含key,且值一样时可以访问API + ban: true + values: # 填自己所有的请求key + - '114514' + whitelist_host: # 强制白名单HOST,不需要加端口号(即不受其他安全设置影响的HOST) + - localhost + - 0.0.0.0 + - 127.0.0.1 + check_lxm: false # 是否检查lxm请求头(正常的LX Music在内置源请求时都会携带这个请求头) + lxm_ban: true # lxm请求头不存在或不匹配时是否将用户IP加入黑名单 + allowed_host: # HOST允许列表,启用后只允许列表内的HOST访问服务器,不需要加端口号 + enable: false + blacklist: # 当用户访问的HOST并不在允许列表中时是否将请求IP加入黑名单,长度单位:秒 + enable: false + length: 0 + list: + - localhost + - 0.0.0.0 + - 127.0.0.1 + banlist: # 是否启用黑名单(全局设置,关闭后已存储的值并不受影响,但不会再检查) + enable: true + expire: # 是否启用黑名单IP过期(关闭后其他地方的配置会失效) + enable: true + length: 604800 + +module: + kg: # 酷狗音乐相关配置 + client: # 客户端请求配置,不懂请保持默认,修改请统一为字符串格式 + appid: '1005' # 酷狗音乐的appid,官方安卓为1005,官方PC为1001 + signatureKey: OIlwieks28dk2k092lksi2UIkp # 客户端signature采用的key值,需要与appid对应 + clientver: '12029' # 客户端versioncode,pidversionsecret可能随此值而变化 + pidversionsecret: 57ae12eb6890223e355ccfcb74edf70d # 获取URL时所用的key值计算验证值 + pid: '2' # url接口的pid + tracker: # trackerapi请求配置,不懂请保持默认,修改请统一为字符串格式 + host: https://gateway.kugou.com + path: /v5/url + version: v5 + x-router: # 当host为gateway.kugou.com时需要追加此头,为tracker类地址时则不需要 + enable: true + value: tracker.kugou.com + extra_params: {} # 自定义添加的param,优先级大于默认,填写类型为普通的JSON数据,会自动转换为请求param + user: # 此处内容请统一抓包获取(/v5/url),需要vip账号来获取会员歌曲,如果没有请留为空值,mid必填,可以瞎填一段数字 + token: '' + userid: '0' + mid: '114514' + lite_sign_in: # 是否启用概念版自动签到,仅在appid=3116时运行 + enable: false + interval: 86400 + mixsongmid: # mix_songmid的获取方式, 默认auto, 可以改成一个数字手动 + value: auto + refresh_token: # 酷狗token保活相关配置,30天不刷新token会失效,enable是否启动,interval刷新间隔。默认appid=1005时有效,3116需要更换signatureKey + enable: false + interval: 86000 + login_url: http://login.user.kugou.com/v4/login_by_token + + tx: # QQ音乐相关配置 + vkeyserver: # 请求官方api时使用的guid,uin等信息,不需要与cookie中信息一致 + guid: '114514' + uin: '10086' + user: # 用户数据,可以通过浏览器获取,需要vip账号来获取会员歌曲,如果没有请留为空值,qqmusic_key可以从Cookie中/客户端的请求体中(comm.authst)获取 + qqmusic_key: '' + uin: '' # key对应的QQ号 + refresh_login: # 刷新登录相关配置,enable是否启动,interval刷新间隔 + enable: false + interval: 86000 + cdnaddr: http://ws.stream.qqmusic.qq.com/ + + wy: # 网易云音乐相关配置 + user: # 账号cookie数据,可以通过浏览器获取,需要vip账号来获取会员歌曲,如果没有请留为空值 + cookie: '' + + mg: # 咪咕音乐相关配置 + user: # 研究不深,后两项自行抓包获取,网页端cookie + by: '' + session: '' + useragent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.82 Safari/537.36 + refresh_login: # cookie保活配置 + enable: false + interval: 86400 + + kw: # 酷我音乐相关配置,proto支持值:['bd-api', 'kuwodes'] + proto: bd-api + user: + uid: '0' + token: '' + device_id: '0' + des: # kuwodes接口(mobi, nmobi)一类的加密相关配置 + f: kuwo + need_encrypt: true # 是否开启kuwodes + # {songId}为歌曲id + # {map_quality}为map后的歌曲音质(酷我规范) + # {raw_quality}为请求时的歌曲音质(LX规范) + # {ext}为歌曲文件扩展名 + params: type=convert_url_with_sign&rid={songId}&quality={map_quality}&ext={ext} + host: nmobi.kuwo.cn + path: mobi.s + # 这里是reponse_type的所有支持值,当设置为json时会使用到下面的两个值来获取url/bitrate,如果为text,则为传统的逐行解析方式 + response_type: json + url_json_path: data.url + bitrate_json_path: data.bitrate + headers: + User-Agent: okhttp/3.10.0 + + cookiepool: + kg: + - userid: '0' + token: '' + mid: '114514' + lite_sign_in: # 是否启用概念版自动签到,仅在appid=3116时运行 + enable: false + interval: 86400 + mixsongmid: # mix_songmid的获取方式, 默认auto, 可以改成一个数字手动 + value: auto + refresh_login: # cookie池中对于此账号刷新登录的配置,账号间互不干扰 + enable: false + interval: 604800 + login_url: http://login.user.kugou.com/v4/login_by_token + + tx: + - qqmusic_key: '' + uin: '' + refresh_login: # cookie池中对于此账号刷新登录的配置,账号间互不干扰 + enable: false + interval: 86000 + + wy: + - cookie: '' + + mg: + - by: '' + session: '' + useragent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.82 Safari/537.36 + refresh_login: + enable: false + interval: 86400 + + kw: + - uid: '0' + token: '' + device_id: '0' +''' \ No newline at end of file diff --git a/common/log.py b/common/log.py index a5dd624..ab88e45 100644 --- a/common/log.py +++ b/common/log.py @@ -12,12 +12,14 @@ import colorlog import os import sys import re +import io import traceback +import time from pygments import highlight from pygments.lexers import PythonLexer from pygments.formatters import TerminalFormatter -from .utils import filterFileName, addToGlobalNamespace -from .variable import debug_mode, log_length_limit, log_file +from .utils import filterFileName, setGlobal +from .variable import debug_mode, log_length_limit, log_file, log_files from colorama import Fore, Back, Style from colorama import init as clinit @@ -159,10 +161,19 @@ class LogHelper(logging.Handler): log_message = self.format(record) self.custom_logger.info(log_message) +class fileWriter(logging.Handler): + def __init__(self, f: io.TextIOWrapper, f2: logging.Formatter): + self.file = f + self.formatter = f2 + + def emit(self, record: logging.LogRecord): + self.file.write(self.format(record) + '\n') + self.file.flush() class log: # 主类 def __init__(self, module_name='Not named logger', output_level='INFO', filename=''): + self.name = module_name self._logger = logging.getLogger(module_name) if not output_level.upper() in dir(logging): raise NameError('Unknown loglevel: '+output_level) @@ -181,21 +192,12 @@ class log: 'CRITICAL': 'red,bg_white', }) if log_file: - file_formatter = logging.Formatter( - '%(asctime)s|[%(name)s/%(levelname)s]|%(message)s', - datefmt='%Y-%m-%d %H:%M:%S' - ) if filename: filename = filterFileName(filename) else: filename = './logs/' + module_name + '.log' - file_handler = logging.FileHandler(filename, encoding="utf-8") - file_handler.setFormatter(file_formatter) - file_handler_ = logging.FileHandler( - "./logs/console_full.log", encoding="utf-8") - file_handler_.setFormatter(file_formatter) - self._logger.addHandler(file_handler_) - self._logger.addHandler(file_handler) + self.file = open(filename, 'a+', encoding='utf-8') + log_files.append(self.file) console_handler = logging.StreamHandler() console_handler.setFormatter(formatter) self.module_name = module_name @@ -204,61 +206,55 @@ class log: debug_handler.setFormatter(formatter) def debug(self, message, allow_hidden=True): - if self.module_name == "flask" and "\n" in message: - if message.startswith("Error"): - return self._logger.error(message) - for m in message.split("\n"): - if "WARNING" in m: - self._logger.warning(m) - else: - self._logger.info(m) - return + if (log_file): + self.file.write('{time}|[{name}/DEBUG]{msg}'.format(time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()), name = self.module_name, msg = message) + '\n') + if len(str(message)) > log_length_limit and allow_hidden: message = str(message)[:log_length_limit] + " ..." self._logger.debug(message) def log(self, message, allow_hidden=True): - if self.module_name == "flask" and "\n" in message: - if message.startswith("Error"): - return self._logger.error(message) - for m in message.split("\n"): - if "WARNING" in m: - self._logger.warning(m) - else: - self._logger.info(m) - return + if (log_file): + self.file.write('{time}|[{name}/INFO]{msg}'.format(time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()), name = self.module_name, msg = message) + '\n') + if len(str(message)) > log_length_limit and allow_hidden: message = str(message)[:log_length_limit] + " ..." self._logger.info(message) def info(self, message, allow_hidden=True): - if self.module_name == "flask" and "\n" in message: - if message.startswith("Error"): - return self._logger.error(message) - for m in message.split("\n"): - if "WARNING" in m: - self._logger.warning(m) - else: - self._logger.info(m) - return + if (log_file): + self.file.write('{time}|[{name}/INFO]{msg}'.format(time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()), name = self.module_name, msg = message) + '\n') + if len(str(message)) > log_length_limit and allow_hidden: message = str(message)[:log_length_limit] + "..." self._logger.info(message) def warning(self, message): + if (log_file): + self.file.write('{time}|[{name}/WARNING]{msg}'.format(time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()), name = self.module_name, msg = message) + '\n') + if (message.strip().startswith('Traceback')): self._logger.warning('\n' + highlight_error(message)) else: self._logger.warning(message) def error(self, message): + if (log_file): + self.file.write('{time}|[{name}/ERROR]{msg}'.format(time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()), name = self.module_name, msg = message) + '\n') + if (message.startswith('Traceback')): self._logger.error('\n' + highlight_error(message)) else: self._logger.error(message) def critical(self, message): - self._logger.critical(message) + if (log_file): + self.file.write('{time}|[{name}/CRITICAL]{msg}'.format(time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()), name = self.module_name, msg = message) + '\n') + + if (message.startswith('Traceback')): + self._logger.critical('\n' + highlight_error(message)) + else: + self._logger.critical(message) def set_level(self, loglevel): loglevel_upper = loglevel.upper() @@ -280,4 +276,4 @@ def logprint(*args, sep=' ', end='', file=None, flush=None): printlogger.info(sep.join(str(arg) for arg in args), allow_hidden=False) -addToGlobalNamespace('print', logprint) +setGlobal(logprint, 'print') diff --git a/common/lx_script.py b/common/lx_script.py index 9f4a339..308f4db 100644 --- a/common/lx_script.py +++ b/common/lx_script.py @@ -37,8 +37,8 @@ async def get_response(retry = 0): if (retry > 21): logger.warning('请求源脚本内容失败') return - baseurl = '/lxmusics/lx-music-api-server/main/lx-music-source-example.js' - jsdbaseurl = '/gh/lxmusics/lx-music-api-server@main/lx-music-source-example.js' + baseurl = '/MeoProject/lx-music-api-server/main/lx-music-source-example.js' + jsdbaseurl = '/gh/MeoProject/lx-music-api-server@main/lx-music-source-example.js' try: i = retry if (i > 10): diff --git a/common/scheduler.py b/common/scheduler.py index 42c3722..32f5a7e 100644 --- a/common/scheduler.py +++ b/common/scheduler.py @@ -40,6 +40,9 @@ class taskWrapper: logger.error(f"task {self.name} run failed, waiting for next execute...") logger.error(traceback.format_exc()) + def __str__(self): + return f'SchedulerTaskWrapper(name="{self.name}", interval={self.interval}, function={self.function}, args={self.args}, latest_execute={self.latest_execute})' + def append(name, task, interval = 86400, args = {}): global tasks wrapper = taskWrapper(name, task, interval, args) diff --git a/common/utils.py b/common/utils.py index 28dda89..81812ae 100644 --- a/common/utils.py +++ b/common/utils.py @@ -16,6 +16,7 @@ import zlib import time import re import xmltodict +import ipaddress from urllib.parse import quote, unquote, urlparse def createBase64Encode(data_bytes): @@ -51,8 +52,8 @@ def require(module): index += 1 return _module -def addToGlobalNamespace(key, data): - setattr(builtins, key, data) +def setGlobal(obj, key = ''): + setattr(builtins, obj.__name__ if (not key) else key, obj) def filterFileName(filename): if platform.system() == 'Windows' or platform.system() == 'Cygwin': @@ -198,5 +199,11 @@ def timestamp_format(t): t = int(t) return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(t)) -addToGlobalNamespace('require', require) +def is_local_ip(ip): + try: + i = ipaddress.ip_address(ip) + return i.is_private + except: + return False +setGlobal(require) \ No newline at end of file diff --git a/common/variable.py b/common/variable.py index ca0cc2e..f195c21 100644 --- a/common/variable.py +++ b/common/variable.py @@ -1,22 +1,24 @@ # ---------------------------------------- -# - mode: python - -# - author: helloplhm-qwq - -# - name: variable.py - -# - project: lx-music-api-server - -# - license: MIT - +# - mode: python - +# - author: helloplhm-qwq - +# - name: variable.py - +# - project: lx-music-api-server - +# - license: MIT - # ---------------------------------------- # This file is part of the "lx-music-api-server" project. import os as _os import ujson as _json + def _read_config_file(): try: - with open("./config/config.json", "r", encoding = "utf-8") as f: + with open("./config/config.json", "r", encoding="utf-8") as f: return _json.load(f) except: return {} + def _read_config(key): try: config = _read_config_file() @@ -36,12 +38,15 @@ def _read_config(key): return value except: return None + + _dm = _read_config("common.debug_mode") _lm = _read_config("common.log_file") _ll = _read_config("common.log_length_limit") -debug_mode = _dm if (_dm) else False +debug_mode = True if (_os.getenv('CURRENT_ENV') == + 'development') else (_dm if (_dm) else False) log_length_limit = _ll if (_ll) else 500 -log_file = _lm if (isinstance(_lm , bool)) else True +log_file = _lm if (isinstance(_lm, bool)) else True running = True config = {} workdir = _os.getcwd() @@ -55,3 +60,4 @@ running_ports = [] use_proxy = False http_proxy = '' https_proxy = '' +log_files = [] diff --git a/main.py b/main.py index 0b30634..3196f3f 100644 --- a/main.py +++ b/main.py @@ -1,22 +1,33 @@ #!/usr/bin/env python3 # ---------------------------------------- -# - mode: python - -# - author: helloplhm-qwq - -# - name: main.py - -# - project: lx-music-api-server - -# - license: MIT - +# - mode: python - +# - author: helloplhm-qwq - +# - name: main.py - +# - project: lx-music-api-server - +# - license: MIT - # ---------------------------------------- # This file is part of the "lx-music-api-server" project. +import time +import aiohttp +import asyncio +import traceback +import threading +import ujson as json +from aiohttp.web import Response, FileResponse, StreamResponse +from io import TextIOWrapper import sys - -from common.utils import createBase64Decode +import os if ((sys.version_info.major == 3 and sys.version_info.minor < 6) or sys.version_info.major == 2): print('Python版本过低,请使用Python 3.6+ ') sys.exit(1) +# fix: module not found: common/modules +sys.path.append(os.path.dirname(os.path.abspath(__file__))) + +from common import utils from common import config, localMusic from common import lxsecurity from common import log @@ -24,24 +35,17 @@ from common import Httpx from common import variable from common import scheduler from common import lx_script -from aiohttp.web import Response, FileResponse, StreamResponse -import ujson as json -import threading -import traceback import modules -import asyncio -import aiohttp -import time -import os -def handleResult(dic, status = 200) -> Response: +def handleResult(dic, status=200) -> Response: if (not isinstance(dic, dict)): dic = { 'code': 0, 'msg': 'success', 'data': dic } - return Response(body = json.dumps(dic, indent=2, ensure_ascii=False), content_type='application/json', status = status) + return Response(body=json.dumps(dic, indent=2, ensure_ascii=False), content_type='application/json', status=status) + logger = log.log("main") aiologger = log.log('aiohttp_web') @@ -54,20 +58,24 @@ if (sys.version_info.minor < 8 and sys.version_info.major == 3): else: stopEvent = asyncio.exceptions.CancelledError + def start_checkcn_thread() -> None: threading.Thread(target=Httpx.checkcn).start() # check request info before start + + async def handle_before_request(app, handler): async def handle_request(request): try: if (config.read_config('common.reverse_proxy.allow_proxy')): if (request.headers.get(config.read_config('common.reverse_proxy.real_ip_header'))): # proxy header - if (request.remote in config.read_config('common.reverse_proxy.proxy_whitelist_remote')): - request.remote_addr = request.headers.get(config.read_config('common.reverse_proxy.real_ip_header')) + if (config.read_config('common.reverse_proxy.allow_public_ip') and (not utils.is_private_ip(request.remote))): + request.remote_addr = request.headers.get( + config.read_config('common.reverse_proxy.real_ip_header')) else: - return handleResult({"code": 1, "msg": "反代客户端远程地址不在反代ip白名单中", "data": None}, 403) + return handleResult({"code": 1, "msg": "不允许的公网ip转发", "data": None}, 403) else: request.remote_addr = request.remote else: @@ -80,13 +88,13 @@ async def handle_before_request(app, handler): (time.time() - config.getRequestTime('global')) < (config.read_config("security.rate_limit.global")) - ): + ): return handleResult({"code": 5, "msg": "全局限速", "data": None}, 429) if ( (time.time() - config.getRequestTime(request.remote_addr)) < (config.read_config("security.rate_limit.ip")) - ): + ): return handleResult({"code": 5, "msg": "IP限速", "data": None}, 429) # update request time config.updateRequestTime('global') @@ -95,27 +103,32 @@ async def handle_before_request(app, handler): if (config.read_config("security.allowed_host.enable")): if request.host.split(":")[0] not in config.read_config("security.allowed_host.list"): if config.read_config("security.allowed_host.blacklist.enable"): - config.ban_ip(request.remote_addr, int(config.read_config("security.allowed_host.blacklist.length"))) + config.ban_ip(request.remote_addr, int( + config.read_config("security.allowed_host.blacklist.length"))) return handleResult({'code': 6, 'msg': '未找到您所请求的资源', 'data': None}, 404) resp = await handler(request) if (isinstance(resp, (str, list, dict))): resp = handleResult(resp) - elif (isinstance(resp, tuple) and len(resp) == 2): # flask like response + elif (isinstance(resp, tuple) and len(resp) == 2): # flask like response body, status = resp if (isinstance(body, (str, list, dict))): resp = handleResult(body, status) else: - resp = Response(body = str(body), content_type='text/plain', status = status) + resp = Response( + body=str(body), content_type='text/plain', status=status) elif (not isinstance(resp, (Response, FileResponse, StreamResponse))): - resp = Response(body = str(resp), content_type='text/plain', status = 200) - aiologger.info(f'{request.remote_addr + ("" if (request.remote == request.remote_addr) else f"|proxy@{request.remote}")} - {request.method} "{request.path}", {resp.status}') + resp = Response( + body=str(resp), content_type='text/plain', status=200) + aiologger.info( + f'{request.remote_addr + ("" if (request.remote == request.remote_addr) else f"|proxy@{request.remote}")} - {request.method} "{request.path}", {resp.status}') return resp - except: + except: logger.error(traceback.format_exc()) return {"code": 4, "msg": "内部服务器错误", "data": None} return handle_request + async def main(request): return handleResult({"code": 0, "msg": "success", "data": None}) @@ -136,7 +149,7 @@ async def handle(request): if (config.read_config('security.lxm_ban.enable')): config.ban_ip(request.remote_addr) return handleResult({"code": 1, "msg": "lxm请求头验证失败", "data": None}, 403) - + try: query = dict(request.query) if (method in dir(modules)): @@ -147,14 +160,17 @@ async def handle(request): logger.error(traceback.format_exc()) return handleResult({'code': 4, 'msg': '内部服务器错误', 'data': None}, 500) + async def handle_404(request): return handleResult({'code': 6, 'msg': '未找到您所请求的资源', 'data': None}, 404) + async def handle_local(request): try: query = dict(request.query) data = query.get('q') - data = createBase64Decode(data.replace('-', '+').replace('_', '/')) + data = utils.createBase64Decode( + data.replace('-', '+').replace('_', '/')) data = json.loads(data) t = request.match_info.get('type') data['t'] = t @@ -208,28 +224,30 @@ if (config.read_config('common.allow_download_script')): # 404 app.router.add_route('*', '/{tail:.*}', handle_404) -async def run_app(): + +async def run_app_host(host): retries = 0 while True: if (retries > 4): logger.warning("重试次数已达上限,但仍有部分端口未能完成监听,已自动进行忽略") - return + break try: - host = config.read_config('common.host') - ports = [int(port) for port in config.read_config('common.ports')] - ssl_ports = [int(port) for port in config.read_config('common.ssl_info.ssl_ports')] - + ports = [int(port) + for port in config.read_config('common.ports')] + ssl_ports = [int(port) for port in config.read_config( + 'common.ssl_info.ssl_ports')] final_ssl_ports = [] final_ports = [] for p in ports: - if (p not in ssl_ports and p not in variable.running_ports): + if (p not in ssl_ports and f'{host}_{p}' not in variable.running_ports): final_ports.append(p) else: if (p not in variable.running_ports): final_ssl_ports.append(p) # 读取证书和私钥路径 cert_path = config.read_config('common.ssl_info.path.cert') - privkey_path = config.read_config('common.ssl_info.path.privkey') + privkey_path = config.read_config( + 'common.ssl_info.path.privkey') # 创建 HTTP AppRunner http_runner = aiohttp.web.AppRunner(app) @@ -238,16 +256,21 @@ async def run_app(): # 启动 HTTP 端口监听 for port in final_ports: if (port not in variable.running_ports): - http_site = aiohttp.web.TCPSite(http_runner, host, port) + http_site = aiohttp.web.TCPSite( + http_runner, host, port) await http_site.start() - variable.running_ports.append(port) - logger.info(f"监听 -> http://{host}:{port}") + variable.running_ports.append(f'{host}_{port}') + logger.info(f"""监听 -> http://{ + host if (':' not in host) + else '[' + host + ']' + }:{port}""") if (config.read_config("common.ssl_info.enable") and final_ssl_ports != []): if (os.path.exists(cert_path) and os.path.exists(privkey_path)): import ssl # 创建 SSL 上下文,加载配置文件中指定的证书和私钥 - ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) + ssl_context = ssl.create_default_context( + ssl.Purpose.CLIENT_AUTH) ssl_context.load_cert_chain(cert_path, privkey_path) # 创建 HTTPS AppRunner @@ -257,12 +280,16 @@ async def run_app(): # 启动 HTTPS 端口监听 for port in ssl_ports: if (port not in variable.running_ports): - https_site = aiohttp.web.TCPSite(https_runner, host, port, ssl_context=ssl_context) + https_site = aiohttp.web.TCPSite( + https_runner, host, port, ssl_context=ssl_context) await https_site.start() - variable.running_ports.append(port) - logger.info(f"监听 -> https://{host}:{port}") - - return + variable.running_ports.append(f'{host}_{port}') + logger.info(f"""监听 -> http://{ + host if (':' not in host) + else '[' + host + ']' + }:{port}""") + logger.debug(f"HOST({host}) 已完成监听") + break except OSError as e: if (str(e).startswith("[Errno 98]") or str(e).startswith('[Errno 10048]')): logger.error("端口已被占用,请检查\n" + str(e)) @@ -271,7 +298,12 @@ async def run_app(): logger.info('重新尝试启动...') retries += 1 else: - raise + logger.error("未知错误,请检查\n" + traceback.format_exc()) + + +async def run_app(): + for host in config.read_config('common.hosts'): + await run_app_host(host) async def initMain(): @@ -294,7 +326,7 @@ async def initMain(): logger.info('wating for sessions to complete...') if variable.aioSession: await variable.aioSession.close() - + variable.running = False logger.info("Server stopped") @@ -305,5 +337,20 @@ if __name__ == "__main__": except KeyboardInterrupt: pass except: - logger.error('初始化出错,请检查日志') - logger.error(traceback.format_exc()) + logger.critical('初始化出错,请检查日志') + logger.critical(traceback.format_exc()) + with open('dumprecord_{}.txt'.format(int(time.time())), 'w', encoding='utf-8') as f: + f.write(traceback.format_exc()) + e = '\n\nGlobal variable object:\n\n' + for k in dir(variable): + e += (k + ' = ' + str(getattr(variable, k)) + '\n') if (not k.startswith('_')) else '' + f.write(e) + e = '\n\nsys.modules:\n\n' + for k in sys.modules: + e += (k + ' = ' + str(sys.modules[k]) + '\n') if (not k.startswith('_')) else '' + f.write(e) + logger.critical('dumprecord_{}.txt 已保存至当前目录'.format(int(time.time()))) + finally: + for f in variable.log_files: + if (f and isinstance(f, TextIOWrapper)): + f.close() diff --git a/modules/tx/refresh_login.py b/modules/tx/refresh_login.py index e80203f..4d6e30f 100644 --- a/modules/tx/refresh_login.py +++ b/modules/tx/refresh_login.py @@ -22,6 +22,7 @@ async def refresh(): return if (not config.read_config('module.tx.user.refresh_login.enable')): return + print(config.read_config('module.tx.user.qqmusic_key')) if (config.read_config('module.tx.user.qqmusic_key').startswith('W_X')): options = { 'method': 'POST', diff --git a/script.py b/script.py new file mode 100644 index 0000000..f7174f5 --- /dev/null +++ b/script.py @@ -0,0 +1,11 @@ +# https://github.com/python-poetry/poetry/issues/241#issuecomment-445434646 + +import sys +import subprocess + + +def __getattr__(name): # python 3.7+, otherwise define each script manually + name = name.replace('_', '-') + subprocess.run( + ['python', '-u', '-m', name] + sys.argv[1:] + ) # run whatever you like based on 'name' \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..db29cc6 --- /dev/null +++ b/setup.py @@ -0,0 +1,24 @@ +from setuptools import setup +import toml + +try: + version = toml.load("./pyproject.toml")["tool"]["poetry"]["version"] + description = toml.load("./pyproject.toml")["tool"]["poetry"]["description"] +except Exception: + version = "1.0.0" + description = "Description not available" + +setup( + name='lx_music_api_server_setup', + version=version, + scripts=['poetry_run.py'], + author='helloplhm-qwq', + author_email='helloplhm-qwq@outlook.com', + description=description, + url='https://github.com/helloplhm-qwq/lx-music-api-server', + classifiers=[ + 'Programming Language :: Python :: 3', + 'License :: OSI Approved :: MIT License', + 'Operating System :: OS Independent', + ], +)