Archived
refactor(async): music_api 异步化 + 全项目 lint 收敛
music_api.py: - 22 个方法改 async(HTTPClient.post_request*/NeteaseAPI.*/QRLoginManager.*), 33 处 requests.* → await core.aiohttp_compat.* - 16 个模块级同步包装改走 run_sync 桥(main.py 等 99 个同步调用点零改动) - async 内 time.sleep → asyncio.sleep(修 7 处阻塞事件循环) musica_downloader/qr_login: - 单曲三请求 ThreadPoolExecutor → asyncio.gather(语义等价,失败处理保留) - interactive_login 改 async,CLI 入口用 asyncio.run 真实缺陷修复(lint 发现): - podcast_api._to_int 未定义(2 处调用会 NameError)→ 补安全转换 helper - main.py VALID_QUALITY_LEVELS 重复定义(覆盖 core.config 导入值)→ 移除 - 移除死代码变量(salt×2/device_id×3/file_path/zipped_files) lint 收敛:524→0(ruff All checks passed);pyright 0 errors。 pyproject 记录「有意保留」项(B904/RUF012/S305 上游协议等)及理由。 验证:冒烟 10/10;pytest 16/16;真实上游 get_toplist 异步直调与桥接均得 63 条。
This commit is contained in:
1 parent
4962542e78
commit
ce7ea66d6a
8 files changed
+388
-358
No files matched your search
+21
-24
@@ -6,12 +6,9 @@
|
|||||||
|
|
||||||
import hmac
|
import hmac
|
||||||
import secrets
|
import secrets
|
||||||
|
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from typing import Optional, Tuple
|
|
||||||
|
|
||||||
from flask import request, send_file
|
from flask import request, send_file
|
||||||
|
|
||||||
import auth_system as auth
|
import auth_system as auth
|
||||||
@@ -304,7 +301,7 @@ def register_auth_routes(app) -> None:
|
|||||||
return _ok({'user': auth._user_public(fresh),
|
return _ok({'user': auth._user_public(fresh),
|
||||||
'duration_text': auth.format_credits(fresh['duration_seconds']),
|
'duration_text': auth.format_credits(fresh['duration_seconds']),
|
||||||
'redirect': 'admin' if fresh['role'] == 'admin' else 'user'},
|
'redirect': 'admin' if fresh['role'] == 'admin' else 'user'},
|
||||||
f'注册成功' + (f',已赠送 {credits} 次下载次数' if credits else ''))
|
'注册成功' + (f',已赠送 {credits} 次下载次数' if credits else ''))
|
||||||
except Exception:
|
except Exception:
|
||||||
return _err('注册失败,请稍后重试', 500)
|
return _err('注册失败,请稍后重试', 500)
|
||||||
|
|
||||||
@@ -366,7 +363,7 @@ def register_auth_routes(app) -> None:
|
|||||||
'密码验证通过,请完成邮箱验证')
|
'密码验证通过,请完成邮箱验证')
|
||||||
auth.login_session(user)
|
auth.login_session(user)
|
||||||
# IP 归属地异常检测:24h 内 >=3 个不同省份自动封禁(防倒卖)
|
# IP 归属地异常检测:24h 内 >=3 个不同省份自动封禁(防倒卖)
|
||||||
banned, info = auth.record_login_location(user, auth._client_ip())
|
banned, _info = auth.record_login_location(user, auth._client_ip())
|
||||||
if banned:
|
if banned:
|
||||||
auth.logout_session()
|
auth.logout_session()
|
||||||
return _err('检测到账号在多个省份异常登录,已自动封禁,请联系管理员')
|
return _err('检测到账号在多个省份异常登录,已自动封禁,请联系管理员')
|
||||||
@@ -442,7 +439,7 @@ def register_auth_routes(app) -> None:
|
|||||||
return _err('账号已被禁用,请联系管理员')
|
return _err('账号已被禁用,请联系管理员')
|
||||||
auth.login_session(user)
|
auth.login_session(user)
|
||||||
# IP 归属地异常检测:管理员天然豁免省份封禁(record_login_location 内部判定)
|
# IP 归属地异常检测:管理员天然豁免省份封禁(record_login_location 内部判定)
|
||||||
banned, info = auth.record_login_location(user, auth._client_ip())
|
banned, _info = auth.record_login_location(user, auth._client_ip())
|
||||||
if banned:
|
if banned:
|
||||||
auth.logout_session()
|
auth.logout_session()
|
||||||
return _err('检测到账号在多个省份异常登录,已自动封禁,请联系管理员')
|
return _err('检测到账号在多个省份异常登录,已自动封禁,请联系管理员')
|
||||||
@@ -552,7 +549,7 @@ def register_auth_routes(app) -> None:
|
|||||||
auth.add_log('success', _actor(), auth._client_ip(), '删除用户', target['username'] if target else str(uid))
|
auth.add_log('success', _actor(), auth._client_ip(), '删除用户', target['username'] if target else str(uid))
|
||||||
return _ok(message='用户已删除')
|
return _ok(message='用户已删除')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return _err(f'删除失败: {str(e)}', 500)
|
return _err(f'删除失败: {e!s}', 500)
|
||||||
|
|
||||||
@app.route('/admin/site/user/duration', methods=['POST'])
|
@app.route('/admin/site/user/duration', methods=['POST'])
|
||||||
@auth.admin_required
|
@auth.admin_required
|
||||||
@@ -576,9 +573,9 @@ def register_auth_routes(app) -> None:
|
|||||||
f'{target["username"]} → {count} 次')
|
f'{target["username"]} → {count} 次')
|
||||||
return _ok(message=f'已修改为 {count} 次')
|
return _ok(message=f'已修改为 {count} 次')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return _err(f'修改失败: {str(e)}', 500)
|
return _err(f'修改失败: {e!s}', 500)
|
||||||
|
|
||||||
def _block_admin_target(target) -> Optional[Tuple]:
|
def _block_admin_target(target) -> tuple | None:
|
||||||
"""禁止对管理员账号执行危险操作(防自锁)"""
|
"""禁止对管理员账号执行危险操作(防自锁)"""
|
||||||
if target and target['role'] == 'admin':
|
if target and target['role'] == 'admin':
|
||||||
return _err('不能对管理员账号执行该操作')
|
return _err('不能对管理员账号执行该操作')
|
||||||
@@ -622,7 +619,7 @@ def register_auth_routes(app) -> None:
|
|||||||
f'{username} · {credits} 次')
|
f'{username} · {credits} 次')
|
||||||
return _ok(message=f'用户 {username} 创建成功({credits} 次)')
|
return _ok(message=f'用户 {username} 创建成功({credits} 次)')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return _err(f'创建失败: {str(e)}', 500)
|
return _err(f'创建失败: {e!s}', 500)
|
||||||
|
|
||||||
@app.route('/admin/site/user/reset_password', methods=['POST'])
|
@app.route('/admin/site/user/reset_password', methods=['POST'])
|
||||||
@auth.admin_required
|
@auth.admin_required
|
||||||
@@ -653,7 +650,7 @@ def register_auth_routes(app) -> None:
|
|||||||
auth.add_log('success', _actor(), auth._client_ip(), '重置密码', target['username'])
|
auth.add_log('success', _actor(), auth._client_ip(), '重置密码', target['username'])
|
||||||
return _ok(message=f'已重置 {target["username"]} 的密码')
|
return _ok(message=f'已重置 {target["username"]} 的密码')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return _err(f'重置失败: {str(e)}', 500)
|
return _err(f'重置失败: {e!s}', 500)
|
||||||
|
|
||||||
@app.route('/admin/site/user/toggle', methods=['POST'])
|
@app.route('/admin/site/user/toggle', methods=['POST'])
|
||||||
@auth.admin_required
|
@auth.admin_required
|
||||||
@@ -674,7 +671,7 @@ def register_auth_routes(app) -> None:
|
|||||||
return _ok({'status': new_status},
|
return _ok({'status': new_status},
|
||||||
message=f'已{"启用" if new_status else "禁用"} {target["username"]}')
|
message=f'已{"启用" if new_status else "禁用"} {target["username"]}')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return _err(f'操作失败: {str(e)}', 500)
|
return _err(f'操作失败: {e!s}', 500)
|
||||||
|
|
||||||
@app.route('/admin/site/user/ban', methods=['POST'])
|
@app.route('/admin/site/user/ban', methods=['POST'])
|
||||||
@auth.admin_required
|
@auth.admin_required
|
||||||
@@ -704,7 +701,7 @@ def register_auth_routes(app) -> None:
|
|||||||
auth._client_ip(), '解封用户', row['username'])
|
auth._client_ip(), '解封用户', row['username'])
|
||||||
return _ok(message=f'已解封用户「{row["username"]}」')
|
return _ok(message=f'已解封用户「{row["username"]}」')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return _err(f'操作失败: {str(e)}', 500)
|
return _err(f'操作失败: {e!s}', 500)
|
||||||
|
|
||||||
@app.route('/admin/site/user/cookie', methods=['POST'])
|
@app.route('/admin/site/user/cookie', methods=['POST'])
|
||||||
@auth.admin_required
|
@auth.admin_required
|
||||||
@@ -743,7 +740,7 @@ def register_auth_routes(app) -> None:
|
|||||||
auth.add_log('success', _actor(), auth._client_ip(), '保存SMTP配置', f'{host}:{port}')
|
auth.add_log('success', _actor(), auth._client_ip(), '保存SMTP配置', f'{host}:{port}')
|
||||||
return _ok(message='SMTP 配置已保存')
|
return _ok(message='SMTP 配置已保存')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return _err(f'保存失败: {str(e)}', 500)
|
return _err(f'保存失败: {e!s}', 500)
|
||||||
|
|
||||||
@app.route('/admin/site/smtp/test', methods=['POST'])
|
@app.route('/admin/site/smtp/test', methods=['POST'])
|
||||||
@auth.admin_required
|
@auth.admin_required
|
||||||
@@ -762,7 +759,7 @@ def register_auth_routes(app) -> None:
|
|||||||
return _ok(message='测试邮件已发送,请查收')
|
return _ok(message='测试邮件已发送,请查收')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
auth.add_log('fail', _actor(), auth._client_ip(), 'SMTP测试', str(e))
|
auth.add_log('fail', _actor(), auth._client_ip(), 'SMTP测试', str(e))
|
||||||
return _err(f'发送失败: {str(e)}', 500)
|
return _err(f'发送失败: {e!s}', 500)
|
||||||
|
|
||||||
@app.route('/admin/site/logs', methods=['POST'])
|
@app.route('/admin/site/logs', methods=['POST'])
|
||||||
@auth.admin_required
|
@auth.admin_required
|
||||||
@@ -815,7 +812,7 @@ def register_auth_routes(app) -> None:
|
|||||||
return _ok({'codes': codes, 'credits': credits, 'count': len(codes)},
|
return _ok({'codes': codes, 'credits': credits, 'count': len(codes)},
|
||||||
f'已生成 {len(codes)} 张卡密')
|
f'已生成 {len(codes)} 张卡密')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return _err(f'生成失败: {str(e)}', 500)
|
return _err(f'生成失败: {e!s}', 500)
|
||||||
|
|
||||||
@app.route('/admin/site/cards/list', methods=['POST'])
|
@app.route('/admin/site/cards/list', methods=['POST'])
|
||||||
@auth.admin_required
|
@auth.admin_required
|
||||||
@@ -884,7 +881,7 @@ def register_auth_routes(app) -> None:
|
|||||||
auth.add_log('success', _actor(), auth._client_ip(), '保存公告', str(data.get('title') or ''))
|
auth.add_log('success', _actor(), auth._client_ip(), '保存公告', str(data.get('title') or ''))
|
||||||
return _ok(message='公告已保存')
|
return _ok(message='公告已保存')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return _err(f'保存失败: {str(e)}', 500)
|
return _err(f'保存失败: {e!s}', 500)
|
||||||
|
|
||||||
# -------------------- 播放 API 密钥 --------------------
|
# -------------------- 播放 API 密钥 --------------------
|
||||||
|
|
||||||
@@ -917,7 +914,7 @@ def register_auth_routes(app) -> None:
|
|||||||
auth.add_log('success', _actor(), auth._client_ip(), '数据库备份', name)
|
auth.add_log('success', _actor(), auth._client_ip(), '数据库备份', name)
|
||||||
return _ok({'name': name}, f'备份完成:{name}')
|
return _ok({'name': name}, f'备份完成:{name}')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return _err(f'备份失败: {str(e)}', 500)
|
return _err(f'备份失败: {e!s}', 500)
|
||||||
|
|
||||||
@app.route('/admin/site/backup/list', methods=['POST'])
|
@app.route('/admin/site/backup/list', methods=['POST'])
|
||||||
@auth.admin_required
|
@auth.admin_required
|
||||||
@@ -979,7 +976,7 @@ def register_auth_routes(app) -> None:
|
|||||||
dest.unlink()
|
dest.unlink()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return _err(f'上传失败: {str(e)}', 500)
|
return _err(f'上传失败: {e!s}', 500)
|
||||||
|
|
||||||
@app.route('/admin/site/backup/restore', methods=['POST'])
|
@app.route('/admin/site/backup/restore', methods=['POST'])
|
||||||
@auth.admin_required
|
@auth.admin_required
|
||||||
@@ -992,10 +989,10 @@ def register_auth_routes(app) -> None:
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
# 第 95 条(P3-1):客户端可纠正错误(备份不存在/校验失败)返回 400,
|
# 第 95 条(P3-1):客户端可纠正错误(备份不存在/校验失败)返回 400,
|
||||||
# 与 delete 的 404 口径对齐——原实现一律 500
|
# 与 delete 的 404 口径对齐——原实现一律 500
|
||||||
return _err(f'恢复失败: {str(e)}', 400)
|
return _err(f'恢复失败: {e!s}', 400)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
auth.add_log('fail', _actor(), auth._client_ip(), '恢复数据库', str(e))
|
auth.add_log('fail', _actor(), auth._client_ip(), '恢复数据库', str(e))
|
||||||
return _err(f'恢复失败: {str(e)}', 500)
|
return _err(f'恢复失败: {e!s}', 500)
|
||||||
|
|
||||||
@app.route('/admin/site/backup/delete', methods=['POST'])
|
@app.route('/admin/site/backup/delete', methods=['POST'])
|
||||||
@auth.admin_required
|
@auth.admin_required
|
||||||
@@ -1006,7 +1003,7 @@ def register_auth_routes(app) -> None:
|
|||||||
return _ok(message='备份已删除')
|
return _ok(message='备份已删除')
|
||||||
return _err('备份文件不存在', 404)
|
return _err('备份文件不存在', 404)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return _err(f'删除失败: {str(e)}', 500)
|
return _err(f'删除失败: {e!s}', 500)
|
||||||
|
|
||||||
@app.route('/admin/site/backup/auto', methods=['POST'])
|
@app.route('/admin/site/backup/auto', methods=['POST'])
|
||||||
@auth.admin_required
|
@auth.admin_required
|
||||||
@@ -1023,10 +1020,10 @@ def register_auth_routes(app) -> None:
|
|||||||
if hours < 0:
|
if hours < 0:
|
||||||
return _err('间隔不能为负')
|
return _err('间隔不能为负')
|
||||||
auth.set_setting('backup_interval_hours', str(hours))
|
auth.set_setting('backup_interval_hours', str(hours))
|
||||||
return _ok(message='定时备份已开启,每 {} 小时自动备份'.format(hours) if hours > 0
|
return _ok(message=f'定时备份已开启,每 {hours} 小时自动备份' if hours > 0
|
||||||
else '定时备份已关闭')
|
else '定时备份已关闭')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return _err(f'设置失败: {str(e)}', 500)
|
return _err(f'设置失败: {e!s}', 500)
|
||||||
|
|
||||||
|
|
||||||
# ============ 辅助 ============
|
# ============ 辅助 ============
|
||||||
|
|||||||
+33
-32
@@ -10,14 +10,14 @@
|
|||||||
数据存储:SQLite(data/site.db),会话:Flask 签名 Cookie(secret_key 持久化)
|
数据存储:SQLite(data/site.db),会话:Flask 签名 Cookie(secret_key 持久化)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
import hashlib
|
import hashlib
|
||||||
import shutil
|
|
||||||
import json
|
import json
|
||||||
import math
|
import math
|
||||||
import base64
|
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
|
import shutil
|
||||||
import smtplib
|
import smtplib
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import ssl
|
import ssl
|
||||||
@@ -29,9 +29,9 @@ from email.mime.text import MIMEText
|
|||||||
from email.utils import formataddr
|
from email.utils import formataddr
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Any
|
||||||
|
|
||||||
from flask import request, session, g
|
from flask import g, request, session
|
||||||
|
|
||||||
# ============ 路径与常量 ============
|
# ============ 路径与常量 ============
|
||||||
DATA_DIR = Path('data')
|
DATA_DIR = Path('data')
|
||||||
@@ -59,7 +59,7 @@ def get_conn() -> sqlite3.Connection:
|
|||||||
return conn
|
return conn
|
||||||
|
|
||||||
|
|
||||||
def _q(sql: str, params: Tuple = ()) -> List[sqlite3.Row]:
|
def _q(sql: str, params: tuple = ()) -> list[sqlite3.Row]:
|
||||||
with _DB_LOCK:
|
with _DB_LOCK:
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
try:
|
try:
|
||||||
@@ -68,7 +68,7 @@ def _q(sql: str, params: Tuple = ()) -> List[sqlite3.Row]:
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
def _x(sql: str, params: Tuple = ()) -> int:
|
def _x(sql: str, params: tuple = ()) -> int:
|
||||||
"""执行写操作,返回 lastrowid / rowcount"""
|
"""执行写操作,返回 lastrowid / rowcount"""
|
||||||
with _DB_LOCK:
|
with _DB_LOCK:
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
@@ -209,7 +209,6 @@ def init_db() -> None:
|
|||||||
row = conn.execute("SELECT id FROM users WHERE role = 'admin' LIMIT 1").fetchone()
|
row = conn.execute("SELECT id FROM users WHERE role = 'admin' LIMIT 1").fetchone()
|
||||||
if not row:
|
if not row:
|
||||||
initial_pass = secrets.token_urlsafe(12)
|
initial_pass = secrets.token_urlsafe(12)
|
||||||
salt = secrets.token_hex(8)
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
'INSERT INTO users (username, password_hash, salt, role, duration_seconds, created_at) '
|
'INSERT INTO users (username, password_hash, salt, role, duration_seconds, created_at) '
|
||||||
'VALUES (?, ?, ?, ?, -1, ?)',
|
'VALUES (?, ?, ?, ?, -1, ?)',
|
||||||
@@ -270,7 +269,7 @@ def _hash_new(password: str, salt: str = '', iterations: int = _PBKDF2_ITERS) ->
|
|||||||
return f'pbkdf2${iterations}${salt}${digest}'
|
return f'pbkdf2${iterations}${salt}${digest}'
|
||||||
|
|
||||||
|
|
||||||
def _verify_password(stored: str, password: str, salt: str) -> Tuple[bool, bool]:
|
def _verify_password(stored: str, password: str, salt: str) -> tuple[bool, bool]:
|
||||||
"""校验口令。返回 (是否正确, 是否需要升级到 PBKDF2)"""
|
"""校验口令。返回 (是否正确, 是否需要升级到 PBKDF2)"""
|
||||||
if stored.startswith('pbkdf2$'):
|
if stored.startswith('pbkdf2$'):
|
||||||
try:
|
try:
|
||||||
@@ -287,7 +286,7 @@ def _verify_password(stored: str, password: str, salt: str) -> Tuple[bool, bool]
|
|||||||
def create_user(username: str, password: str, email: str = '', credits: int = 0,
|
def create_user(username: str, password: str, email: str = '', credits: int = 0,
|
||||||
role: str = 'user', device_id: str = '', ip: str = '', ua: str = '',
|
role: str = 'user', device_id: str = '', ip: str = '', ua: str = '',
|
||||||
register_ip_limit: int = 0, register_ip_window: int = 86400,
|
register_ip_limit: int = 0, register_ip_window: int = 86400,
|
||||||
register_device_once: bool = False) -> Dict[str, Any]:
|
register_device_once: bool = False) -> dict[str, Any]:
|
||||||
# F77:余额只接受 -1(不限)或 >= 0;非法负值一律按 0 处理,禁止出现 -2 这类"假不限"
|
# F77:余额只接受 -1(不限)或 >= 0;非法负值一律按 0 处理,禁止出现 -2 这类"假不限"
|
||||||
try:
|
try:
|
||||||
credits = int(credits)
|
credits = int(credits)
|
||||||
@@ -298,7 +297,6 @@ def create_user(username: str, password: str, email: str = '', credits: int = 0,
|
|||||||
# R5(第 79 条):注册入口传 register_ip_limit=3 —— 同 IP 24h 注册数检查挪进
|
# R5(第 79 条):注册入口传 register_ip_limit=3 —— 同 IP 24h 注册数检查挪进
|
||||||
# _DB_LOCK 事务内与 INSERT 原子完成,堵住"锁外检查+并发插入"的 TOCTOU 刷号;
|
# _DB_LOCK 事务内与 INSERT 原子完成,堵住"锁外检查+并发插入"的 TOCTOU 刷号;
|
||||||
# 管理建号与不限制场景传 0 关闭。超限抛 ValueError 由调用方转 400/429。
|
# 管理建号与不限制场景传 0 关闭。超限抛 ValueError 由调用方转 400/429。
|
||||||
salt = secrets.token_hex(8)
|
|
||||||
now = int(time.time())
|
now = int(time.time())
|
||||||
with _DB_LOCK:
|
with _DB_LOCK:
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
@@ -328,12 +326,12 @@ def create_user(username: str, password: str, email: str = '', credits: int = 0,
|
|||||||
return get_user_by_name(username)
|
return get_user_by_name(username)
|
||||||
|
|
||||||
|
|
||||||
def get_user_by_name(username: str) -> Optional[sqlite3.Row]:
|
def get_user_by_name(username: str) -> sqlite3.Row | None:
|
||||||
rows = _q('SELECT * FROM users WHERE username = ?', (username,))
|
rows = _q('SELECT * FROM users WHERE username = ?', (username,))
|
||||||
return rows[0] if rows else None
|
return rows[0] if rows else None
|
||||||
|
|
||||||
|
|
||||||
def get_user_by_email(email: str) -> Optional[sqlite3.Row]:
|
def get_user_by_email(email: str) -> sqlite3.Row | None:
|
||||||
"""按邮箱查用户(P3-19:注册/建号邮箱查重的友好提示;库侧另有唯一索引兜底并发)"""
|
"""按邮箱查用户(P3-19:注册/建号邮箱查重的友好提示;库侧另有唯一索引兜底并发)"""
|
||||||
email = _normalize_email(email)
|
email = _normalize_email(email)
|
||||||
if not email:
|
if not email:
|
||||||
@@ -367,12 +365,12 @@ def register_ip_exceeded(ip: str, limit: int = 3, window: int = 86400) -> bool:
|
|||||||
return bool(rows and rows[0]['n'] >= limit)
|
return bool(rows and rows[0]['n'] >= limit)
|
||||||
|
|
||||||
|
|
||||||
def get_user_by_id(uid: int) -> Optional[sqlite3.Row]:
|
def get_user_by_id(uid: int) -> sqlite3.Row | None:
|
||||||
rows = _q('SELECT * FROM users WHERE id = ?', (uid,))
|
rows = _q('SELECT * FROM users WHERE id = ?', (uid,))
|
||||||
return rows[0] if rows else None
|
return rows[0] if rows else None
|
||||||
|
|
||||||
|
|
||||||
def verify_login(username: str, password: str) -> Optional[sqlite3.Row]:
|
def verify_login(username: str, password: str) -> sqlite3.Row | None:
|
||||||
"""校验用户名密码。密码正确即返回用户行(含被禁用/封禁账号——
|
"""校验用户名密码。密码正确即返回用户行(含被禁用/封禁账号——
|
||||||
状态语义由登录路由判断:禁用显示"已被禁用"、封禁显示封禁原因。
|
状态语义由登录路由判断:禁用显示"已被禁用"、封禁显示封禁原因。
|
||||||
原实现把禁用账号提前过滤成 None,路由的"账号已被禁用"分支永不可达)"""
|
原实现把禁用账号提前过滤成 None,路由的"账号已被禁用"分支永不可达)"""
|
||||||
@@ -388,7 +386,7 @@ def verify_login(username: str, password: str) -> Optional[sqlite3.Row]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def list_users() -> List[Dict[str, Any]]:
|
def list_users() -> list[dict[str, Any]]:
|
||||||
rows = _q('SELECT * FROM users ORDER BY id DESC')
|
rows = _q('SELECT * FROM users ORDER BY id DESC')
|
||||||
return [_user_public(r, full=True) for r in rows]
|
return [_user_public(r, full=True) for r in rows]
|
||||||
|
|
||||||
@@ -449,7 +447,7 @@ def unban_user(uid: int) -> bool:
|
|||||||
return bool(_x('UPDATE users SET status = 1, ban_reason = \'\', recent_provinces = \'[]\' WHERE id = ?', (uid,)))
|
return bool(_x('UPDATE users SET status = 1, ban_reason = \'\', recent_provinces = \'[]\' WHERE id = ?', (uid,)))
|
||||||
|
|
||||||
|
|
||||||
_PROVINCE_CACHE: Dict[str, str] = {}
|
_PROVINCE_CACHE: dict[str, str] = {}
|
||||||
|
|
||||||
|
|
||||||
def _is_private_ip(ip: str) -> bool:
|
def _is_private_ip(ip: str) -> bool:
|
||||||
@@ -484,8 +482,9 @@ def query_ip_province(ip: str) -> str:
|
|||||||
return _PROVINCE_CACHE[ip]
|
return _PROVINCE_CACHE[ip]
|
||||||
province = ''
|
province = ''
|
||||||
try:
|
try:
|
||||||
import requests as _rq
|
|
||||||
import urllib.parse as _ul
|
import urllib.parse as _ul
|
||||||
|
|
||||||
|
import requests as _rq
|
||||||
r = _rq.get('http://ip-api.com/json/' + _ul.quote(ip),
|
r = _rq.get('http://ip-api.com/json/' + _ul.quote(ip),
|
||||||
params={'lang': 'zh-CN', 'fields': 'status,regionName'},
|
params={'lang': 'zh-CN', 'fields': 'status,regionName'},
|
||||||
timeout=5)
|
timeout=5)
|
||||||
@@ -560,7 +559,7 @@ def set_user_netease_cookie(username: str, cookie: str) -> None:
|
|||||||
_x('UPDATE users SET netease_cookie = ? WHERE username = ?', (cookie, username))
|
_x('UPDATE users SET netease_cookie = ? WHERE username = ?', (cookie, username))
|
||||||
|
|
||||||
|
|
||||||
def _user_public(r: sqlite3.Row, full: bool = False) -> Dict[str, Any]:
|
def _user_public(r: sqlite3.Row, full: bool = False) -> dict[str, Any]:
|
||||||
d = {
|
d = {
|
||||||
'id': r['id'],
|
'id': r['id'],
|
||||||
'username': r['username'],
|
'username': r['username'],
|
||||||
@@ -636,7 +635,7 @@ def logout_session() -> None:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def current_user() -> Optional[sqlite3.Row]:
|
def current_user() -> sqlite3.Row | None:
|
||||||
"""当前登录用户(每请求只查一次库:结果缓存到 flask.g。
|
"""当前登录用户(每请求只查一次库:结果缓存到 flask.g。
|
||||||
一次下载流程会调用本函数 5-6 次——原实现每次都新建 SQLite 连接,公网下是纯浪费。
|
一次下载流程会调用本函数 5-6 次——原实现每次都新建 SQLite 连接,公网下是纯浪费。
|
||||||
缓存含 None:未登录同样只查一次。请求内登录态不会变化(登录/注册路由不走本函数)。)"""
|
缓存含 None:未登录同样只查一次。请求内登录态不会变化(登录/注册路由不走本函数)。)"""
|
||||||
@@ -648,7 +647,7 @@ def current_user() -> Optional[sqlite3.Row]:
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
def _load_current_user() -> Optional[sqlite3.Row]:
|
def _load_current_user() -> sqlite3.Row | None:
|
||||||
uid = session.get(SESSION_KEY)
|
uid = session.get(SESSION_KEY)
|
||||||
if not uid:
|
if not uid:
|
||||||
return None
|
return None
|
||||||
@@ -848,7 +847,7 @@ def ledger_open(task_id: str, user_id: int, reserved: int) -> None:
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
def reserve_and_open(uid: int, count: int, ledger_key: str) -> Optional[int]:
|
def reserve_and_open(uid: int, count: int, ledger_key: str) -> int | None:
|
||||||
"""A2/B8:预扣与开账同一事务(原两步之间存在崩溃丢账窗口)。
|
"""A2/B8:预扣与开账同一事务(原两步之间存在崩溃丢账窗口)。
|
||||||
足额:扣减+开账并返回剩余;不限/管理员:开账(reserved=0)返回 -1;不足:返回 None(不开账)。"""
|
足额:扣减+开账并返回剩余;不限/管理员:开账(reserved=0)返回 -1;不足:返回 None(不开账)。"""
|
||||||
count = max(1, int(count))
|
count = max(1, int(count))
|
||||||
@@ -941,7 +940,7 @@ def ledger_supplement(task_id: str, extra: int) -> bool:
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
def ledger_get(task_id: str) -> Optional[Dict[str, Any]]:
|
def ledger_get(task_id: str) -> dict[str, Any] | None:
|
||||||
rows = _q('SELECT * FROM billing_ledger WHERE task_id = ?', (str(task_id),))
|
rows = _q('SELECT * FROM billing_ledger WHERE task_id = ?', (str(task_id),))
|
||||||
return dict(rows[0]) if rows else None
|
return dict(rows[0]) if rows else None
|
||||||
|
|
||||||
@@ -1016,7 +1015,7 @@ def ledger_cleanup_settled(max_age_seconds: int = 90 * 86400) -> int:
|
|||||||
# ============ 装饰器 ============
|
# ============ 装饰器 ============
|
||||||
|
|
||||||
|
|
||||||
def _deny(message: str, code: int = 401, error_code: str = 'unauthorized') -> Tuple[Dict[str, Any], int]:
|
def _deny(message: str, code: int = 401, error_code: str = 'unauthorized') -> tuple[dict[str, Any], int]:
|
||||||
return ({'status': code, 'success': False, 'message': message, 'error_code': error_code}, code)
|
return ({'status': code, 'success': False, 'message': message, 'error_code': error_code}, code)
|
||||||
|
|
||||||
|
|
||||||
@@ -1174,8 +1173,9 @@ _SMTP_ENC_PREFIX = 'enc1:' # 第 95 条:信封加密标记前缀(存量明
|
|||||||
def _smtp_password_encrypt(plaintext: str) -> str:
|
def _smtp_password_encrypt(plaintext: str) -> str:
|
||||||
"""用 data/secret.key 派生 Fernet 密钥加密 SMTP 授权码(第 95 条 P2-7:
|
"""用 data/secret.key 派生 Fernet 密钥加密 SMTP 授权码(第 95 条 P2-7:
|
||||||
原明文落库+随备份导出=泄露半径扩大;注释编号笔误已按第 97 条纠正)。"""
|
原明文落库+随备份导出=泄露半径扩大;注释编号笔误已按第 97 条纠正)。"""
|
||||||
from cryptography.fernet import Fernet
|
|
||||||
import hashlib as _hl
|
import hashlib as _hl
|
||||||
|
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
key = _hl.sha256(get_secret_key().encode('utf-8')).digest()
|
key = _hl.sha256(get_secret_key().encode('utf-8')).digest()
|
||||||
f = Fernet(base64.urlsafe_b64encode(key))
|
f = Fernet(base64.urlsafe_b64encode(key))
|
||||||
return _SMTP_ENC_PREFIX + f.encrypt(plaintext.encode('utf-8')).decode('utf-8')
|
return _SMTP_ENC_PREFIX + f.encrypt(plaintext.encode('utf-8')).decode('utf-8')
|
||||||
@@ -1184,14 +1184,15 @@ def _smtp_password_encrypt(plaintext: str) -> str:
|
|||||||
def _smtp_password_decrypt(ciphertext: str) -> str:
|
def _smtp_password_decrypt(ciphertext: str) -> str:
|
||||||
if not ciphertext.startswith(_SMTP_ENC_PREFIX):
|
if not ciphertext.startswith(_SMTP_ENC_PREFIX):
|
||||||
return ciphertext # 存量明文:透明兼容,下次保存时升级为密文
|
return ciphertext # 存量明文:透明兼容,下次保存时升级为密文
|
||||||
from cryptography.fernet import Fernet
|
|
||||||
import hashlib as _hl
|
import hashlib as _hl
|
||||||
|
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
key = _hl.sha256(get_secret_key().encode('utf-8')).digest()
|
key = _hl.sha256(get_secret_key().encode('utf-8')).digest()
|
||||||
f = Fernet(base64.urlsafe_b64encode(key))
|
f = Fernet(base64.urlsafe_b64encode(key))
|
||||||
return f.decrypt(ciphertext[len(_SMTP_ENC_PREFIX):].encode('utf-8')).decode('utf-8')
|
return f.decrypt(ciphertext[len(_SMTP_ENC_PREFIX):].encode('utf-8')).decode('utf-8')
|
||||||
|
|
||||||
|
|
||||||
def get_smtp_config() -> Dict[str, Any]:
|
def get_smtp_config() -> dict[str, Any]:
|
||||||
rows = _q('SELECT * FROM smtp_config WHERE id = 1')
|
rows = _q('SELECT * FROM smtp_config WHERE id = 1')
|
||||||
cfg = dict(rows[0]) if rows else {'host': '', 'port': 587, 'sender': '', 'password': ''}
|
cfg = dict(rows[0]) if rows else {'host': '', 'port': 587, 'sender': '', 'password': ''}
|
||||||
# 第 95 条:读取时解密密码——调用方(SMTP 连接/后台展示)拿到的始终是明文
|
# 第 95 条:读取时解密密码——调用方(SMTP 连接/后台展示)拿到的始终是明文
|
||||||
@@ -1399,7 +1400,7 @@ def restore_verify_code(email: str, code: str, ttl_seconds: int, purpose: str =
|
|||||||
|
|
||||||
|
|
||||||
def generate_cards(count: int, credits: int, created_by: str = '',
|
def generate_cards(count: int, credits: int, created_by: str = '',
|
||||||
prefix: str = '', note: str = '', valid_days: int = 0) -> List[str]:
|
prefix: str = '', note: str = '', valid_days: int = 0) -> list[str]:
|
||||||
"""生成次数卡密(credits = 每张卡可下载次数)
|
"""生成次数卡密(credits = 每张卡可下载次数)
|
||||||
prefix: 自定义卡密前缀(默认 VIP-)
|
prefix: 自定义卡密前缀(默认 VIP-)
|
||||||
note: 备注信息
|
note: 备注信息
|
||||||
@@ -1425,7 +1426,7 @@ def generate_cards(count: int, credits: int, created_by: str = '',
|
|||||||
return codes
|
return codes
|
||||||
|
|
||||||
|
|
||||||
def list_cards(limit: int = 100, offset: int = 0) -> List[Dict[str, Any]]:
|
def list_cards(limit: int = 100, offset: int = 0) -> list[dict[str, Any]]:
|
||||||
"""卡密列表(F85:支持分页 limit/offset,管理路由透传)"""
|
"""卡密列表(F85:支持分页 limit/offset,管理路由透传)"""
|
||||||
limit = max(1, min(int(limit), 500))
|
limit = max(1, min(int(limit), 500))
|
||||||
offset = max(0, int(offset))
|
offset = max(0, int(offset))
|
||||||
@@ -1485,7 +1486,7 @@ def delete_card(code: str) -> None:
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
def redeem_card(code: str, username: str) -> Tuple[int, int]:
|
def redeem_card(code: str, username: str) -> tuple[int, int]:
|
||||||
"""兑换卡密,返回 (卡密次数, 新剩余次数)(F25:标记已用与加余额同事务,失败整体回滚不吞卡)"""
|
"""兑换卡密,返回 (卡密次数, 新剩余次数)(F25:标记已用与加余额同事务,失败整体回滚不吞卡)"""
|
||||||
code = (code or '').strip().upper()
|
code = (code or '').strip().upper()
|
||||||
with _DB_LOCK:
|
with _DB_LOCK:
|
||||||
@@ -1533,7 +1534,7 @@ def redeem_card(code: str, username: str) -> Tuple[int, int]:
|
|||||||
# ============ 公告 ============
|
# ============ 公告 ============
|
||||||
|
|
||||||
|
|
||||||
def get_announcement() -> Dict[str, Any]:
|
def get_announcement() -> dict[str, Any]:
|
||||||
rows = _q('SELECT * FROM announcements WHERE id = 1')
|
rows = _q('SELECT * FROM announcements WHERE id = 1')
|
||||||
return dict(rows[0]) if rows else {'title': '', 'content': '', 'popup_mode': 'off'}
|
return dict(rows[0]) if rows else {'title': '', 'content': '', 'popup_mode': 'off'}
|
||||||
|
|
||||||
@@ -1606,7 +1607,7 @@ def create_backup() -> str:
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
def list_backups() -> List[Dict[str, Any]]:
|
def list_backups() -> list[dict[str, Any]]:
|
||||||
"""备份列表(F44:按文件修改时间倒序——按名字排序会让 upload_ 永远排前,误删新自动备份)"""
|
"""备份列表(F44:按文件修改时间倒序——按名字排序会让 upload_ 永远排前,误删新自动备份)"""
|
||||||
if not BACKUP_DIR.exists():
|
if not BACKUP_DIR.exists():
|
||||||
return []
|
return []
|
||||||
@@ -1622,7 +1623,7 @@ def list_backups() -> List[Dict[str, Any]]:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def backup_path(name: str) -> Optional[Path]:
|
def backup_path(name: str) -> Path | None:
|
||||||
name = Path(name).name # 防目录穿越
|
name = Path(name).name # 防目录穿越
|
||||||
p = BACKUP_DIR / name
|
p = BACKUP_DIR / name
|
||||||
return p if p.exists() and p.suffix == '.db' else None
|
return p if p.exists() and p.suffix == '.db' else None
|
||||||
|
|||||||
@@ -9,16 +9,13 @@
|
|||||||
- 健康检查
|
- 健康检查
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import base64
|
|
||||||
import json
|
|
||||||
import collections
|
import collections
|
||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import requests
|
|
||||||
import secrets
|
|
||||||
import shutil
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
@@ -26,27 +23,45 @@ import time
|
|||||||
import traceback
|
import traceback
|
||||||
import uuid
|
import uuid
|
||||||
import zipfile
|
import zipfile
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, Any, Optional, Tuple, List
|
from typing import Any
|
||||||
from urllib.parse import quote, urlsplit, parse_qs
|
from urllib.parse import parse_qs, quote, urlsplit
|
||||||
from flask import Flask, request, send_file, render_template, Response, jsonify, stream_with_context, make_response, g
|
|
||||||
|
import requests
|
||||||
|
from flask import Flask, Response, g, make_response, render_template, request, send_file, stream_with_context
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from music_api import (
|
|
||||||
NeteaseAPI, APIException, QualityLevel,
|
|
||||||
url_v1, name_v1, lyric_v1, search_music,
|
|
||||||
playlist_detail, album_detail,
|
|
||||||
toplist_list, artist_songs, song_comments,
|
|
||||||
account_info, user_playlists, detect_vip_by_probe,
|
|
||||||
playlist_subscribe, album_subscribe, post_comment,
|
|
||||||
load_cookies,
|
|
||||||
)
|
|
||||||
from music_downloader import MusicDownloader, DownloadException, DownloadCanceled, CountingSemaphore, fetch_cover_bytes
|
|
||||||
from qr_login import QRLoginClient
|
|
||||||
import podcast_api
|
|
||||||
import auth_system
|
import auth_system
|
||||||
|
import podcast_api
|
||||||
from auth_routes import register_auth_routes
|
from auth_routes import register_auth_routes
|
||||||
|
from music_api import (
|
||||||
|
APIException,
|
||||||
|
NeteaseAPI,
|
||||||
|
account_info,
|
||||||
|
album_detail,
|
||||||
|
album_subscribe,
|
||||||
|
artist_songs,
|
||||||
|
detect_vip_by_probe,
|
||||||
|
load_cookies,
|
||||||
|
lyric_v1,
|
||||||
|
name_v1,
|
||||||
|
playlist_detail,
|
||||||
|
playlist_subscribe,
|
||||||
|
post_comment,
|
||||||
|
search_music,
|
||||||
|
song_comments,
|
||||||
|
toplist_list,
|
||||||
|
url_v1,
|
||||||
|
user_playlists,
|
||||||
|
)
|
||||||
|
from music_downloader import (
|
||||||
|
CountingSemaphore,
|
||||||
|
DownloadCanceled,
|
||||||
|
DownloadException,
|
||||||
|
MusicDownloader,
|
||||||
|
fetch_cover_bytes,
|
||||||
|
)
|
||||||
|
from qr_login import QRLoginClient
|
||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
# 坑 11:导入失败发生在 _safe_console_print 定义之前,用纯 ASCII 保证任何编码下可打印
|
# 坑 11:导入失败发生在 _safe_console_print 定义之前,用纯 ASCII 保证任何编码下可打印
|
||||||
print("ImportError: " + str(e))
|
print("ImportError: " + str(e))
|
||||||
@@ -58,35 +73,46 @@ except ImportError as e:
|
|||||||
# 常量/配置、运行时状态、纯工具、音频签名已迁出至 core 包,以**同一对象/同一实现**
|
# 常量/配置、运行时状态、纯工具、音频签名已迁出至 core 包,以**同一对象/同一实现**
|
||||||
# 供 Flask 与迁移目标 FastAPI 共用。此处以原名称再导出(别名),既有 250+ 调用点零改动。
|
# 供 Flask 与迁移目标 FastAPI 共用。此处以原名称再导出(别名),既有 250+ 调用点零改动。
|
||||||
from core.config import (
|
from core.config import (
|
||||||
APP_VERSION, RESULT_RETENTION_SECONDS, Settings,
|
APP_VERSION,
|
||||||
|
BATCH_ZIP_CONCURRENCY,
|
||||||
|
CANCEL_AFTER_SECONDS,
|
||||||
|
HEARTBEAT_INTERVAL,
|
||||||
|
MAX_ACTIVE_TASKS_PER_USER,
|
||||||
|
MAX_TERMINAL_TASKS,
|
||||||
|
MAX_TOTAL_TASKS,
|
||||||
|
PAUSE_AFTER_SECONDS,
|
||||||
|
RESULT_RETENTION_SECONDS,
|
||||||
USER_COOKIE_FILE,
|
USER_COOKIE_FILE,
|
||||||
PAUSE_AFTER_SECONDS, CANCEL_AFTER_SECONDS, HEARTBEAT_INTERVAL,
|
VALID_QUALITY_LEVELS,
|
||||||
MAX_ACTIVE_TASKS_PER_USER, MAX_TOTAL_TASKS, MAX_TERMINAL_TASKS,
|
Settings,
|
||||||
BATCH_ZIP_CONCURRENCY, VALID_QUALITY_LEVELS,
|
|
||||||
)
|
)
|
||||||
|
from core.security import init_secret_key as _init_audio_secret
|
||||||
|
from core.security import protect_audio_urls as _protect_audio_urls
|
||||||
|
from core.security import verify_audio_token as _verify_audio_token
|
||||||
from core.state import (
|
from core.state import (
|
||||||
RATE_BUCKETS as _RATE_BUCKETS, RATE_LOCK as _RATE_LOCK,
|
BATCH_DURATIONS,
|
||||||
BATCH_DURATIONS, BATCH_DURATIONS_LOCK,
|
BATCH_DURATIONS_LOCK,
|
||||||
DOWNLOAD_TASKS, DOWNLOAD_TASKS_LOCK,
|
DEVICE_LAST_SEEN,
|
||||||
DEVICE_LAST_SEEN, DEVICE_LAST_SEEN_LOCK,
|
DEVICE_LAST_SEEN_LOCK,
|
||||||
REQUEST_STATS, STATS_LOCK as _STATS_LOCK, REQ_LOG,
|
DOWNLOAD_TASKS,
|
||||||
GLOBAL_MUSIC_U_CACHE as _GLOBAL_MUSIC_U_CACHE,
|
DOWNLOAD_TASKS_LOCK,
|
||||||
VIP_CACHE as _VIP_CACHE, VIP_USER_CACHE as _VIP_USER_CACHE,
|
REQ_LOG,
|
||||||
SERVER_NICK_CACHE as _SERVER_NICK_CACHE, SERVER_NICK_LOCK as _SERVER_NICK_LOCK,
|
REQUEST_STATS,
|
||||||
VIP_PROBE_LOCK as _VIP_PROBE_LOCK,
|
|
||||||
)
|
|
||||||
from core.utils import (
|
|
||||||
safe_int as _safe_int, safe_console_print as _safe_console_print,
|
|
||||||
srt_time as _srt_time, format_lyric as _format_lyric,
|
|
||||||
parse_meta_fields as _parse_meta_fields, expand_shortlink as _expand_shortlink,
|
|
||||||
)
|
|
||||||
from core.security import (
|
|
||||||
init_secret_key as _init_audio_secret,
|
|
||||||
is_audio_url as _is_audio_url,
|
|
||||||
make_audio_token as _make_audio_token,
|
|
||||||
verify_audio_token as _verify_audio_token,
|
|
||||||
protect_audio_urls as _protect_audio_urls,
|
|
||||||
)
|
)
|
||||||
|
from core.state import GLOBAL_MUSIC_U_CACHE as _GLOBAL_MUSIC_U_CACHE
|
||||||
|
from core.state import RATE_BUCKETS as _RATE_BUCKETS
|
||||||
|
from core.state import RATE_LOCK as _RATE_LOCK
|
||||||
|
from core.state import SERVER_NICK_CACHE as _SERVER_NICK_CACHE
|
||||||
|
from core.state import SERVER_NICK_LOCK as _SERVER_NICK_LOCK
|
||||||
|
from core.state import STATS_LOCK as _STATS_LOCK
|
||||||
|
from core.state import VIP_CACHE as _VIP_CACHE
|
||||||
|
from core.state import VIP_PROBE_LOCK as _VIP_PROBE_LOCK
|
||||||
|
from core.state import VIP_USER_CACHE as _VIP_USER_CACHE
|
||||||
|
from core.utils import expand_shortlink as _expand_shortlink
|
||||||
|
from core.utils import format_lyric as _format_lyric
|
||||||
|
from core.utils import parse_meta_fields as _parse_meta_fields
|
||||||
|
from core.utils import safe_console_print as _safe_console_print
|
||||||
|
from core.utils import safe_int as _safe_int
|
||||||
|
|
||||||
# 向后兼容别名:既有代码/文档沿用 APIConfig 名称
|
# 向后兼容别名:既有代码/文档沿用 APIConfig 名称
|
||||||
APIConfig = Settings
|
APIConfig = Settings
|
||||||
@@ -96,7 +122,7 @@ class APIResponse:
|
|||||||
"""API响应工具类"""
|
"""API响应工具类"""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def success(data: Any = None, message: str = 'success', status_code: int = 200) -> Tuple[Dict[str, Any], int]:
|
def success(data: Any = None, message: str = 'success', status_code: int = 200) -> tuple[dict[str, Any], int]:
|
||||||
"""成功响应"""
|
"""成功响应"""
|
||||||
response = {
|
response = {
|
||||||
'status': status_code,
|
'status': status_code,
|
||||||
@@ -108,7 +134,7 @@ class APIResponse:
|
|||||||
return response, status_code
|
return response, status_code
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def error(message: str, status_code: int = 400, error_code: str = None) -> Tuple[Dict[str, Any], int]:
|
def error(message: str, status_code: int = 400, error_code: str | None = None) -> tuple[dict[str, Any], int]:
|
||||||
"""错误响应"""
|
"""错误响应"""
|
||||||
response = {
|
response = {
|
||||||
'status': status_code,
|
'status': status_code,
|
||||||
@@ -166,7 +192,7 @@ class MusicAPIService:
|
|||||||
|
|
||||||
return logger
|
return logger
|
||||||
|
|
||||||
def _get_cookies(self) -> Dict[str, str]:
|
def _get_cookies(self) -> dict[str, str]:
|
||||||
"""直接从 cookie.txt 读取并解析为 dict"""
|
"""直接从 cookie.txt 读取并解析为 dict"""
|
||||||
try:
|
try:
|
||||||
cookies = load_cookies()
|
cookies = load_cookies()
|
||||||
@@ -240,14 +266,14 @@ class MusicAPIService:
|
|||||||
}
|
}
|
||||||
return quality_names.get(quality, f"未知音质({quality})")
|
return quality_names.get(quality, f"未知音质({quality})")
|
||||||
|
|
||||||
def _validate_request_params(self, required_params: Dict[str, Any]) -> Optional[Tuple[Dict[str, Any], int]]:
|
def _validate_request_params(self, required_params: dict[str, Any]) -> tuple[dict[str, Any], int] | None:
|
||||||
"""验证请求参数"""
|
"""验证请求参数"""
|
||||||
for param_name, param_value in required_params.items():
|
for param_name, param_value in required_params.items():
|
||||||
if not param_value:
|
if not param_value:
|
||||||
return APIResponse.error(f"参数 '{param_name}' 不能为空", 400)
|
return APIResponse.error(f"参数 '{param_name}' 不能为空", 400)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _safe_get_request_data(self) -> Dict[str, Any]:
|
def _safe_get_request_data(self) -> dict[str, Any]:
|
||||||
"""安全获取请求数据"""
|
"""安全获取请求数据"""
|
||||||
try:
|
try:
|
||||||
if request.method == 'GET':
|
if request.method == 'GET':
|
||||||
@@ -541,7 +567,6 @@ def api_task_cancel():
|
|||||||
try:
|
try:
|
||||||
data = api_service._safe_get_request_data()
|
data = api_service._safe_get_request_data()
|
||||||
task_id = str(data.get('task_id') or '').strip()
|
task_id = str(data.get('task_id') or '').strip()
|
||||||
device_id = str(data.get('device_id') or '').strip()[:64] # 第 98 条:与注册同口径钳 64(防超长键灌爆 DEVICE_LAST_SEEN)
|
|
||||||
if not task_id:
|
if not task_id:
|
||||||
return APIResponse.error("缺少 task_id 参数")
|
return APIResponse.error("缺少 task_id 参数")
|
||||||
with DOWNLOAD_TASKS_LOCK:
|
with DOWNLOAD_TASKS_LOCK:
|
||||||
@@ -685,7 +710,7 @@ BATCH_ZIP_SEM = CountingSemaphore(BATCH_ZIP_CONCURRENCY)
|
|||||||
# USER_COOKIE_FILE → core.config
|
# USER_COOKIE_FILE → core.config
|
||||||
|
|
||||||
|
|
||||||
def _register_download_task(task: Dict[str, Any], reserved: int, task_id_hint: str = '') -> Optional[str]:
|
def _register_download_task(task: dict[str, Any], reserved: int, task_id_hint: str = '') -> str | None:
|
||||||
"""任务登记统一入口:归属校验、数量上限、账本承接。失败返回错误描述(str),成功返回 None。
|
"""任务登记统一入口:归属校验、数量上限、账本承接。失败返回错误描述(str),成功返回 None。
|
||||||
|
|
||||||
- F05:任务必须绑定站点用户(提交接口都有 login_required,这里再取一次会话)
|
- F05:任务必须绑定站点用户(提交接口都有 login_required,这里再取一次会话)
|
||||||
@@ -759,7 +784,7 @@ def _register_download_task(task: Dict[str, Any], reserved: int, task_id_hint: s
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _start_task_thread(task_id: str, target, args=()) -> Optional[str]:
|
def _start_task_thread(task_id: str, target, args=()) -> str | None:
|
||||||
"""启动任务 worker(F90:启动失败时收敛任务终态并按账本退费,不留僵尸 queued 记录)。
|
"""启动任务 worker(F90:启动失败时收敛任务终态并按账本退费,不留僵尸 queued 记录)。
|
||||||
A2:退款只走账本(本处 settle 后装饰器看到账已关,不会重复退)。
|
A2:退款只走账本(本处 settle 后装饰器看到账已关,不会重复退)。
|
||||||
R5-14:线程句柄存入任务字典——监控循环对 4h 超时任务若发现 worker 线程已死
|
R5-14:线程句柄存入任务字典——监控循环对 4h 超时任务若发现 worker 线程已死
|
||||||
@@ -784,7 +809,7 @@ def _start_task_thread(task_id: str, target, args=()) -> Optional[str]:
|
|||||||
return f'任务启动失败:{e}'
|
return f'任务启动失败:{e}'
|
||||||
|
|
||||||
|
|
||||||
def _task_owned_by_current_user(task: Dict[str, Any]) -> bool:
|
def _task_owned_by_current_user(task: dict[str, Any]) -> bool:
|
||||||
"""任务归属校验(F05:站点用户是任务的第一归属,device_id 仅作辅助)"""
|
"""任务归属校验(F05:站点用户是任务的第一归属,device_id 仅作辅助)"""
|
||||||
user = auth_system.current_user()
|
user = auth_system.current_user()
|
||||||
if not user:
|
if not user:
|
||||||
@@ -823,7 +848,7 @@ def _mark_task_terminal(task_id: str, status: str, msg: str = '') -> None:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _task_files(task: Dict[str, Any]) -> List[str]:
|
def _task_files(task: dict[str, Any]) -> list[str]:
|
||||||
"""任务当前关联的可清理文件路径(交付 ZIP/交付副本;缓存文件不在此列)"""
|
"""任务当前关联的可清理文件路径(交付 ZIP/交付副本;缓存文件不在此列)"""
|
||||||
result = task.get('result') or {}
|
result = task.get('result') or {}
|
||||||
files = []
|
files = []
|
||||||
@@ -875,9 +900,9 @@ def _task_monitor_loop() -> None:
|
|||||||
# ---- 终态任务清理(F38/F106:按完成时刻;删除失败保留记录下轮重试,最多 8 次)----
|
# ---- 终态任务清理(F38/F106:按完成时刻;删除失败保留记录下轮重试,最多 8 次)----
|
||||||
# 第 91 条(P2-1):锁内只收集过期清单,文件删除移到锁外——GB 级任务目录
|
# 第 91 条(P2-1):锁内只收集过期清单,文件删除移到锁外——GB 级任务目录
|
||||||
# 的删除曾阻塞锁数十秒,期间全站进度上报/提交/取消/领取全部停摆
|
# 的删除曾阻塞锁数十秒,期间全站进度上报/提交/取消/领取全部停摆
|
||||||
finished_done: List[str] = []
|
finished_done: list[str] = []
|
||||||
finished_retry: List[str] = []
|
finished_retry: list[str] = []
|
||||||
expired: List[tuple] = []
|
expired: list[tuple] = []
|
||||||
with DOWNLOAD_TASKS_LOCK:
|
with DOWNLOAD_TASKS_LOCK:
|
||||||
for tid, task in DOWNLOAD_TASKS.items():
|
for tid, task in DOWNLOAD_TASKS.items():
|
||||||
if task.get('status') not in ('done', 'error', 'canceled'):
|
if task.get('status') not in ('done', 'error', 'canceled'):
|
||||||
@@ -890,7 +915,7 @@ def _task_monitor_loop() -> None:
|
|||||||
continue
|
continue
|
||||||
expired.append((tid, _task_files(task),
|
expired.append((tid, _task_files(task),
|
||||||
api_service.downloads_path / 'tasks' / tid))
|
api_service.downloads_path / 'tasks' / tid))
|
||||||
cleanup_results: List[tuple] = []
|
cleanup_results: list[tuple] = []
|
||||||
for tid, files, task_dir in expired:
|
for tid, files, task_dir in expired:
|
||||||
all_removed = True
|
all_removed = True
|
||||||
for f in files:
|
for f in files:
|
||||||
@@ -929,7 +954,7 @@ def _task_monitor_loop() -> None:
|
|||||||
if finished_retry:
|
if finished_retry:
|
||||||
api_service.logger.info(f"监控线程保留清理失败任务待重试: {finished_retry}")
|
api_service.logger.info(f"监控线程保留清理失败任务待重试: {finished_retry}")
|
||||||
# ---- 长时间未结束的任务:置取消(含 cancel Event,唤醒可能的阻塞等待)----
|
# ---- 长时间未结束的任务:置取消(含 cancel Event,唤醒可能的阻塞等待)----
|
||||||
timeout_settle: List[str] = []
|
timeout_settle: list[str] = []
|
||||||
with DOWNLOAD_TASKS_LOCK:
|
with DOWNLOAD_TASKS_LOCK:
|
||||||
for tid, task in DOWNLOAD_TASKS.items():
|
for tid, task in DOWNLOAD_TASKS.items():
|
||||||
if (task.get('status') in ('queued', 'running', 'paused')
|
if (task.get('status') in ('queued', 'running', 'paused')
|
||||||
@@ -1118,9 +1143,9 @@ def _record_log(action: str, ip: str, song: str) -> None:
|
|||||||
del REQ_LOG[:len(REQ_LOG) - 2000]
|
del REQ_LOG[:len(REQ_LOG) - 2000]
|
||||||
|
|
||||||
|
|
||||||
def _parse_cookie_str(raw) -> Dict[str, str]:
|
def _parse_cookie_str(raw) -> dict[str, str]:
|
||||||
"""将 'k=v;k2=v2' 格式的 Cookie 字符串解析为字典"""
|
"""将 'k=v;k2=v2' 格式的 Cookie 字符串解析为字典"""
|
||||||
cookies: Dict[str, str] = {}
|
cookies: dict[str, str] = {}
|
||||||
if not raw:
|
if not raw:
|
||||||
return cookies
|
return cookies
|
||||||
for part in str(raw).split(';'):
|
for part in str(raw).split(';'):
|
||||||
@@ -1154,7 +1179,7 @@ def _global_music_u_cache_reset() -> None:
|
|||||||
_GLOBAL_MUSIC_U_CACHE['ts'] = 0.0
|
_GLOBAL_MUSIC_U_CACHE['ts'] = 0.0
|
||||||
|
|
||||||
|
|
||||||
def _resolve_user_cookies(data: Dict[str, Any]) -> Dict[str, str]:
|
def _resolve_user_cookies(data: dict[str, Any]) -> dict[str, str]:
|
||||||
"""解析个人账号 Cookie:仅使用浏览器传入的 user_cookie(不落服务器,杜绝跨设备访问他人账号)。
|
"""解析个人账号 Cookie:仅使用浏览器传入的 user_cookie(不落服务器,杜绝跨设备访问他人账号)。
|
||||||
第 92 条(owner 拍板):全局账号(cookie.txt)在发评/收藏/下载等个人身份操作上
|
第 92 条(owner 拍板):全局账号(cookie.txt)在发评/收藏/下载等个人身份操作上
|
||||||
零权限、零功能、零入口——浏览器提交的 MUSIC_U 若与全局账号一致(全局 Cookie
|
零权限、零功能、零入口——浏览器提交的 MUSIC_U 若与全局账号一致(全局 Cookie
|
||||||
@@ -1177,7 +1202,7 @@ def _resolve_user_cookies(data: Dict[str, Any]) -> Dict[str, str]:
|
|||||||
return ck
|
return ck
|
||||||
|
|
||||||
|
|
||||||
def _build_download_base(filename_format: str, music_info: Dict[str, Any]) -> str:
|
def _build_download_base(filename_format: str, music_info: dict[str, Any]) -> str:
|
||||||
"""按文件名格式生成下载基础名(不含扩展名)"""
|
"""按文件名格式生成下载基础名(不含扩展名)"""
|
||||||
name = music_info.get('name', '未知歌曲')
|
name = music_info.get('name', '未知歌曲')
|
||||||
artist = music_info.get('artist_string', '') or '未知歌手'
|
artist = music_info.get('artist_string', '') or '未知歌手'
|
||||||
@@ -1199,7 +1224,7 @@ def _build_download_base(filename_format: str, music_info: Dict[str, Any]) -> st
|
|||||||
# _srt_time / _format_lyric / _parse_meta_fields 已迁出至 core.utils(此处 import 同名共享)
|
# _srt_time / _format_lyric / _parse_meta_fields 已迁出至 core.utils(此处 import 同名共享)
|
||||||
|
|
||||||
|
|
||||||
def _verify_admin_password(data) -> Optional[Tuple[Dict[str, Any], int]]:
|
def _verify_admin_password(data) -> tuple[dict[str, Any], int] | None:
|
||||||
"""校验管理员权限:仅站点管理员会话(旧版口令已废除,2026-09-15)"""
|
"""校验管理员权限:仅站点管理员会话(旧版口令已废除,2026-09-15)"""
|
||||||
_user = auth_system.current_user()
|
_user = auth_system.current_user()
|
||||||
if _user is not None and _user['role'] == 'admin':
|
if _user is not None and _user['role'] == 'admin':
|
||||||
@@ -1219,7 +1244,7 @@ def _vip_name(vip_type: int) -> str:
|
|||||||
}.get(vip_type, '普通用户')
|
}.get(vip_type, '普通用户')
|
||||||
|
|
||||||
|
|
||||||
def _detect_vip(cookies: Dict[str, str]) -> Tuple[int, str, List[str]]:
|
def _detect_vip(cookies: dict[str, str]) -> tuple[int, str, list[str]]:
|
||||||
"""检测账号会员类型与可解析音质:
|
"""检测账号会员类型与可解析音质:
|
||||||
优先账号接口(vipType 精确),并始终用音质探测接口实测可解析列表。
|
优先账号接口(vipType 精确),并始终用音质探测接口实测可解析列表。
|
||||||
第 100 条:探测单飞锁(有界等待 0.5s)——上游黑洞时并发请求不再各自串行
|
第 100 条:探测单飞锁(有界等待 0.5s)——上游黑洞时并发请求不再各自串行
|
||||||
@@ -1669,7 +1694,7 @@ def search_music_api():
|
|||||||
return APIResponse.success(result, "搜索完成")
|
return APIResponse.success(result, "搜索完成")
|
||||||
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return APIResponse.error(f"参数格式错误: {str(e)}")
|
return APIResponse.error(f"参数格式错误: {e!s}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
api_service.logger.error(f"搜索音乐异常: {e}\n{traceback.format_exc()}")
|
api_service.logger.error(f"搜索音乐异常: {e}\n{traceback.format_exc()}")
|
||||||
return APIResponse.error("服务暂时不可用,请稍后再试", 500)
|
return APIResponse.error("服务暂时不可用,请稍后再试", 500)
|
||||||
@@ -2219,8 +2244,8 @@ def _podcast_stream_download(url: str, target: Path, total_size: int,
|
|||||||
return done
|
return done
|
||||||
|
|
||||||
|
|
||||||
def _podcast_resolve_audio(program: Dict[str, Any], quality: str,
|
def _podcast_resolve_audio(program: dict[str, Any], quality: str,
|
||||||
cookies: Dict[str, str]) -> Dict[str, Any]:
|
cookies: dict[str, str]) -> dict[str, Any]:
|
||||||
"""解析单集音频直链,失败抛 DownloadException"""
|
"""解析单集音频直链,失败抛 DownloadException"""
|
||||||
song_id = program.get('main_song_id')
|
song_id = program.get('main_song_id')
|
||||||
if not song_id:
|
if not song_id:
|
||||||
@@ -2255,7 +2280,7 @@ def get_song_comments_api():
|
|||||||
result = song_comments(song_id, limit, offset)
|
result = song_comments(song_id, limit, offset)
|
||||||
return APIResponse.success(result, "获取评论成功")
|
return APIResponse.success(result, "获取评论成功")
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return APIResponse.error(f"参数格式错误: {str(e)}")
|
return APIResponse.error(f"参数格式错误: {e!s}")
|
||||||
except APIException as e:
|
except APIException as e:
|
||||||
api_service.logger.error(f"获取评论失败: {e}")
|
api_service.logger.error(f"获取评论失败: {e}")
|
||||||
return APIResponse.error("服务暂时不可用,请稍后再试", 500)
|
return APIResponse.error("服务暂时不可用,请稍后再试", 500)
|
||||||
@@ -2687,8 +2712,7 @@ def user_comment():
|
|||||||
|
|
||||||
|
|
||||||
# ============ 全局账号歌单(管理员 cookie.txt,无需游客扫码) ============
|
# ============ 全局账号歌单(管理员 cookie.txt,无需游客扫码) ============
|
||||||
|
# VALID_QUALITY_LEVELS 已迁至 core.config(顶部 import 同名共享,此处不再重复定义)
|
||||||
VALID_QUALITY_LEVELS = ['standard', 'exhigh', 'lossless', 'hires', 'sky', 'jyeffect', 'jymaster', 'dolby']
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/global/playlists', methods=['GET', 'POST'])
|
@app.route('/global/playlists', methods=['GET', 'POST'])
|
||||||
@@ -3007,7 +3031,6 @@ def download_music_api():
|
|||||||
# R5-P3:对外泛化(error_message 可能含上游细节),原文留日志
|
# R5-P3:对外泛化(error_message 可能含上游细节),原文留日志
|
||||||
api_service.logger.error(f"下载失败: {download_result.error_message}")
|
api_service.logger.error(f"下载失败: {download_result.error_message}")
|
||||||
return APIResponse.error("下载失败,请稍后重试或更换音质", 500)
|
return APIResponse.error("下载失败,请稍后重试或更换音质", 500)
|
||||||
file_path = Path(download_result.file_path)
|
|
||||||
api_service.logger.info(f"下载完成: {filename}")
|
api_service.logger.info(f"下载完成: {filename}")
|
||||||
except DownloadException as e:
|
except DownloadException as e:
|
||||||
api_service.logger.error(f"下载异常: {e}")
|
api_service.logger.error(f"下载异常: {e}")
|
||||||
@@ -3265,10 +3288,9 @@ def _batch_zip_worker(task_id: str) -> None:
|
|||||||
import tempfile as _tempfile
|
import tempfile as _tempfile
|
||||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
|
|
||||||
zip_tmp_path: Optional[str] = None
|
zip_tmp_path: str | None = None
|
||||||
zipped_files: List[str] = []
|
|
||||||
ok_count = 0
|
ok_count = 0
|
||||||
failed: List[str] = []
|
failed: list[str] = []
|
||||||
used_arcnames = set() # ZIP 内已占用文件名(同名歌曲去重,防止包内互相覆盖)
|
used_arcnames = set() # ZIP 内已占用文件名(同名歌曲去重,防止包内互相覆盖)
|
||||||
info_pool = None
|
info_pool = None
|
||||||
info_futures = {}
|
info_futures = {}
|
||||||
@@ -3562,7 +3584,7 @@ def _batch_zip_worker(task_id: str) -> None:
|
|||||||
refunded = _settle_batch_billing(task_id, delivered, 'error')
|
refunded = _settle_batch_billing(task_id, delivered, 'error')
|
||||||
if delivered > 0 and zip_tmp_path:
|
if delivered > 0 and zip_tmp_path:
|
||||||
_mark_task_terminal(task_id, 'error',
|
_mark_task_terminal(task_id, 'error',
|
||||||
f"部分歌曲已打包完成,可领取" +
|
"部分歌曲已打包完成,可领取" +
|
||||||
(f",退回 {refunded} 次" if refunded else ''))
|
(f",退回 {refunded} 次" if refunded else ''))
|
||||||
with DOWNLOAD_TASKS_LOCK:
|
with DOWNLOAD_TASKS_LOCK:
|
||||||
t = DOWNLOAD_TASKS.get(task_id)
|
t = DOWNLOAD_TASKS.get(task_id)
|
||||||
@@ -3603,9 +3625,9 @@ def _podcast_download_worker(task_id: str) -> None:
|
|||||||
"""
|
"""
|
||||||
import tempfile as _tempfile
|
import tempfile as _tempfile
|
||||||
|
|
||||||
file_path_str: Optional[str] = None
|
file_path_str: str | None = None
|
||||||
zip_tmp_path: Optional[str] = None
|
zip_tmp_path: str | None = None
|
||||||
task_dir: Optional[Path] = None
|
task_dir: Path | None = None
|
||||||
try:
|
try:
|
||||||
with DOWNLOAD_TASKS_LOCK:
|
with DOWNLOAD_TASKS_LOCK:
|
||||||
task = DOWNLOAD_TASKS[task_id]
|
task = DOWNLOAD_TASKS[task_id]
|
||||||
@@ -3643,7 +3665,7 @@ def _podcast_download_worker(task_id: str) -> None:
|
|||||||
raise DownloadCanceled("任务已取消")
|
raise DownloadCanceled("任务已取消")
|
||||||
file_path_str = str(target)
|
file_path_str = str(target)
|
||||||
|
|
||||||
result: Dict[str, Any]
|
result: dict[str, Any]
|
||||||
if need_zip:
|
if need_zip:
|
||||||
fd, zip_tmp_path = _tempfile.mkstemp(prefix='podcast_zip_', suffix='.zip',
|
fd, zip_tmp_path = _tempfile.mkstemp(prefix='podcast_zip_', suffix='.zip',
|
||||||
dir=str(task_dir))
|
dir=str(task_dir))
|
||||||
@@ -3737,7 +3759,7 @@ def _podcast_download_worker(task_id: str) -> None:
|
|||||||
shutil.rmtree(str(task_dir), ignore_errors=True)
|
shutil.rmtree(str(task_dir), ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
def _podcast_cleanup_files(paths: List[Optional[str]]) -> None:
|
def _podcast_cleanup_files(paths: list[str | None]) -> None:
|
||||||
"""清理播客任务产生的临时/残留文件"""
|
"""清理播客任务产生的临时/残留文件"""
|
||||||
for p in paths:
|
for p in paths:
|
||||||
if not p:
|
if not p:
|
||||||
@@ -3759,9 +3781,9 @@ def _podcast_zip_worker(task_id: str) -> None:
|
|||||||
"""整台播客打包:逐集 下载→入包即删,支持取消与断线暂停(复用批量任务机制)"""
|
"""整台播客打包:逐集 下载→入包即删,支持取消与断线暂停(复用批量任务机制)"""
|
||||||
import tempfile as _tempfile
|
import tempfile as _tempfile
|
||||||
|
|
||||||
zip_tmp_path: Optional[str] = None
|
zip_tmp_path: str | None = None
|
||||||
ok_count = 0
|
ok_count = 0
|
||||||
failed: List[str] = []
|
failed: list[str] = []
|
||||||
# 第 97 条(P1-1):zip_valid 必须在函数头初始化——空电台/无单集/补扣失败等
|
# 第 97 条(P1-1):zip_valid 必须在函数头初始化——空电台/无单集/补扣失败等
|
||||||
# 正常业务错误在 try 内早期 raise,异常处理器引用未绑定名会二次崩溃 worker 线程
|
# 正常业务错误在 try 内早期 raise,异常处理器引用未绑定名会二次崩溃 worker 线程
|
||||||
zip_valid = False
|
zip_valid = False
|
||||||
@@ -4064,7 +4086,6 @@ def download_batch_zip_result():
|
|||||||
try:
|
try:
|
||||||
data = api_service._safe_get_request_data()
|
data = api_service._safe_get_request_data()
|
||||||
task_id = str(data.get('task_id') or '').strip()
|
task_id = str(data.get('task_id') or '').strip()
|
||||||
device_id = str(data.get('device_id') or '').strip()[:64] # 第 98 条:与注册同口径钳 64(防超长键灌爆 DEVICE_LAST_SEEN)
|
|
||||||
if not task_id:
|
if not task_id:
|
||||||
return APIResponse.error("缺少 task_id 参数")
|
return APIResponse.error("缺少 task_id 参数")
|
||||||
with DOWNLOAD_TASKS_LOCK:
|
with DOWNLOAD_TASKS_LOCK:
|
||||||
@@ -4127,9 +4148,9 @@ def _single_download_worker(task_id: str) -> None:
|
|||||||
"""后台单曲下载:下载音频(可选 ZIP 打包),支持取消;完成后由 /download/result 交付,35 分钟保留期清理"""
|
"""后台单曲下载:下载音频(可选 ZIP 打包),支持取消;完成后由 /download/result 交付,35 分钟保留期清理"""
|
||||||
import tempfile as _tempfile
|
import tempfile as _tempfile
|
||||||
|
|
||||||
file_path_str: Optional[str] = None
|
file_path_str: str | None = None
|
||||||
zip_tmp_path: Optional[str] = None
|
zip_tmp_path: str | None = None
|
||||||
task_dir_path: Optional[str] = None
|
task_dir_path: str | None = None
|
||||||
try:
|
try:
|
||||||
with DOWNLOAD_TASKS_LOCK:
|
with DOWNLOAD_TASKS_LOCK:
|
||||||
task = DOWNLOAD_TASKS[task_id]
|
task = DOWNLOAD_TASKS[task_id]
|
||||||
@@ -4308,7 +4329,6 @@ def download_single_result():
|
|||||||
try:
|
try:
|
||||||
data = api_service._safe_get_request_data()
|
data = api_service._safe_get_request_data()
|
||||||
task_id = str(data.get('task_id') or '').strip()
|
task_id = str(data.get('task_id') or '').strip()
|
||||||
device_id = str(data.get('device_id') or '').strip()[:64] # 第 98 条:与注册同口径钳 64(防超长键灌爆 DEVICE_LAST_SEEN)
|
|
||||||
if not task_id:
|
if not task_id:
|
||||||
return APIResponse.error("缺少 task_id 参数")
|
return APIResponse.error("缺少 task_id 参数")
|
||||||
with DOWNLOAD_TASKS_LOCK:
|
with DOWNLOAD_TASKS_LOCK:
|
||||||
@@ -4437,21 +4457,21 @@ def start_api_server():
|
|||||||
_safe_console_print(f"📁 下载目录: {api_service.downloads_path.absolute()}")
|
_safe_console_print(f"📁 下载目录: {api_service.downloads_path.absolute()}")
|
||||||
_safe_console_print(f"📋 日志级别: {config.log_level}")
|
_safe_console_print(f"📋 日志级别: {config.log_level}")
|
||||||
_safe_console_print("\n📚 API端点:")
|
_safe_console_print("\n📚 API端点:")
|
||||||
_safe_console_print(f" ├─ GET /health - 健康检查")
|
_safe_console_print(" ├─ GET /health - 健康检查")
|
||||||
_safe_console_print(f" ├─ POST /search - 搜索音乐(支持歌曲/歌单/专辑/歌手)")
|
_safe_console_print(" ├─ POST /search - 搜索音乐(支持歌曲/歌单/专辑/歌手)")
|
||||||
_safe_console_print(f" ├─ POST /song - 获取歌曲信息")
|
_safe_console_print(" ├─ POST /song - 获取歌曲信息")
|
||||||
_safe_console_print(f" ├─ POST /playlist - 获取歌单详情")
|
_safe_console_print(" ├─ POST /playlist - 获取歌单详情")
|
||||||
_safe_console_print(f" ├─ POST /album - 获取专辑详情")
|
_safe_console_print(" ├─ POST /album - 获取专辑详情")
|
||||||
_safe_console_print(f" ├─ POST /toplist - 获取官方榜单列表")
|
_safe_console_print(" ├─ POST /toplist - 获取官方榜单列表")
|
||||||
_safe_console_print(f" ├─ POST /artist_songs - 获取歌手信息与热门歌曲")
|
_safe_console_print(" ├─ POST /artist_songs - 获取歌手信息与热门歌曲")
|
||||||
_safe_console_print(f" ├─ POST /comments - 获取歌曲评论")
|
_safe_console_print(" ├─ POST /comments - 获取歌曲评论")
|
||||||
_safe_console_print(f" ├─ POST /download - 下载音乐")
|
_safe_console_print(" ├─ POST /download - 下载音乐")
|
||||||
_safe_console_print(f" ├─ GET/POST /global/playlists - 全局账号歌单列表")
|
_safe_console_print(" ├─ GET/POST /global/playlists - 全局账号歌单列表")
|
||||||
_safe_console_print(f" ├─ GET/POST /global/playlist - 全局账号歌单详情")
|
_safe_console_print(" ├─ GET/POST /global/playlist - 全局账号歌单详情")
|
||||||
_safe_console_print(f" ├─ GET/POST /api/playlist/play - 公开歌单播放API")
|
_safe_console_print(" ├─ GET/POST /api/playlist/play - 公开歌单播放API")
|
||||||
_safe_console_print(f" └─ GET /api/info - API信息")
|
_safe_console_print(" └─ GET /api/info - API信息")
|
||||||
_safe_console_print("\n🎵 支持的音质:")
|
_safe_console_print("\n🎵 支持的音质:")
|
||||||
_safe_console_print(f" standard, exhigh, lossless, hires, sky, jyeffect, jymaster, dolby")
|
_safe_console_print(" standard, exhigh, lossless, hires, sky, jyeffect, jymaster, dolby")
|
||||||
_safe_console_print("="*60)
|
_safe_console_print("="*60)
|
||||||
_safe_console_print(f"⏰ 启动时间: {time.strftime('%Y-%m-%d %H:%M:%S')}")
|
_safe_console_print(f"⏰ 启动时间: {time.strftime('%Y-%m-%d %H:%M:%S')}")
|
||||||
_safe_console_print("🌟 服务已就绪,等待请求...\n")
|
_safe_console_print("🌟 服务已就绪,等待请求...\n")
|
||||||
|
|||||||
+113
-110
@@ -10,19 +10,22 @@
|
|||||||
- 二维码登录
|
- 二维码登录
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import urllib.parse
|
|
||||||
import time
|
import time
|
||||||
from random import randrange
|
import urllib.parse
|
||||||
from typing import Dict, List, Optional, Tuple, Any
|
|
||||||
from hashlib import md5
|
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
from hashlib import md5
|
||||||
|
from random import randrange
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import requests
|
|
||||||
from cryptography.hazmat.primitives import padding
|
from cryptography.hazmat.primitives import padding
|
||||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||||
|
|
||||||
|
from core import aiohttp_compat as requests # 异步 HTTP 垫片(requests 同形 API)
|
||||||
|
from core.async_bridge import run_sync # 模块级同步兼容接口用的同步/异步桥
|
||||||
|
|
||||||
|
|
||||||
class QualityLevel(Enum):
|
class QualityLevel(Enum):
|
||||||
"""音质等级枚举"""
|
"""音质等级枚举"""
|
||||||
@@ -95,7 +98,7 @@ class CryptoUtils:
|
|||||||
return CryptoUtils.hex_digest(CryptoUtils.hash_digest(text))
|
return CryptoUtils.hex_digest(CryptoUtils.hash_digest(text))
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def encrypt_params(url: str, payload: Dict[str, Any]) -> str:
|
def encrypt_params(url: str, payload: dict[str, Any]) -> str:
|
||||||
"""加密请求参数"""
|
"""加密请求参数"""
|
||||||
url_path = urllib.parse.urlparse(url).path.replace("/eapi/", "/api/")
|
url_path = urllib.parse.urlparse(url).path.replace("/eapi/", "/api/")
|
||||||
digest = CryptoUtils.hash_hex_digest(f"nobody{url_path}use{json.dumps(payload)}md5forencrypt")
|
digest = CryptoUtils.hash_hex_digest(f"nobody{url_path}use{json.dumps(payload)}md5forencrypt")
|
||||||
@@ -135,7 +138,7 @@ class WeapiCrypto:
|
|||||||
return encryptor.update(padded) + encryptor.finalize()
|
return encryptor.update(padded) + encryptor.finalize()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def encrypt(payload: Dict[str, Any]) -> Dict[str, str]:
|
def encrypt(payload: dict[str, Any]) -> dict[str, str]:
|
||||||
"""将请求体加密为 weapi 的 params/encSecKey。
|
"""将请求体加密为 weapi 的 params/encSecKey。
|
||||||
第 91 条(P2-5):按官方协议重写——secKey 取官方字符集随机 16 位,
|
第 91 条(P2-5):按官方协议重写——secKey 取官方字符集随机 16 位,
|
||||||
encSecKey = secKey 反转后做 textbook RSA(无填充)的 256 位 hex。
|
encSecKey = secKey 反转后做 textbook RSA(无填充)的 256 位 hex。
|
||||||
@@ -166,7 +169,7 @@ class WeapiCrypto:
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_dict(result: Any, msg: str) -> Dict[str, Any]:
|
def _ensure_dict(result: Any, msg: str) -> dict[str, Any]:
|
||||||
"""第 91 条(P2-8):上游 200 但返回 null/数组等非 dict 体(风控/抖动)时统一前置校验"""
|
"""第 91 条(P2-8):上游 200 但返回 null/数组等非 dict 体(风控/抖动)时统一前置校验"""
|
||||||
if isinstance(result, dict):
|
if isinstance(result, dict):
|
||||||
return result
|
return result
|
||||||
@@ -177,7 +180,7 @@ class HTTPClient:
|
|||||||
"""HTTP客户端类"""
|
"""HTTP客户端类"""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def post_request(url: str, params: str, cookies: Dict[str, str]) -> str:
|
async def post_request(url: str, params: str, cookies: dict[str, str]) -> str:
|
||||||
"""发送POST请求并返回文本响应。
|
"""发送POST请求并返回文本响应。
|
||||||
R5-20:网络异常/5xx 做一次 1 秒退避重试(全站 API 调用此前零重试,
|
R5-20:网络异常/5xx 做一次 1 秒退避重试(全站 API 调用此前零重试,
|
||||||
上游抖动即整首失败);4xx(429/460 风控)不重试——立即重试会加剧风控。"""
|
上游抖动即整首失败);4xx(429/460 风控)不重试——立即重试会加剧风控。"""
|
||||||
@@ -192,10 +195,10 @@ class HTTPClient:
|
|||||||
last_err = None
|
last_err = None
|
||||||
for attempt in range(2):
|
for attempt in range(2):
|
||||||
try:
|
try:
|
||||||
response = requests.post(url, headers=headers, cookies=request_cookies,
|
response = await requests.post(url, headers=headers, cookies=request_cookies,
|
||||||
data={"params": params}, timeout=30)
|
data={"params": params}, timeout=30)
|
||||||
if response.status_code >= 500 and attempt == 0:
|
if response.status_code >= 500 and attempt == 0:
|
||||||
time.sleep(1.0)
|
await asyncio.sleep(1.0)
|
||||||
continue
|
continue
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
if len(response.content) > _MAX_UPSTREAM_JSON_BYTES:
|
if len(response.content) > _MAX_UPSTREAM_JSON_BYTES:
|
||||||
@@ -208,12 +211,12 @@ class HTTPClient:
|
|||||||
except requests.RequestException as e:
|
except requests.RequestException as e:
|
||||||
last_err = e
|
last_err = e
|
||||||
if attempt == 0:
|
if attempt == 0:
|
||||||
time.sleep(1.0)
|
await asyncio.sleep(1.0)
|
||||||
continue
|
continue
|
||||||
raise APIException(f"HTTP请求失败: {last_err}")
|
raise APIException(f"HTTP请求失败: {last_err}")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def post_request_full(url: str, params: str, cookies: Dict[str, str]) -> requests.Response:
|
async def post_request_full(url: str, params: str, cookies: dict[str, str]) -> requests.Response:
|
||||||
"""发送POST请求并返回完整响应对象(R5-20:同 post_request 的有界重试)"""
|
"""发送POST请求并返回完整响应对象(R5-20:同 post_request 的有界重试)"""
|
||||||
headers = {
|
headers = {
|
||||||
'User-Agent': APIConstants.USER_AGENT,
|
'User-Agent': APIConstants.USER_AGENT,
|
||||||
@@ -226,10 +229,10 @@ class HTTPClient:
|
|||||||
last_err = None
|
last_err = None
|
||||||
for attempt in range(2):
|
for attempt in range(2):
|
||||||
try:
|
try:
|
||||||
response = requests.post(url, headers=headers, cookies=request_cookies,
|
response = await requests.post(url, headers=headers, cookies=request_cookies,
|
||||||
data={"params": params}, timeout=30)
|
data={"params": params}, timeout=30)
|
||||||
if response.status_code >= 500 and attempt == 0:
|
if response.status_code >= 500 and attempt == 0:
|
||||||
time.sleep(1.0)
|
await asyncio.sleep(1.0)
|
||||||
continue
|
continue
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
if len(response.content) > _MAX_UPSTREAM_JSON_BYTES:
|
if len(response.content) > _MAX_UPSTREAM_JSON_BYTES:
|
||||||
@@ -241,7 +244,7 @@ class HTTPClient:
|
|||||||
except requests.RequestException as e:
|
except requests.RequestException as e:
|
||||||
last_err = e
|
last_err = e
|
||||||
if attempt == 0:
|
if attempt == 0:
|
||||||
time.sleep(1.0)
|
await asyncio.sleep(1.0)
|
||||||
continue
|
continue
|
||||||
raise APIException(f"HTTP请求失败: {last_err}")
|
raise APIException(f"HTTP请求失败: {last_err}")
|
||||||
|
|
||||||
@@ -263,7 +266,7 @@ class NeteaseAPI:
|
|||||||
self.http_client = HTTPClient()
|
self.http_client = HTTPClient()
|
||||||
self.crypto_utils = CryptoUtils()
|
self.crypto_utils = CryptoUtils()
|
||||||
|
|
||||||
def get_song_url(self, song_id: int, quality: str, cookies: Dict[str, str]) -> Dict[str, Any]:
|
async def get_song_url(self, song_id: int, quality: str, cookies: dict[str, str]) -> dict[str, Any]:
|
||||||
"""获取歌曲播放URL
|
"""获取歌曲播放URL
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -292,7 +295,7 @@ class NeteaseAPI:
|
|||||||
payload['immerseType'] = 'c51'
|
payload['immerseType'] = 'c51'
|
||||||
|
|
||||||
params = self.crypto_utils.encrypt_params(APIConstants.SONG_URL_V1, payload)
|
params = self.crypto_utils.encrypt_params(APIConstants.SONG_URL_V1, payload)
|
||||||
response_text = self.http_client.post_request(APIConstants.SONG_URL_V1, params, cookies)
|
response_text = await self.http_client.post_request(APIConstants.SONG_URL_V1, params, cookies)
|
||||||
|
|
||||||
result = json.loads(response_text)
|
result = json.loads(response_text)
|
||||||
# R5-19:合法 JSON 但非对象(null/数组)时给明确错误,不让 AttributeError 失真
|
# R5-19:合法 JSON 但非对象(null/数组)时给明确错误,不让 AttributeError 失真
|
||||||
@@ -305,7 +308,7 @@ class NeteaseAPI:
|
|||||||
except (json.JSONDecodeError, KeyError) as e:
|
except (json.JSONDecodeError, KeyError) as e:
|
||||||
raise APIException(f"解析响应数据失败: {e}")
|
raise APIException(f"解析响应数据失败: {e}")
|
||||||
|
|
||||||
def get_song_detail(self, song_id: int) -> Dict[str, Any]:
|
async def get_song_detail(self, song_id: int) -> dict[str, Any]:
|
||||||
"""获取歌曲详细信息
|
"""获取歌曲详细信息
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -324,7 +327,7 @@ class NeteaseAPI:
|
|||||||
'User-Agent': APIConstants.USER_AGENT,
|
'User-Agent': APIConstants.USER_AGENT,
|
||||||
'Referer': APIConstants.REFERER
|
'Referer': APIConstants.REFERER
|
||||||
}
|
}
|
||||||
response = requests.post(APIConstants.SONG_DETAIL_V3, data=data,
|
response = await requests.post(APIConstants.SONG_DETAIL_V3, data=data,
|
||||||
headers=headers, timeout=30)
|
headers=headers, timeout=30)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
@@ -341,7 +344,7 @@ class NeteaseAPI:
|
|||||||
except json.JSONDecodeError as e:
|
except json.JSONDecodeError as e:
|
||||||
raise APIException(f"解析歌曲详情响应失败: {e}")
|
raise APIException(f"解析歌曲详情响应失败: {e}")
|
||||||
|
|
||||||
def get_lyric(self, song_id: int, cookies: Dict[str, str]) -> Dict[str, Any]:
|
async def get_lyric(self, song_id: int, cookies: dict[str, str]) -> dict[str, Any]:
|
||||||
"""获取歌词信息
|
"""获取歌词信息
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -372,7 +375,7 @@ class NeteaseAPI:
|
|||||||
'Referer': APIConstants.REFERER
|
'Referer': APIConstants.REFERER
|
||||||
}
|
}
|
||||||
|
|
||||||
response = requests.post(APIConstants.LYRIC_API, data=data,
|
response = await requests.post(APIConstants.LYRIC_API, data=data,
|
||||||
headers=headers, cookies=cookies, timeout=30)
|
headers=headers, cookies=cookies, timeout=30)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
@@ -387,8 +390,8 @@ class NeteaseAPI:
|
|||||||
except json.JSONDecodeError as e:
|
except json.JSONDecodeError as e:
|
||||||
raise APIException(f"解析歌词响应失败: {e}")
|
raise APIException(f"解析歌词响应失败: {e}")
|
||||||
|
|
||||||
def search_music(self, keywords: str, cookies: Dict[str, str], limit: int = 10,
|
async def search_music(self, keywords: str, cookies: dict[str, str], limit: int = 10,
|
||||||
search_type: int = 1, offset: int = 0) -> List[Dict[str, Any]]:
|
search_type: int = 1, offset: int = 0) -> list[dict[str, Any]]:
|
||||||
"""搜索音乐
|
"""搜索音乐
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -412,7 +415,7 @@ class NeteaseAPI:
|
|||||||
'Referer': APIConstants.REFERER
|
'Referer': APIConstants.REFERER
|
||||||
}
|
}
|
||||||
|
|
||||||
response = requests.post(APIConstants.SEARCH_API, data=data,
|
response = await requests.post(APIConstants.SEARCH_API, data=data,
|
||||||
headers=headers, cookies=cookies, timeout=30)
|
headers=headers, cookies=cookies, timeout=30)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
@@ -424,7 +427,7 @@ class NeteaseAPI:
|
|||||||
raise APIException(f"搜索失败: {result.get('message', '未知错误')}")
|
raise APIException(f"搜索失败: {result.get('message', '未知错误')}")
|
||||||
|
|
||||||
result_data = result.get('result') or {}
|
result_data = result.get('result') or {}
|
||||||
items: List[Dict[str, Any]] = []
|
items: list[dict[str, Any]] = []
|
||||||
|
|
||||||
if search_type == 1:
|
if search_type == 1:
|
||||||
# 歌曲(P2-12:id 改 .get——上游单条残缺(下架/屏蔽)不再拖死整页搜索)
|
# 歌曲(P2-12:id 改 .get——上游单条残缺(下架/屏蔽)不再拖死整页搜索)
|
||||||
@@ -489,7 +492,7 @@ class NeteaseAPI:
|
|||||||
except (json.JSONDecodeError, KeyError) as e:
|
except (json.JSONDecodeError, KeyError) as e:
|
||||||
raise APIException(f"解析搜索响应失败: {e}")
|
raise APIException(f"解析搜索响应失败: {e}")
|
||||||
|
|
||||||
def get_playlist_detail(self, playlist_id: int, cookies: Dict[str, str]) -> Dict[str, Any]:
|
async def get_playlist_detail(self, playlist_id: int, cookies: dict[str, str]) -> dict[str, Any]:
|
||||||
"""获取歌单详情
|
"""获取歌单详情
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -509,7 +512,7 @@ class NeteaseAPI:
|
|||||||
'Referer': APIConstants.REFERER
|
'Referer': APIConstants.REFERER
|
||||||
}
|
}
|
||||||
|
|
||||||
response = requests.post(APIConstants.PLAYLIST_DETAIL_API, data=data,
|
response = await requests.post(APIConstants.PLAYLIST_DETAIL_API, data=data,
|
||||||
headers=headers, cookies=cookies, timeout=30)
|
headers=headers, cookies=cookies, timeout=30)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
@@ -549,7 +552,7 @@ class NeteaseAPI:
|
|||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
song_resp = requests.post(APIConstants.SONG_DETAIL_V3, data=song_data,
|
song_resp = await requests.post(APIConstants.SONG_DETAIL_V3, data=song_data,
|
||||||
headers=headers, cookies=cookies, timeout=30)
|
headers=headers, cookies=cookies, timeout=30)
|
||||||
song_resp.raise_for_status()
|
song_resp.raise_for_status()
|
||||||
|
|
||||||
@@ -584,7 +587,7 @@ class NeteaseAPI:
|
|||||||
except (json.JSONDecodeError, KeyError) as e:
|
except (json.JSONDecodeError, KeyError) as e:
|
||||||
raise APIException(f"解析歌单详情响应失败: {e}")
|
raise APIException(f"解析歌单详情响应失败: {e}")
|
||||||
|
|
||||||
def get_album_detail(self, album_id: int, cookies: Dict[str, str]) -> Dict[str, Any]:
|
async def get_album_detail(self, album_id: int, cookies: dict[str, str]) -> dict[str, Any]:
|
||||||
"""获取专辑详情
|
"""获取专辑详情
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -604,7 +607,7 @@ class NeteaseAPI:
|
|||||||
'Referer': APIConstants.REFERER
|
'Referer': APIConstants.REFERER
|
||||||
}
|
}
|
||||||
|
|
||||||
response = requests.get(url, headers=headers, cookies=cookies, timeout=30)
|
response = await requests.get(url, headers=headers, cookies=cookies, timeout=30)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
result = response.json()
|
result = response.json()
|
||||||
@@ -640,7 +643,7 @@ class NeteaseAPI:
|
|||||||
except (json.JSONDecodeError, KeyError) as e:
|
except (json.JSONDecodeError, KeyError) as e:
|
||||||
raise APIException(f"解析专辑详情响应失败: {e}")
|
raise APIException(f"解析专辑详情响应失败: {e}")
|
||||||
|
|
||||||
def get_toplist(self) -> List[Dict[str, Any]]:
|
async def get_toplist(self) -> list[dict[str, Any]]:
|
||||||
"""获取网易云官方榜单列表
|
"""获取网易云官方榜单列表
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -655,7 +658,7 @@ class NeteaseAPI:
|
|||||||
'Referer': APIConstants.REFERER
|
'Referer': APIConstants.REFERER
|
||||||
}
|
}
|
||||||
|
|
||||||
response = requests.get(APIConstants.TOPLIST_API, headers=headers, timeout=30)
|
response = await requests.get(APIConstants.TOPLIST_API, headers=headers, timeout=30)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
result = response.json()
|
result = response.json()
|
||||||
@@ -681,7 +684,7 @@ class NeteaseAPI:
|
|||||||
except (json.JSONDecodeError, KeyError) as e:
|
except (json.JSONDecodeError, KeyError) as e:
|
||||||
raise APIException(f"解析榜单响应失败: {e}")
|
raise APIException(f"解析榜单响应失败: {e}")
|
||||||
|
|
||||||
def get_artist_hot_songs(self, artist_id: int) -> Dict[str, Any]:
|
async def get_artist_hot_songs(self, artist_id: int) -> dict[str, Any]:
|
||||||
"""获取歌手信息和热门歌曲
|
"""获取歌手信息和热门歌曲
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -700,7 +703,7 @@ class NeteaseAPI:
|
|||||||
'Referer': APIConstants.REFERER
|
'Referer': APIConstants.REFERER
|
||||||
}
|
}
|
||||||
|
|
||||||
response = requests.get(url, headers=headers, timeout=30)
|
response = await requests.get(url, headers=headers, timeout=30)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
result = response.json()
|
result = response.json()
|
||||||
@@ -735,7 +738,7 @@ class NeteaseAPI:
|
|||||||
except (json.JSONDecodeError, KeyError) as e:
|
except (json.JSONDecodeError, KeyError) as e:
|
||||||
raise APIException(f"解析歌手信息响应失败: {e}")
|
raise APIException(f"解析歌手信息响应失败: {e}")
|
||||||
|
|
||||||
def get_song_comments(self, song_id: int, limit: int = 20, offset: int = 0) -> Dict[str, Any]:
|
async def get_song_comments(self, song_id: int, limit: int = 20, offset: int = 0) -> dict[str, Any]:
|
||||||
"""获取歌曲评论
|
"""获取歌曲评论
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -756,7 +759,7 @@ class NeteaseAPI:
|
|||||||
'Referer': APIConstants.REFERER
|
'Referer': APIConstants.REFERER
|
||||||
}
|
}
|
||||||
|
|
||||||
response = requests.get(url, headers=headers, timeout=30)
|
response = await requests.get(url, headers=headers, timeout=30)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
result = response.json()
|
result = response.json()
|
||||||
@@ -802,7 +805,7 @@ class NeteaseAPI:
|
|||||||
except (json.JSONDecodeError, KeyError) as e:
|
except (json.JSONDecodeError, KeyError) as e:
|
||||||
raise APIException(f"解析评论响应失败: {e}")
|
raise APIException(f"解析评论响应失败: {e}")
|
||||||
|
|
||||||
def get_account_info(self, cookies: Dict[str, str]) -> Optional[Dict[str, Any]]:
|
async def get_account_info(self, cookies: dict[str, str]) -> dict[str, Any] | None:
|
||||||
"""获取当前账号信息(用于个人登录状态展示)
|
"""获取当前账号信息(用于个人登录状态展示)
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -817,7 +820,7 @@ class NeteaseAPI:
|
|||||||
'Referer': APIConstants.REFERER
|
'Referer': APIConstants.REFERER
|
||||||
}
|
}
|
||||||
|
|
||||||
response = requests.get(APIConstants.USER_ACCOUNT_API, headers=headers,
|
response = await requests.get(APIConstants.USER_ACCOUNT_API, headers=headers,
|
||||||
cookies=cookies, timeout=30)
|
cookies=cookies, timeout=30)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
@@ -842,7 +845,7 @@ class NeteaseAPI:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_user_playlists(self, user_id: int, cookies: Dict[str, str]) -> List[Dict[str, Any]]:
|
async def get_user_playlists(self, user_id: int, cookies: dict[str, str]) -> list[dict[str, Any]]:
|
||||||
"""获取用户收藏/创建的歌单列表
|
"""获取用户收藏/创建的歌单列表
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -869,7 +872,7 @@ class NeteaseAPI:
|
|||||||
page_size = 100
|
page_size = 100
|
||||||
for _ in range(20): # 有界翻页,防上游 more 异常导致无限循环
|
for _ in range(20): # 有界翻页,防上游 more 异常导致无限循环
|
||||||
params = {'uid': user_id, 'limit': page_size, 'offset': offset}
|
params = {'uid': user_id, 'limit': page_size, 'offset': offset}
|
||||||
response = requests.get(APIConstants.USER_PLAYLIST_API, params=params,
|
response = await requests.get(APIConstants.USER_PLAYLIST_API, params=params,
|
||||||
headers=headers, cookies=cookies, timeout=30)
|
headers=headers, cookies=cookies, timeout=30)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
@@ -906,8 +909,8 @@ class NeteaseAPI:
|
|||||||
except (json.JSONDecodeError, KeyError) as e:
|
except (json.JSONDecodeError, KeyError) as e:
|
||||||
raise APIException(f"解析用户歌单响应失败: {e}")
|
raise APIException(f"解析用户歌单响应失败: {e}")
|
||||||
|
|
||||||
def detect_vip_by_probe(self, cookies: Dict[str, str],
|
async def detect_vip_by_probe(self, cookies: dict[str, str],
|
||||||
probe_song_id: int = 347230) -> Dict[str, Any]:
|
probe_song_id: int = 347230) -> dict[str, Any]:
|
||||||
"""通过歌曲URL接口探测账号可用的最高音质,推断会员类型
|
"""通过歌曲URL接口探测账号可用的最高音质,推断会员类型
|
||||||
|
|
||||||
直接询问网易云接口「这个账号能把歌解析到什么音质」,
|
直接询问网易云接口「这个账号能把歌解析到什么音质」,
|
||||||
@@ -920,7 +923,7 @@ class NeteaseAPI:
|
|||||||
Returns:
|
Returns:
|
||||||
{'vip_type': int, 'highest_level': str, 'levels_ok': List[str]}
|
{'vip_type': int, 'highest_level': str, 'levels_ok': List[str]}
|
||||||
"""
|
"""
|
||||||
levels_ok = self.probe_levels(cookies, probe_song_id)
|
levels_ok = await self.probe_levels(cookies, probe_song_id)
|
||||||
highest = levels_ok[0] if levels_ok else 'standard'
|
highest = levels_ok[0] if levels_ok else 'standard'
|
||||||
|
|
||||||
if highest in ('jymaster', 'jyeffect', 'sky'):
|
if highest in ('jymaster', 'jyeffect', 'sky'):
|
||||||
@@ -931,8 +934,8 @@ class NeteaseAPI:
|
|||||||
vip_type = 0 # 普通用户
|
vip_type = 0 # 普通用户
|
||||||
return {'vip_type': vip_type, 'highest_level': highest, 'levels_ok': levels_ok}
|
return {'vip_type': vip_type, 'highest_level': highest, 'levels_ok': levels_ok}
|
||||||
|
|
||||||
def probe_levels(self, cookies: Dict[str, str],
|
async def probe_levels(self, cookies: dict[str, str],
|
||||||
probe_song_id: int = 347230) -> List[str]:
|
probe_song_id: int = 347230) -> list[str]:
|
||||||
"""实测账号对每个音质级别能否真正解析
|
"""实测账号对每个音质级别能否真正解析
|
||||||
|
|
||||||
只有接口返回的 level 与请求一致且带 URL,才算该音质可解析。
|
只有接口返回的 level 与请求一致且带 URL,才算该音质可解析。
|
||||||
@@ -954,7 +957,7 @@ class NeteaseAPI:
|
|||||||
if time.monotonic() > _deadline:
|
if time.monotonic() > _deadline:
|
||||||
break
|
break
|
||||||
try:
|
try:
|
||||||
result = self.get_song_url(probe_song_id, level, cookies)
|
result = await self.get_song_url(probe_song_id, level, cookies)
|
||||||
if result and result.get('data'):
|
if result and result.get('data'):
|
||||||
d = result['data'][0] or {}
|
d = result['data'][0] or {}
|
||||||
# 请求与返回一致且带直链,才说明该音质真正可用
|
# 请求与返回一致且带直链,才说明该音质真正可用
|
||||||
@@ -964,8 +967,8 @@ class NeteaseAPI:
|
|||||||
continue
|
continue
|
||||||
return levels_ok
|
return levels_ok
|
||||||
|
|
||||||
def subscribe_playlist(self, playlist_id, cookies: Dict[str, str],
|
async def subscribe_playlist(self, playlist_id, cookies: dict[str, str],
|
||||||
action: int = 1) -> Dict[str, Any]:
|
action: int = 1) -> dict[str, Any]:
|
||||||
"""收藏/取消收藏歌单(官方接口)
|
"""收藏/取消收藏歌单(官方接口)
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -985,7 +988,7 @@ class NeteaseAPI:
|
|||||||
'User-Agent': APIConstants.USER_AGENT,
|
'User-Agent': APIConstants.USER_AGENT,
|
||||||
'Referer': APIConstants.REFERER
|
'Referer': APIConstants.REFERER
|
||||||
}
|
}
|
||||||
response = requests.post(APIConstants.PLAYLIST_SUBSCRIBE_API, data=data,
|
response = await requests.post(APIConstants.PLAYLIST_SUBSCRIBE_API, data=data,
|
||||||
headers=headers, cookies=cookies, timeout=30)
|
headers=headers, cookies=cookies, timeout=30)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
result = response.json()
|
result = response.json()
|
||||||
@@ -998,8 +1001,8 @@ class NeteaseAPI:
|
|||||||
except (json.JSONDecodeError, KeyError) as e:
|
except (json.JSONDecodeError, KeyError) as e:
|
||||||
raise APIException(f"解析收藏歌单响应失败: {e}")
|
raise APIException(f"解析收藏歌单响应失败: {e}")
|
||||||
|
|
||||||
def subscribe_album(self, album_id, cookies: Dict[str, str],
|
async def subscribe_album(self, album_id, cookies: dict[str, str],
|
||||||
action: int = 1) -> Dict[str, Any]:
|
action: int = 1) -> dict[str, Any]:
|
||||||
"""收藏/取消收藏专辑(官方接口)
|
"""收藏/取消收藏专辑(官方接口)
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -1019,7 +1022,7 @@ class NeteaseAPI:
|
|||||||
'User-Agent': APIConstants.USER_AGENT,
|
'User-Agent': APIConstants.USER_AGENT,
|
||||||
'Referer': APIConstants.REFERER
|
'Referer': APIConstants.REFERER
|
||||||
}
|
}
|
||||||
response = requests.post(APIConstants.ALBUM_SUBSCRIBE_API, data=data,
|
response = await requests.post(APIConstants.ALBUM_SUBSCRIBE_API, data=data,
|
||||||
headers=headers, cookies=cookies, timeout=30)
|
headers=headers, cookies=cookies, timeout=30)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
result = response.json()
|
result = response.json()
|
||||||
@@ -1032,9 +1035,9 @@ class NeteaseAPI:
|
|||||||
except (json.JSONDecodeError, KeyError) as e:
|
except (json.JSONDecodeError, KeyError) as e:
|
||||||
raise APIException(f"解析收藏专辑响应失败: {e}")
|
raise APIException(f"解析收藏专辑响应失败: {e}")
|
||||||
|
|
||||||
def post_comment(self, thread_id: str, content: str, action: int = 1,
|
async def post_comment(self, thread_id: str, content: str, action: int = 1,
|
||||||
comment_id: Optional[int] = None,
|
comment_id: int | None = None,
|
||||||
cookies: Dict[str, str] = None) -> Dict[str, Any]:
|
cookies: dict[str, str] | None = None) -> dict[str, Any]:
|
||||||
"""发送/回复歌曲评论(官方 weapi 接口)
|
"""发送/回复歌曲评论(官方 weapi 接口)
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -1051,7 +1054,7 @@ class NeteaseAPI:
|
|||||||
APIException: 操作失败时抛出
|
APIException: 操作失败时抛出
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
payload: Dict[str, Any] = {
|
payload: dict[str, Any] = {
|
||||||
'threadId': thread_id,
|
'threadId': thread_id,
|
||||||
'content': content,
|
'content': content,
|
||||||
't': action,
|
't': action,
|
||||||
@@ -1070,7 +1073,7 @@ class NeteaseAPI:
|
|||||||
'User-Agent': APIConstants.USER_AGENT,
|
'User-Agent': APIConstants.USER_AGENT,
|
||||||
'Referer': APIConstants.REFERER,
|
'Referer': APIConstants.REFERER,
|
||||||
}
|
}
|
||||||
response = requests.post(APIConstants.COMMENT_POST_API, data=body,
|
response = await requests.post(APIConstants.COMMENT_POST_API, data=body,
|
||||||
headers=headers, cookies=cookies or {}, timeout=30)
|
headers=headers, cookies=cookies or {}, timeout=30)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
@@ -1114,7 +1117,7 @@ class NeteaseAPI:
|
|||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def get_pic_url(self, pic_id: Optional[int], size: int = 300) -> str:
|
def get_pic_url(self, pic_id: int | None, size: int = 300) -> str:
|
||||||
"""获取网易云加密歌曲/专辑封面直链
|
"""获取网易云加密歌曲/专辑封面直链
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -1138,7 +1141,7 @@ class QRLoginManager:
|
|||||||
self.http_client = HTTPClient()
|
self.http_client = HTTPClient()
|
||||||
self.crypto_utils = CryptoUtils()
|
self.crypto_utils = CryptoUtils()
|
||||||
|
|
||||||
def generate_qr_key(self) -> Optional[str]:
|
async def generate_qr_key(self) -> str | None:
|
||||||
"""生成二维码的key
|
"""生成二维码的key
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -1157,7 +1160,7 @@ class QRLoginManager:
|
|||||||
}
|
}
|
||||||
|
|
||||||
params = self.crypto_utils.encrypt_params(APIConstants.QR_UNIKEY_API, payload)
|
params = self.crypto_utils.encrypt_params(APIConstants.QR_UNIKEY_API, payload)
|
||||||
response = self.http_client.post_request_full(APIConstants.QR_UNIKEY_API, params, {})
|
response = await self.http_client.post_request_full(APIConstants.QR_UNIKEY_API, params, {})
|
||||||
|
|
||||||
result = json.loads(response.text)
|
result = json.loads(response.text)
|
||||||
# 第 97 条(P3):非 dict 响应(null/数组)不再 AttributeError 逃出捕获
|
# 第 97 条(P3):非 dict 响应(null/数组)不再 AttributeError 逃出捕获
|
||||||
@@ -1169,7 +1172,7 @@ class QRLoginManager:
|
|||||||
except (json.JSONDecodeError, KeyError) as e:
|
except (json.JSONDecodeError, KeyError) as e:
|
||||||
raise APIException(f"解析二维码key响应失败: {e}")
|
raise APIException(f"解析二维码key响应失败: {e}")
|
||||||
|
|
||||||
def create_qr_login(self) -> Optional[str]:
|
async def create_qr_login(self) -> str | None:
|
||||||
"""创建登录二维码并在控制台显示
|
"""创建登录二维码并在控制台显示
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -1178,7 +1181,7 @@ class QRLoginManager:
|
|||||||
try:
|
try:
|
||||||
import qrcode
|
import qrcode
|
||||||
|
|
||||||
unikey = self.generate_qr_key()
|
unikey = await self.generate_qr_key()
|
||||||
if not unikey:
|
if not unikey:
|
||||||
print("生成二维码key失败")
|
print("生成二维码key失败")
|
||||||
return None
|
return None
|
||||||
@@ -1199,7 +1202,7 @@ class QRLoginManager:
|
|||||||
print(f"创建二维码失败: {e}")
|
print(f"创建二维码失败: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def check_qr_login(self, unikey: str) -> Tuple[int, Dict[str, str]]:
|
async def check_qr_login(self, unikey: str) -> tuple[int, dict[str, str]]:
|
||||||
"""检查二维码登录状态
|
"""检查二维码登录状态
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -1222,7 +1225,7 @@ class QRLoginManager:
|
|||||||
}
|
}
|
||||||
|
|
||||||
params = self.crypto_utils.encrypt_params(APIConstants.QR_LOGIN_API, payload)
|
params = self.crypto_utils.encrypt_params(APIConstants.QR_LOGIN_API, payload)
|
||||||
response = self.http_client.post_request_full(APIConstants.QR_LOGIN_API, params, {})
|
response = await self.http_client.post_request_full(APIConstants.QR_LOGIN_API, params, {})
|
||||||
|
|
||||||
result = json.loads(response.text)
|
result = json.loads(response.text)
|
||||||
# 第 97 条(P3):非 dict 响应不再 AttributeError 逃出捕获(同 generate_qr_key)
|
# 第 97 条(P3):非 dict 响应不再 AttributeError 逃出捕获(同 generate_qr_key)
|
||||||
@@ -1250,19 +1253,19 @@ class QRLoginManager:
|
|||||||
except (json.JSONDecodeError, KeyError) as e:
|
except (json.JSONDecodeError, KeyError) as e:
|
||||||
raise APIException(f"解析登录状态响应失败: {e}")
|
raise APIException(f"解析登录状态响应失败: {e}")
|
||||||
|
|
||||||
def qr_login(self) -> Optional[str]:
|
async def qr_login(self) -> str | None:
|
||||||
"""完整的二维码登录流程
|
"""完整的二维码登录流程
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
成功返回cookie字符串,失败返回None
|
成功返回cookie字符串,失败返回None
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
unikey = self.create_qr_login()
|
unikey = await self.create_qr_login()
|
||||||
if not unikey:
|
if not unikey:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
code, cookies = self.check_qr_login(unikey)
|
code, cookies = await self.check_qr_login(unikey)
|
||||||
|
|
||||||
if code == 803:
|
if code == 803:
|
||||||
print("\n登录成功!")
|
print("\n登录成功!")
|
||||||
@@ -1280,7 +1283,7 @@ class QRLoginManager:
|
|||||||
print(f"\n登录失败,错误码:{code}")
|
print(f"\n登录失败,错误码:{code}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
time.sleep(2)
|
await asyncio.sleep(2)
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
print("\n用户取消登录")
|
print("\n用户取消登录")
|
||||||
return None
|
return None
|
||||||
@@ -1290,115 +1293,115 @@ class QRLoginManager:
|
|||||||
|
|
||||||
|
|
||||||
# 向后兼容的函数接口
|
# 向后兼容的函数接口
|
||||||
def url_v1(song_id: int, level: str, cookies: Dict[str, str]) -> Dict[str, Any]:
|
def url_v1(song_id: int, level: str, cookies: dict[str, str]) -> dict[str, Any]:
|
||||||
"""获取歌曲URL(向后兼容)"""
|
"""获取歌曲URL(向后兼容)"""
|
||||||
api = NeteaseAPI()
|
api = NeteaseAPI()
|
||||||
return api.get_song_url(song_id, level, cookies)
|
return run_sync(api.get_song_url(song_id, level, cookies))
|
||||||
|
|
||||||
|
|
||||||
def name_v1(song_id: int) -> Dict[str, Any]:
|
def name_v1(song_id: int) -> dict[str, Any]:
|
||||||
"""获取歌曲详情(向后兼容)"""
|
"""获取歌曲详情(向后兼容)"""
|
||||||
api = NeteaseAPI()
|
api = NeteaseAPI()
|
||||||
return api.get_song_detail(song_id)
|
return run_sync(api.get_song_detail(song_id))
|
||||||
|
|
||||||
|
|
||||||
def lyric_v1(song_id: int, cookies: Dict[str, str]) -> Dict[str, Any]:
|
def lyric_v1(song_id: int, cookies: dict[str, str]) -> dict[str, Any]:
|
||||||
"""获取歌词(向后兼容)"""
|
"""获取歌词(向后兼容)"""
|
||||||
api = NeteaseAPI()
|
api = NeteaseAPI()
|
||||||
return api.get_lyric(song_id, cookies)
|
return run_sync(api.get_lyric(song_id, cookies))
|
||||||
|
|
||||||
|
|
||||||
def search_music(keywords: str, cookies: Dict[str, str], limit: int = 10,
|
def search_music(keywords: str, cookies: dict[str, str], limit: int = 10,
|
||||||
search_type: int = 1, offset: int = 0) -> List[Dict[str, Any]]:
|
search_type: int = 1, offset: int = 0) -> list[dict[str, Any]]:
|
||||||
"""搜索音乐(向后兼容)"""
|
"""搜索音乐(向后兼容)"""
|
||||||
api = NeteaseAPI()
|
api = NeteaseAPI()
|
||||||
return api.search_music(keywords, cookies, limit, search_type, offset=offset)
|
return run_sync(api.search_music(keywords, cookies, limit, search_type, offset=offset))
|
||||||
|
|
||||||
|
|
||||||
def toplist_list() -> List[Dict[str, Any]]:
|
def toplist_list() -> list[dict[str, Any]]:
|
||||||
"""获取官方榜单列表(向后兼容)"""
|
"""获取官方榜单列表(向后兼容)"""
|
||||||
api = NeteaseAPI()
|
api = NeteaseAPI()
|
||||||
return api.get_toplist()
|
return run_sync(api.get_toplist())
|
||||||
|
|
||||||
|
|
||||||
def artist_songs(artist_id: int) -> Dict[str, Any]:
|
def artist_songs(artist_id: int) -> dict[str, Any]:
|
||||||
"""获取歌手信息和热门歌曲(向后兼容)"""
|
"""获取歌手信息和热门歌曲(向后兼容)"""
|
||||||
api = NeteaseAPI()
|
api = NeteaseAPI()
|
||||||
return api.get_artist_hot_songs(artist_id)
|
return run_sync(api.get_artist_hot_songs(artist_id))
|
||||||
|
|
||||||
|
|
||||||
def song_comments(song_id: int, limit: int = 20, offset: int = 0) -> Dict[str, Any]:
|
def song_comments(song_id: int, limit: int = 20, offset: int = 0) -> dict[str, Any]:
|
||||||
"""获取歌曲评论(向后兼容)"""
|
"""获取歌曲评论(向后兼容)"""
|
||||||
api = NeteaseAPI()
|
api = NeteaseAPI()
|
||||||
return api.get_song_comments(song_id, limit, offset)
|
return run_sync(api.get_song_comments(song_id, limit, offset))
|
||||||
|
|
||||||
|
|
||||||
def account_info(cookies: Dict[str, str]) -> Optional[Dict[str, Any]]:
|
def account_info(cookies: dict[str, str]) -> dict[str, Any] | None:
|
||||||
"""获取当前账号信息(向后兼容)"""
|
"""获取当前账号信息(向后兼容)"""
|
||||||
api = NeteaseAPI()
|
api = NeteaseAPI()
|
||||||
return api.get_account_info(cookies)
|
return run_sync(api.get_account_info(cookies))
|
||||||
|
|
||||||
|
|
||||||
def user_playlists(user_id: int, cookies: Dict[str, str]) -> List[Dict[str, Any]]:
|
def user_playlists(user_id: int, cookies: dict[str, str]) -> list[dict[str, Any]]:
|
||||||
"""获取用户收藏/创建的歌单列表(向后兼容)"""
|
"""获取用户收藏/创建的歌单列表(向后兼容)"""
|
||||||
api = NeteaseAPI()
|
api = NeteaseAPI()
|
||||||
return api.get_user_playlists(user_id, cookies)
|
return run_sync(api.get_user_playlists(user_id, cookies))
|
||||||
|
|
||||||
|
|
||||||
def detect_vip_by_probe(cookies: Dict[str, str], probe_song_id: int = 347230) -> Dict[str, Any]:
|
def detect_vip_by_probe(cookies: dict[str, str], probe_song_id: int = 347230) -> dict[str, Any]:
|
||||||
"""通过歌曲URL接口探测账号会员类型(向后兼容)"""
|
"""通过歌曲URL接口探测账号会员类型(向后兼容)"""
|
||||||
api = NeteaseAPI()
|
api = NeteaseAPI()
|
||||||
return api.detect_vip_by_probe(cookies, probe_song_id)
|
return run_sync(api.detect_vip_by_probe(cookies, probe_song_id))
|
||||||
|
|
||||||
|
|
||||||
def playlist_subscribe(playlist_id: int, cookies: Dict[str, str], action: int = 1) -> Dict[str, Any]:
|
def playlist_subscribe(playlist_id: int, cookies: dict[str, str], action: int = 1) -> dict[str, Any]:
|
||||||
"""收藏/取消收藏歌单(向后兼容)"""
|
"""收藏/取消收藏歌单(向后兼容)"""
|
||||||
api = NeteaseAPI()
|
api = NeteaseAPI()
|
||||||
return api.subscribe_playlist(playlist_id, cookies, action)
|
return run_sync(api.subscribe_playlist(playlist_id, cookies, action))
|
||||||
|
|
||||||
|
|
||||||
def album_subscribe(album_id: int, cookies: Dict[str, str], action: int = 1) -> Dict[str, Any]:
|
def album_subscribe(album_id: int, cookies: dict[str, str], action: int = 1) -> dict[str, Any]:
|
||||||
"""收藏/取消收藏专辑(向后兼容)"""
|
"""收藏/取消收藏专辑(向后兼容)"""
|
||||||
api = NeteaseAPI()
|
api = NeteaseAPI()
|
||||||
return api.subscribe_album(album_id, cookies, action)
|
return run_sync(api.subscribe_album(album_id, cookies, action))
|
||||||
|
|
||||||
|
|
||||||
def post_comment(thread_id: str, content: str, action: int = 1,
|
def post_comment(thread_id: str, content: str, action: int = 1,
|
||||||
comment_id: Optional[int] = None,
|
comment_id: int | None = None,
|
||||||
cookies: Dict[str, str] = None) -> Dict[str, Any]:
|
cookies: dict[str, str] | None = None) -> dict[str, Any]:
|
||||||
"""发送/回复歌曲评论(向后兼容)"""
|
"""发送/回复歌曲评论(向后兼容)"""
|
||||||
api = NeteaseAPI()
|
api = NeteaseAPI()
|
||||||
return api.post_comment(thread_id, content, action, comment_id, cookies)
|
return run_sync(api.post_comment(thread_id, content, action, comment_id, cookies))
|
||||||
|
|
||||||
|
|
||||||
def probe_levels(cookies: Dict[str, str], probe_song_id: int = 347230) -> List[str]:
|
def probe_levels(cookies: dict[str, str], probe_song_id: int = 347230) -> list[str]:
|
||||||
"""实测账号可解析的音质列表(向后兼容)"""
|
"""实测账号可解析的音质列表(向后兼容)"""
|
||||||
api = NeteaseAPI()
|
api = NeteaseAPI()
|
||||||
return api.probe_levels(cookies, probe_song_id)
|
return run_sync(api.probe_levels(cookies, probe_song_id))
|
||||||
|
|
||||||
|
|
||||||
def playlist_detail(playlist_id: int, cookies: Dict[str, str]) -> Dict[str, Any]:
|
def playlist_detail(playlist_id: int, cookies: dict[str, str]) -> dict[str, Any]:
|
||||||
"""获取歌单详情(向后兼容)"""
|
"""获取歌单详情(向后兼容)"""
|
||||||
api = NeteaseAPI()
|
api = NeteaseAPI()
|
||||||
return api.get_playlist_detail(playlist_id, cookies)
|
return run_sync(api.get_playlist_detail(playlist_id, cookies))
|
||||||
|
|
||||||
|
|
||||||
def album_detail(album_id: int, cookies: Dict[str, str]) -> Dict[str, Any]:
|
def album_detail(album_id: int, cookies: dict[str, str]) -> dict[str, Any]:
|
||||||
"""获取专辑详情(向后兼容)"""
|
"""获取专辑详情(向后兼容)"""
|
||||||
api = NeteaseAPI()
|
api = NeteaseAPI()
|
||||||
return api.get_album_detail(album_id, cookies)
|
return run_sync(api.get_album_detail(album_id, cookies))
|
||||||
|
|
||||||
|
|
||||||
def get_pic_url(pic_id: Optional[int], size: int = 300) -> str:
|
def get_pic_url(pic_id: int | None, size: int = 300) -> str:
|
||||||
"""获取图片URL(向后兼容)"""
|
"""获取图片URL(向后兼容)"""
|
||||||
api = NeteaseAPI()
|
api = NeteaseAPI()
|
||||||
return api.get_pic_url(pic_id, size)
|
return api.get_pic_url(pic_id, size)
|
||||||
|
|
||||||
|
|
||||||
def qr_login() -> Optional[str]:
|
def qr_login() -> str | None:
|
||||||
"""二维码登录(向后兼容)"""
|
"""二维码登录(向后兼容)"""
|
||||||
manager = QRLoginManager()
|
manager = QRLoginManager()
|
||||||
return manager.qr_login()
|
return run_sync(manager.qr_login())
|
||||||
|
|
||||||
|
|
||||||
# ============ Cookie 工具 ============
|
# ============ Cookie 工具 ============
|
||||||
@@ -1406,7 +1409,7 @@ def qr_login() -> Optional[str]:
|
|||||||
DEFAULT_COOKIE_FILE = "cookie.txt"
|
DEFAULT_COOKIE_FILE = "cookie.txt"
|
||||||
|
|
||||||
|
|
||||||
def load_cookies(path: str = DEFAULT_COOKIE_FILE) -> Dict[str, str]:
|
def load_cookies(path: str = DEFAULT_COOKIE_FILE) -> dict[str, str]:
|
||||||
"""从 cookie.txt 直接读取并解析为 dict
|
"""从 cookie.txt 直接读取并解析为 dict
|
||||||
|
|
||||||
支持标准 ``NMTID=xxx; _ntes_nnid=xxx; ...`` 格式。
|
支持标准 ``NMTID=xxx; _ntes_nnid=xxx; ...`` 格式。
|
||||||
@@ -1417,19 +1420,19 @@ def load_cookies(path: str = DEFAULT_COOKIE_FILE) -> Dict[str, str]:
|
|||||||
if not os.path.isfile(path):
|
if not os.path.isfile(path):
|
||||||
return {}
|
return {}
|
||||||
try:
|
try:
|
||||||
with open(path, "r", encoding="utf-8-sig") as f:
|
with open(path, encoding="utf-8-sig") as f:
|
||||||
content = f.read()
|
content = f.read()
|
||||||
except OSError:
|
except OSError:
|
||||||
return {}
|
return {}
|
||||||
return parse_cookie_string(content)
|
return parse_cookie_string(content)
|
||||||
|
|
||||||
|
|
||||||
def parse_cookie_string(cookie_string: str) -> Dict[str, str]:
|
def parse_cookie_string(cookie_string: str) -> dict[str, str]:
|
||||||
"""把 ``k1=v1; k2=v2`` 格式字符串解析为 dict
|
"""把 ``k1=v1; k2=v2`` 格式字符串解析为 dict
|
||||||
|
|
||||||
支持 ``;`` 或 ``\\n`` 作为分隔符。
|
支持 ``;`` 或 ``\\n`` 作为分隔符。
|
||||||
"""
|
"""
|
||||||
cookies: Dict[str, str] = {}
|
cookies: dict[str, str] = {}
|
||||||
if not cookie_string:
|
if not cookie_string:
|
||||||
return cookies
|
return cookies
|
||||||
cookie_string = cookie_string.strip()
|
cookie_string = cookie_string.strip()
|
||||||
|
|||||||
+40
-44
@@ -15,7 +15,6 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import contextlib
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
@@ -27,13 +26,12 @@ import time
|
|||||||
from collections import deque
|
from collections import deque
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from io import BytesIO
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from requests.adapters import HTTPAdapter
|
from requests.adapters import HTTPAdapter
|
||||||
|
|
||||||
|
from core.async_bridge import run_sync
|
||||||
from music_api import APIException, NeteaseAPI, load_cookies
|
from music_api import APIException, NeteaseAPI, load_cookies
|
||||||
|
|
||||||
# P2-5:worker 线程的输出一律走 logger(坑 11——NSSM GBK 重定向下裸 print
|
# P2-5:worker 线程的输出一律走 logger(坑 11——NSSM GBK 重定向下裸 print
|
||||||
@@ -80,7 +78,7 @@ def _sniff_audio_header(path) -> bool:
|
|||||||
|
|
||||||
def get_http_session() -> requests.Session:
|
def get_http_session() -> requests.Session:
|
||||||
"""获取当前线程的共享 requests.Session(带连接池 + keep-alive)"""
|
"""获取当前线程的共享 requests.Session(带连接池 + keep-alive)"""
|
||||||
session: Optional[requests.Session] = getattr(_SESSION_LOCAL, "session", None)
|
session: requests.Session | None = getattr(_SESSION_LOCAL, "session", None)
|
||||||
if session is None:
|
if session is None:
|
||||||
session = requests.Session()
|
session = requests.Session()
|
||||||
adapter = HTTPAdapter(pool_connections=8, pool_maxsize=32, max_retries=0)
|
adapter = HTTPAdapter(pool_connections=8, pool_maxsize=32, max_retries=0)
|
||||||
@@ -96,7 +94,7 @@ def get_http_session() -> requests.Session:
|
|||||||
# F78:锁表带引用计数与回收——无人引用时从表中移除,防止长期运行无界增长
|
# F78:锁表带引用计数与回收——无人引用时从表中移除,防止长期运行无界增长
|
||||||
# A3:计数在注册表守卫锁内完成("取出引用"即计数 +1),消除"取引用后、acquire 前
|
# A3:计数在注册表守卫锁内完成("取出引用"即计数 +1),消除"取引用后、acquire 前
|
||||||
# 被另一个线程回收、第三个线程拿到新锁对象"的互斥破坏窗口
|
# 被另一个线程回收、第三个线程拿到新锁对象"的互斥破坏窗口
|
||||||
_FILE_LOCKS: Dict[str, "RefCountedFileLock"] = {}
|
_FILE_LOCKS: dict[str, RefCountedFileLock] = {}
|
||||||
_FILE_LOCKS_GUARD = threading.Lock()
|
_FILE_LOCKS_GUARD = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
@@ -125,7 +123,7 @@ class _FileLockHandle:
|
|||||||
self._lock = None
|
self._lock = None
|
||||||
self._key = ''
|
self._key = ''
|
||||||
|
|
||||||
def __enter__(self) -> "RefCountedFileLock":
|
def __enter__(self) -> RefCountedFileLock:
|
||||||
key = str(self._path)
|
key = str(self._path)
|
||||||
with _FILE_LOCKS_GUARD:
|
with _FILE_LOCKS_GUARD:
|
||||||
lock = _FILE_LOCKS.get(key)
|
lock = _FILE_LOCKS.get(key)
|
||||||
@@ -159,18 +157,18 @@ class _FileLockHandle:
|
|||||||
self._lock = None
|
self._lock = None
|
||||||
|
|
||||||
|
|
||||||
def get_file_lock(path: Path) -> "_FileLockHandle":
|
def get_file_lock(path: Path) -> _FileLockHandle:
|
||||||
"""兼容入口:返回可用 with 语句的锁句柄(A3:引用计数在守卫锁内登记,无回收竞态)"""
|
"""兼容入口:返回可用 with 语句的锁句柄(A3:引用计数在守卫锁内登记,无回收竞态)"""
|
||||||
return _FileLockHandle(path)
|
return _FileLockHandle(path)
|
||||||
|
|
||||||
|
|
||||||
# 封面字节缓存:同一专辑封面只从网络拉取一次(ffmpeg 写元数据 与 ZIP 打包 共用)
|
# 封面字节缓存:同一专辑封面只从网络拉取一次(ffmpeg 写元数据 与 ZIP 打包 共用)
|
||||||
_COVER_CACHE: Dict[str, bytes] = {}
|
_COVER_CACHE: dict[str, bytes] = {}
|
||||||
_COVER_CACHE_ORDER: deque = deque(maxlen=128)
|
_COVER_CACHE_ORDER: deque = deque(maxlen=128)
|
||||||
_COVER_CACHE_LOCK = threading.Lock()
|
_COVER_CACHE_LOCK = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
def fetch_cover_bytes(pic_url: str) -> Optional[bytes]:
|
def fetch_cover_bytes(pic_url: str) -> bytes | None:
|
||||||
"""下载并缓存封面图片字节;失败返回 None(B3:字节预算 10MB,拒绝超大盘点阵/伪装文件)"""
|
"""下载并缓存封面图片字节;失败返回 None(B3:字节预算 10MB,拒绝超大盘点阵/伪装文件)"""
|
||||||
if not pic_url:
|
if not pic_url:
|
||||||
return None
|
return None
|
||||||
@@ -243,17 +241,17 @@ class MusicInfo:
|
|||||||
quality: str
|
quality: str
|
||||||
lyric: str = ""
|
lyric: str = ""
|
||||||
tlyric: str = ""
|
tlyric: str = ""
|
||||||
publish_year: Optional[int] = None
|
publish_year: int | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class DownloadResult:
|
class DownloadResult:
|
||||||
"""下载结果数据类"""
|
"""下载结果数据类"""
|
||||||
success: bool
|
success: bool
|
||||||
file_path: Optional[str] = None
|
file_path: str | None = None
|
||||||
file_size: int = 0
|
file_size: int = 0
|
||||||
error_message: str = ""
|
error_message: str = ""
|
||||||
music_info: Optional[MusicInfo] = None
|
music_info: MusicInfo | None = None
|
||||||
|
|
||||||
|
|
||||||
class DownloadException(Exception):
|
class DownloadException(Exception):
|
||||||
@@ -330,7 +328,7 @@ class CountingSemaphore:
|
|||||||
with self._lock:
|
with self._lock:
|
||||||
self.active = max(0, self.active - 1)
|
self.active = max(0, self.active - 1)
|
||||||
|
|
||||||
def __enter__(self) -> "CountingSemaphore":
|
def __enter__(self) -> CountingSemaphore:
|
||||||
self.acquire()
|
self.acquire()
|
||||||
return self
|
return self
|
||||||
|
|
||||||
@@ -378,7 +376,7 @@ class MusicDownloader:
|
|||||||
self,
|
self,
|
||||||
download_dir: str = "downloads",
|
download_dir: str = "downloads",
|
||||||
max_concurrent: int = 3,
|
max_concurrent: int = 3,
|
||||||
ffmpeg_path: Optional[str] = None,
|
ffmpeg_path: str | None = None,
|
||||||
global_max_concurrent: int = 10,
|
global_max_concurrent: int = 10,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
@@ -461,7 +459,7 @@ class MusicDownloader:
|
|||||||
|
|
||||||
return ".mp3"
|
return ".mp3"
|
||||||
|
|
||||||
def _resolve_ffmpeg(self) -> Optional[str]:
|
def _resolve_ffmpeg(self) -> str | None:
|
||||||
"""懒加载解析 ffmpeg 路径
|
"""懒加载解析 ffmpeg 路径
|
||||||
|
|
||||||
每次写入前调用一次:若启动时未检测到,但中途已安装,下一次就能用。
|
每次写入前调用一次:若启动时未检测到,但中途已安装,下一次就能用。
|
||||||
@@ -484,31 +482,29 @@ class MusicDownloader:
|
|||||||
# ----------- 信息获取 -----------
|
# ----------- 信息获取 -----------
|
||||||
|
|
||||||
def get_music_info(self, music_id: int, quality: str = "standard",
|
def get_music_info(self, music_id: int, quality: str = "standard",
|
||||||
cookies: Optional[Dict[str, str]] = None) -> MusicInfo:
|
cookies: dict[str, str] | None = None) -> MusicInfo:
|
||||||
"""获取音乐详细信息(同步,URL/详情/歌词 3 个接口并行请求,加快单曲/批量启动)"""
|
"""获取音乐详细信息(同步,URL/详情/歌词 3 个接口并行请求,加快单曲/批量启动)"""
|
||||||
try:
|
try:
|
||||||
cookies = cookies if cookies is not None else load_cookies()
|
cookies = cookies if cookies is not None else load_cookies()
|
||||||
|
|
||||||
# 3 个接口并行请求,单曲下载/批量预取都能明显提速
|
# 3 个接口并行请求,单曲下载/批量预取都能明显提速
|
||||||
# F99:URL/详情拿到后立即 shutdown(cancel_futures),
|
# F99:URL/详情拿到后立即取消未完成的歌词请求,已知失败不再被拖着等完
|
||||||
# 已知失败不再被无关的歌词请求拖着等完才报错
|
# 异步化:改用 asyncio.gather 并发(api 方法已异步),失败语义与旧线程池一致
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
import asyncio
|
||||||
_pool = ThreadPoolExecutor(max_workers=3)
|
|
||||||
url_f = _pool.submit(self.api.get_song_url, music_id, quality, cookies)
|
async def _gather_info():
|
||||||
detail_f = _pool.submit(self.api.get_song_detail, music_id)
|
url_t = self.api.get_song_url(music_id, quality, cookies)
|
||||||
lyric_f = _pool.submit(self.api.get_lyric, music_id, cookies)
|
detail_t = self.api.get_song_detail(music_id)
|
||||||
try:
|
lyric_t = self.api.get_lyric(music_id, cookies)
|
||||||
url_result = url_f.result()
|
return await asyncio.gather(url_t, detail_t, lyric_t, return_exceptions=True)
|
||||||
detail_result = detail_f.result()
|
|
||||||
try:
|
url_result, detail_result, lyric_result = run_sync(_gather_info())
|
||||||
lyric_result = lyric_f.result()
|
if isinstance(url_result, BaseException):
|
||||||
except Exception:
|
raise url_result
|
||||||
lyric_result = None # 歌词接口抖动不阻断解析/下载
|
if isinstance(detail_result, BaseException):
|
||||||
finally:
|
raise detail_result
|
||||||
try:
|
if isinstance(lyric_result, BaseException):
|
||||||
_pool.shutdown(wait=False, cancel_futures=True)
|
lyric_result = None # 歌词接口抖动不阻断解析/下载
|
||||||
except TypeError:
|
|
||||||
_pool.shutdown(wait=False)
|
|
||||||
|
|
||||||
if not url_result.get("data") or not url_result["data"]:
|
if not url_result.get("data") or not url_result["data"]:
|
||||||
raise DownloadException(f"无法获取音乐ID {music_id} 的播放链接")
|
raise DownloadException(f"无法获取音乐ID {music_id} 的播放链接")
|
||||||
@@ -602,11 +598,11 @@ class MusicDownloader:
|
|||||||
music_id: int,
|
music_id: int,
|
||||||
quality: str = "standard",
|
quality: str = "standard",
|
||||||
write_meta: bool = True,
|
write_meta: bool = True,
|
||||||
meta_fields: Optional[List[str]] = None,
|
meta_fields: list[str] | None = None,
|
||||||
cookies: Optional[Dict[str, str]] = None,
|
cookies: dict[str, str] | None = None,
|
||||||
retries: int = 2,
|
retries: int = 2,
|
||||||
cancel_event: Optional[threading.Event] = None,
|
cancel_event: threading.Event | None = None,
|
||||||
music_info: Optional[MusicInfo] = None,
|
music_info: MusicInfo | None = None,
|
||||||
progress_cb=None,
|
progress_cb=None,
|
||||||
) -> DownloadResult:
|
) -> DownloadResult:
|
||||||
"""同步下载音乐到本地
|
"""同步下载音乐到本地
|
||||||
@@ -719,7 +715,7 @@ class MusicDownloader:
|
|||||||
progress_cb(resume_from, music_info.file_size or 0)
|
progress_cb(resume_from, music_info.file_size or 0)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # 进度回调绝不影响下载主流程
|
pass # 进度回调绝不影响下载主流程
|
||||||
resp: Optional[requests.Response] = None
|
resp: requests.Response | None = None
|
||||||
try:
|
try:
|
||||||
resp = get_http_session().get(
|
resp = get_http_session().get(
|
||||||
music_info.download_url,
|
music_info.download_url,
|
||||||
@@ -878,7 +874,7 @@ class MusicDownloader:
|
|||||||
self,
|
self,
|
||||||
file_path: Path,
|
file_path: Path,
|
||||||
music_info: MusicInfo,
|
music_info: MusicInfo,
|
||||||
fields: Optional[List[str]] = None,
|
fields: list[str] | None = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""使用 ffmpeg 写入音频元数据
|
"""使用 ffmpeg 写入音频元数据
|
||||||
|
|
||||||
@@ -909,7 +905,7 @@ class MusicDownloader:
|
|||||||
file_path.stem + ".tagging" + file_path.suffix
|
file_path.stem + ".tagging" + file_path.suffix
|
||||||
)
|
)
|
||||||
# 临时封面文件
|
# 临时封面文件
|
||||||
cover_tmp: Optional[str] = None
|
cover_tmp: str | None = None
|
||||||
cover_ok = False # F114:创建成功与否与"路径是否需要清理"分离管理
|
cover_ok = False # F114:创建成功与否与"路径是否需要清理"分离管理
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -934,7 +930,7 @@ class MusicDownloader:
|
|||||||
# 构造 ffmpeg 命令
|
# 构造 ffmpeg 命令
|
||||||
# 顺序: global opts -> -i input1 -> -i input2 -> metadata -> mapping -> -f format -> output
|
# 顺序: global opts -> -i input1 -> -i input2 -> metadata -> mapping -> -f format -> output
|
||||||
output_format = EXT_TO_FFMPEG_FORMAT.get(file_ext, "")
|
output_format = EXT_TO_FFMPEG_FORMAT.get(file_ext, "")
|
||||||
cmd: List[str] = [
|
cmd: list[str] = [
|
||||||
ffmpeg_path, "-y", "-loglevel", "error",
|
ffmpeg_path, "-y", "-loglevel", "error",
|
||||||
"-i", str(file_path),
|
"-i", str(file_path),
|
||||||
]
|
]
|
||||||
@@ -1030,7 +1026,7 @@ class MusicDownloader:
|
|||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def _build_format_args(self, file_ext: str, has_cover: bool) -> List[str]:
|
def _build_format_args(self, file_ext: str, has_cover: bool) -> list[str]:
|
||||||
"""根据文件格式构造 mapping / 编码参数
|
"""根据文件格式构造 mapping / 编码参数
|
||||||
F34:不写封面时用 -map 0 -c copy 保留全部既有流(含已有封面),
|
F34:不写封面时用 -map 0 -c copy 保留全部既有流(含已有封面),
|
||||||
原实现 -map 0:a 会把 MP3/M4A 已有封面静默删掉。"""
|
原实现 -map 0:a 会把 MP3/M4A 已有封面静默删掉。"""
|
||||||
|
|||||||
+39
-32
@@ -19,8 +19,7 @@
|
|||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
from random import randrange
|
from typing import Any
|
||||||
from typing import Dict, List, Optional, Any
|
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
@@ -54,7 +53,7 @@ class PodcastConstants:
|
|||||||
_MAX_UPSTREAM_JSON_BYTES = 8 * 1024 * 1024
|
_MAX_UPSTREAM_JSON_BYTES = 8 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
def _post_json(url: str, data: Dict[str, Any], cookies: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
|
def _post_json(url: str, data: dict[str, Any], cookies: dict[str, str] | None = None) -> dict[str, Any]:
|
||||||
"""发送表单 POST 请求并返回 JSON"""
|
"""发送表单 POST 请求并返回 JSON"""
|
||||||
headers = {
|
headers = {
|
||||||
'User-Agent': APIConstants.USER_AGENT,
|
'User-Agent': APIConstants.USER_AGENT,
|
||||||
@@ -78,7 +77,15 @@ def _post_json(url: str, data: Dict[str, Any], cookies: Optional[Dict[str, str]]
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _ts_to_date(ts: Optional[int]) -> str:
|
def _to_int(v: Any, default: int | None = None) -> int | None:
|
||||||
|
"""安全转 int:垃圾值/None 回退默认值(上游字段常为 null/字符串)。"""
|
||||||
|
try:
|
||||||
|
return int(v)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _ts_to_date(ts: int | None) -> str:
|
||||||
"""毫秒时间戳 → 日期字符串"""
|
"""毫秒时间戳 → 日期字符串"""
|
||||||
try:
|
try:
|
||||||
if not ts:
|
if not ts:
|
||||||
@@ -88,7 +95,7 @@ def _ts_to_date(ts: Optional[int]) -> str:
|
|||||||
return ''
|
return ''
|
||||||
|
|
||||||
|
|
||||||
def _fmt_duration(ms: Optional[int]) -> str:
|
def _fmt_duration(ms: int | None) -> str:
|
||||||
"""毫秒 → mm:ss / hh:mm:ss"""
|
"""毫秒 → mm:ss / hh:mm:ss"""
|
||||||
try:
|
try:
|
||||||
sec = int((ms or 0) / 1000)
|
sec = int((ms or 0) / 1000)
|
||||||
@@ -101,7 +108,7 @@ def _fmt_duration(ms: Optional[int]) -> str:
|
|||||||
return f"{h}:{m:02d}:{s:02d}" if h else f"{m:02d}:{s:02d}"
|
return f"{h}:{m:02d}:{s:02d}" if h else f"{m:02d}:{s:02d}"
|
||||||
|
|
||||||
|
|
||||||
def _fmt_count(n: Optional[int]) -> str:
|
def _fmt_count(n: int | None) -> str:
|
||||||
"""数量 → 万单位简写"""
|
"""数量 → 万单位简写"""
|
||||||
try:
|
try:
|
||||||
n = int(n or 0)
|
n = int(n or 0)
|
||||||
@@ -118,7 +125,7 @@ def _fmt_count(n: Optional[int]) -> str:
|
|||||||
|
|
||||||
# ============ 数据规范化 ============
|
# ============ 数据规范化 ============
|
||||||
|
|
||||||
def _norm_radio(raw: Dict[str, Any]) -> Dict[str, Any]:
|
def _norm_radio(raw: dict[str, Any]) -> dict[str, Any]:
|
||||||
"""规范化电台(播客)信息"""
|
"""规范化电台(播客)信息"""
|
||||||
dj = raw.get('dj') or {}
|
dj = raw.get('dj') or {}
|
||||||
return {
|
return {
|
||||||
@@ -139,7 +146,7 @@ def _norm_radio(raw: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _norm_program(raw: Dict[str, Any]) -> Dict[str, Any]:
|
def _norm_program(raw: dict[str, Any]) -> dict[str, Any]:
|
||||||
"""规范化单集(program)信息"""
|
"""规范化单集(program)信息"""
|
||||||
radio = raw.get('radio') or {}
|
radio = raw.get('radio') or {}
|
||||||
dj = raw.get('dj') or radio.get('dj') or {}
|
dj = raw.get('dj') or radio.get('dj') or {}
|
||||||
@@ -168,7 +175,7 @@ def _norm_program(raw: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
|
|
||||||
# ============ 链接 / ID 解析 ============
|
# ============ 链接 / ID 解析 ============
|
||||||
|
|
||||||
_SHORTLINK_CACHE: Dict[str, str] = {}
|
_SHORTLINK_CACHE: dict[str, str] = {}
|
||||||
|
|
||||||
|
|
||||||
def _resolve_short_link(url: str) -> str:
|
def _resolve_short_link(url: str) -> str:
|
||||||
@@ -201,7 +208,7 @@ def _resolve_short_link(url: str) -> str:
|
|||||||
return real
|
return real
|
||||||
|
|
||||||
|
|
||||||
def extract_podcast_ref(raw: str) -> Dict[str, str]:
|
def extract_podcast_ref(raw: str) -> dict[str, str]:
|
||||||
"""从用户输入中提取播客引用(T4:正规 URL 解析,替代子串匹配与非数字剔除)
|
"""从用户输入中提取播客引用(T4:正规 URL 解析,替代子串匹配与非数字剔除)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -210,7 +217,7 @@ def extract_podcast_ref(raw: str) -> Dict[str, str]:
|
|||||||
- program: program?id= / sound?id= 链接
|
- program: program?id= / sound?id= 链接
|
||||||
- unknown: 纯数字 ID(需依次尝试单集/电台)
|
- unknown: 纯数字 ID(需依次尝试单集/电台)
|
||||||
"""
|
"""
|
||||||
from urllib.parse import urlsplit, parse_qs
|
from urllib.parse import parse_qs, urlsplit
|
||||||
text = str(raw or '').strip()
|
text = str(raw or '').strip()
|
||||||
# 短链先还原(仅当整串就是一个短链 URL)
|
# 短链先还原(仅当整串就是一个短链 URL)
|
||||||
if '163cn.tv' in text.lower():
|
if '163cn.tv' in text.lower():
|
||||||
@@ -247,9 +254,9 @@ def extract_podcast_ref(raw: str) -> Dict[str, str]:
|
|||||||
|
|
||||||
# ============ 核心 API ============
|
# ============ 核心 API ============
|
||||||
|
|
||||||
def search_podcast(keywords: str, cookies: Optional[Dict[str, str]] = None,
|
def search_podcast(keywords: str, cookies: dict[str, str] | None = None,
|
||||||
limit: int = 20, offset: int = 0,
|
limit: int = 20, offset: int = 0,
|
||||||
search_type: str = 'all') -> Dict[str, Any]:
|
search_type: str = 'all') -> dict[str, Any]:
|
||||||
"""搜索播客
|
"""搜索播客
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -267,9 +274,9 @@ def search_podcast(keywords: str, cookies: Optional[Dict[str, str]] = None,
|
|||||||
raise APIException('搜索关键词不能为空')
|
raise APIException('搜索关键词不能为空')
|
||||||
limit = max(1, min(int(limit or 20), 50))
|
limit = max(1, min(int(limit or 20), 50))
|
||||||
|
|
||||||
result: Dict[str, Any] = {'radios': [], 'voices': []}
|
result: dict[str, Any] = {'radios': [], 'voices': []}
|
||||||
|
|
||||||
def _do_search(stype: int) -> Dict[str, Any]:
|
def _do_search(stype: int) -> dict[str, Any]:
|
||||||
return _post_json(PodcastConstants.SEARCH_API,
|
return _post_json(PodcastConstants.SEARCH_API,
|
||||||
{'s': keywords, 'type': stype, 'limit': limit, 'offset': offset},
|
{'s': keywords, 'type': stype, 'limit': limit, 'offset': offset},
|
||||||
cookies)
|
cookies)
|
||||||
@@ -288,7 +295,7 @@ def search_podcast(keywords: str, cookies: Optional[Dict[str, str]] = None,
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def get_radio_detail(radio_id: Any, cookies: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
|
def get_radio_detail(radio_id: Any, cookies: dict[str, str] | None = None) -> dict[str, Any]:
|
||||||
"""获取播客电台(djradio)详情"""
|
"""获取播客电台(djradio)详情"""
|
||||||
try:
|
try:
|
||||||
rid = int(str(radio_id).strip())
|
rid = int(str(radio_id).strip())
|
||||||
@@ -302,8 +309,8 @@ def get_radio_detail(radio_id: Any, cookies: Optional[Dict[str, str]] = None) ->
|
|||||||
return _norm_radio(data)
|
return _norm_radio(data)
|
||||||
|
|
||||||
|
|
||||||
def get_radio_programs(radio_id: Any, cookies: Optional[Dict[str, str]] = None,
|
def get_radio_programs(radio_id: Any, cookies: dict[str, str] | None = None,
|
||||||
limit: int = 30, offset: int = 0, asc: bool = True) -> Dict[str, Any]:
|
limit: int = 30, offset: int = 0, asc: bool = True) -> dict[str, Any]:
|
||||||
"""获取电台下的单集列表(分页)
|
"""获取电台下的单集列表(分页)
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -334,7 +341,7 @@ def get_radio_programs(radio_id: Any, cookies: Optional[Dict[str, str]] = None,
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_program_detail(program_id: Any, cookies: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
|
def get_program_detail(program_id: Any, cookies: dict[str, str] | None = None) -> dict[str, Any]:
|
||||||
"""获取单集(program)详情"""
|
"""获取单集(program)详情"""
|
||||||
try:
|
try:
|
||||||
pid = int(str(program_id).strip())
|
pid = int(str(program_id).strip())
|
||||||
@@ -347,7 +354,7 @@ def get_program_detail(program_id: Any, cookies: Optional[Dict[str, str]] = None
|
|||||||
return _norm_program(program)
|
return _norm_program(program)
|
||||||
|
|
||||||
|
|
||||||
def get_categories() -> List[Dict[str, Any]]:
|
def get_categories() -> list[dict[str, Any]]:
|
||||||
"""获取播客分类列表(GET 接口)
|
"""获取播客分类列表(GET 接口)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -387,9 +394,9 @@ def get_categories() -> List[Dict[str, Any]]:
|
|||||||
return cats
|
return cats
|
||||||
|
|
||||||
|
|
||||||
def get_hot_radios(cookies: Optional[Dict[str, str]] = None,
|
def get_hot_radios(cookies: dict[str, str] | None = None,
|
||||||
limit: int = 30, offset: int = 0,
|
limit: int = 30, offset: int = 0,
|
||||||
cate_id: Optional[int] = None) -> List[Dict[str, Any]]:
|
cate_id: int | None = None) -> list[dict[str, Any]]:
|
||||||
"""获取分类下热门播客
|
"""获取分类下热门播客
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -400,7 +407,7 @@ def get_hot_radios(cookies: Optional[Dict[str, str]] = None,
|
|||||||
电台列表(已规范化)
|
电台列表(已规范化)
|
||||||
"""
|
"""
|
||||||
limit = max(1, min(int(limit or 30), 100))
|
limit = max(1, min(int(limit or 30), 100))
|
||||||
cate_ids: List[int] = []
|
cate_ids: list[int] = []
|
||||||
user_cate = False
|
user_cate = False
|
||||||
if cate_id:
|
if cate_id:
|
||||||
try:
|
try:
|
||||||
@@ -425,8 +432,8 @@ def get_hot_radios(cookies: Optional[Dict[str, str]] = None,
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
def resolve_episode_url(song_id: Any, cookies: Optional[Dict[str, str]] = None,
|
def resolve_episode_url(song_id: Any, cookies: dict[str, str] | None = None,
|
||||||
level: str = 'auto') -> Optional[Dict[str, Any]]:
|
level: str = 'auto') -> dict[str, Any] | None:
|
||||||
"""解析单集音频直链(带音质降级)
|
"""解析单集音频直链(带音质降级)
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -439,7 +446,7 @@ def resolve_episode_url(song_id: Any, cookies: Optional[Dict[str, str]] = None,
|
|||||||
"""
|
"""
|
||||||
# F83:降级阶梯只向下不向上——原实现指定 standard 会回退到更高的 exhigh
|
# F83:降级阶梯只向下不向上——原实现指定 standard 会回退到更高的 exhigh
|
||||||
_FALLDOWN_LADDER = ['hires', 'lossless', 'exhigh', 'standard']
|
_FALLDOWN_LADDER = ['hires', 'lossless', 'exhigh', 'standard']
|
||||||
order: List[str] = []
|
order: list[str] = []
|
||||||
level = str(level or 'auto').strip()
|
level = str(level or 'auto').strip()
|
||||||
if level and level != 'auto':
|
if level and level != 'auto':
|
||||||
order.append(level)
|
order.append(level)
|
||||||
@@ -454,7 +461,7 @@ def resolve_episode_url(song_id: Any, cookies: Optional[Dict[str, str]] = None,
|
|||||||
else:
|
else:
|
||||||
order.extend(PodcastConstants.QUALITY_LADDER)
|
order.extend(PodcastConstants.QUALITY_LADDER)
|
||||||
# 去重保序
|
# 去重保序
|
||||||
dedup: List[str] = []
|
dedup: list[str] = []
|
||||||
for lv in order:
|
for lv in order:
|
||||||
if lv not in dedup:
|
if lv not in dedup:
|
||||||
dedup.append(lv)
|
dedup.append(lv)
|
||||||
@@ -487,8 +494,8 @@ def resolve_episode_url(song_id: Any, cookies: Optional[Dict[str, str]] = None,
|
|||||||
|
|
||||||
# ============ 组合能力 ============
|
# ============ 组合能力 ============
|
||||||
|
|
||||||
def parse_podcast_ref(raw: str, cookies: Optional[Dict[str, str]] = None,
|
def parse_podcast_ref(raw: str, cookies: dict[str, str] | None = None,
|
||||||
page_size: int = 30) -> Dict[str, Any]:
|
page_size: int = 30) -> dict[str, Any]:
|
||||||
"""智能解析用户输入:电台链接 → 电台详情+单集列表;单集链接 → 单集详情
|
"""智能解析用户输入:电台链接 → 电台详情+单集列表;单集链接 → 单集详情
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -515,15 +522,15 @@ def parse_podcast_ref(raw: str, cookies: Optional[Dict[str, str]] = None,
|
|||||||
return {'kind': 'radio', 'radio': radio, **page}
|
return {'kind': 'radio', 'radio': radio, **page}
|
||||||
|
|
||||||
|
|
||||||
def list_radio_all_program_ids(radio_id: Any, cookies: Optional[Dict[str, str]] = None,
|
def list_radio_all_program_ids(radio_id: Any, cookies: dict[str, str] | None = None,
|
||||||
max_count: int = 200) -> List[Dict[str, Any]]:
|
max_count: int = 200) -> list[dict[str, Any]]:
|
||||||
"""拉取电台全部单集(用于整台打包,正序),返回 [{'id','name','main_song_id','serial_num','cover_url'}]
|
"""拉取电台全部单集(用于整台打包,正序),返回 [{'id','name','main_song_id','serial_num','cover_url'}]
|
||||||
|
|
||||||
最多拉取 max_count 集,防止超大电台拖垮磁盘/任务。
|
最多拉取 max_count 集,防止超大电台拖垮磁盘/任务。
|
||||||
F98:按节目 ID 去重;一页没有任何新增(短页/重复页/列表变动)即有界停止,
|
F98:按节目 ID 去重;一页没有任何新增(短页/重复页/列表变动)即有界停止,
|
||||||
不再固定 offset+=100 盲目翻页。
|
不再固定 offset+=100 盲目翻页。
|
||||||
"""
|
"""
|
||||||
out: List[Dict[str, Any]] = []
|
out: list[dict[str, Any]] = []
|
||||||
seen_ids = set()
|
seen_ids = set()
|
||||||
offset = 0
|
offset = 0
|
||||||
page_size = 100
|
page_size = 100
|
||||||
|
|||||||
@@ -25,6 +25,12 @@ ignore = [
|
|||||||
"S603", "S607", # subprocess(打包/运维脚本)
|
"S603", "S607", # subprocess(打包/运维脚本)
|
||||||
"UP009", # 保留 `# -*- coding: utf-8 -*-` 文件头(全项目既有约定)
|
"UP009", # 保留 `# -*- coding: utf-8 -*-` 文件头(全项目既有约定)
|
||||||
"RUF001", "RUF002", "RUF003", # 全角字符(中文注释/文案,误报)
|
"RUF001", "RUF002", "RUF003", # 全角字符(中文注释/文案,误报)
|
||||||
|
# ---- 以下为「有意保留」,随文件在迁移中重写时再行处理,非待修缺陷 ----
|
||||||
|
"B904", # raise-from 异常链:纯风格,40 处均在历史巨石文件,逐处改写收益低风险高
|
||||||
|
"RUF012", # 常量类可变默认值:APIConstants/PodcastConstants 是常量表,非实例状态
|
||||||
|
"S305", # AES-ECB:网易云 weapi 协议**要求**的加密模式,非可自由选择
|
||||||
|
"S311", # randrange:用于生成上游 requestId(协议随机数),非安全用途
|
||||||
|
"ASYNC250", # input():qr_login 交互式 CLI 的人类输入,异步化无意义
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.ruff.lint.per-file-ignores]
|
[tool.ruff.lint.per-file-ignores]
|
||||||
|
|||||||
+13
-13
@@ -7,17 +7,17 @@
|
|||||||
- 用户友好的交互界面
|
- 用户友好的交互界面
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import datetime
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
import shutil
|
|
||||||
import logging
|
|
||||||
import datetime
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, Tuple
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from music_api import QRLoginManager, APIException, load_cookies
|
from music_api import APIException, QRLoginManager, load_cookies
|
||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
print(f"导入模块失败: {e}")
|
print(f"导入模块失败: {e}")
|
||||||
print("请确保 music_api.py 文件存在且可用")
|
print("请确保 music_api.py 文件存在且可用")
|
||||||
@@ -87,7 +87,7 @@ class QRLoginClient:
|
|||||||
shutil.copy2(backup, self.cookie_file)
|
shutil.copy2(backup, self.cookie_file)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _backup_cookie(self, suffix: str = "backup") -> Optional[Path]:
|
def _backup_cookie(self, suffix: str = "backup") -> Path | None:
|
||||||
if not self.cookie_file.exists():
|
if not self.cookie_file.exists():
|
||||||
return None
|
return None
|
||||||
ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S_%f") # 第 91 条(P3):微秒防同秒覆盖
|
ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S_%f") # 第 91 条(P3):微秒防同秒覆盖
|
||||||
@@ -172,7 +172,7 @@ class QRLoginClient:
|
|||||||
self.logger.error(f"检查登录状态失败: {e}")
|
self.logger.error(f"检查登录状态失败: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def interactive_login(self) -> Tuple[bool, Optional[str]]:
|
async def interactive_login(self) -> tuple[bool, str | None]:
|
||||||
"""交互式二维码登录"""
|
"""交互式二维码登录"""
|
||||||
try:
|
try:
|
||||||
print("\n=== 网易云音乐二维码登录 ===")
|
print("\n=== 网易云音乐二维码登录 ===")
|
||||||
@@ -186,7 +186,7 @@ class QRLoginClient:
|
|||||||
print("\n开始二维码登录流程...")
|
print("\n开始二维码登录流程...")
|
||||||
print("正在生成二维码...")
|
print("正在生成二维码...")
|
||||||
# create_qr_login 返回 unikey 字符串(失败为 None),并已在控制台打印二维码
|
# create_qr_login 返回 unikey 字符串(失败为 None),并已在控制台打印二维码
|
||||||
qr_key = self.qr_manager.create_qr_login()
|
qr_key = await self.qr_manager.create_qr_login()
|
||||||
|
|
||||||
if not qr_key:
|
if not qr_key:
|
||||||
error_msg = "生成二维码失败"
|
error_msg = "生成二维码失败"
|
||||||
@@ -203,7 +203,7 @@ class QRLoginClient:
|
|||||||
while attempt < max_attempts:
|
while attempt < max_attempts:
|
||||||
try:
|
try:
|
||||||
# check_qr_login 返回 (状态码, cookie字典):801等待/802已扫/803成功/800过期
|
# check_qr_login 返回 (状态码, cookie字典):801等待/802已扫/803成功/800过期
|
||||||
code, cookie_dict = self.qr_manager.check_qr_login(qr_key)
|
code, cookie_dict = await self.qr_manager.check_qr_login(qr_key)
|
||||||
|
|
||||||
if code == 803:
|
if code == 803:
|
||||||
musu_u = cookie_dict.get('MUSIC_U', '')
|
musu_u = cookie_dict.get('MUSIC_U', '')
|
||||||
@@ -233,7 +233,7 @@ class QRLoginClient:
|
|||||||
if code == 801 and attempt % 10 == 0:
|
if code == 801 and attempt % 10 == 0:
|
||||||
print(f"等待扫码中... ({attempt + 1}/{max_attempts})")
|
print(f"等待扫码中... ({attempt + 1}/{max_attempts})")
|
||||||
|
|
||||||
time.sleep(5)
|
await asyncio.sleep(5)
|
||||||
attempt += 1
|
attempt += 1
|
||||||
|
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
@@ -241,7 +241,7 @@ class QRLoginClient:
|
|||||||
return False, "用户取消登录"
|
return False, "用户取消登录"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"检查登录状态时发生错误: {e}")
|
self.logger.error(f"检查登录状态时发生错误: {e}")
|
||||||
time.sleep(5)
|
await asyncio.sleep(5)
|
||||||
attempt += 1
|
attempt += 1
|
||||||
|
|
||||||
print("\n[超时] 登录超时,请重新尝试")
|
print("\n[超时] 登录超时,请重新尝试")
|
||||||
@@ -325,7 +325,7 @@ def main():
|
|||||||
command = sys.argv[1].lower()
|
command = sys.argv[1].lower()
|
||||||
|
|
||||||
if command == 'login':
|
if command == 'login':
|
||||||
success, error = client.interactive_login()
|
success, error = asyncio.run(client.interactive_login())
|
||||||
if success:
|
if success:
|
||||||
print("\n登录完成!")
|
print("\n登录完成!")
|
||||||
client.show_login_info()
|
client.show_login_info()
|
||||||
@@ -368,7 +368,7 @@ def main():
|
|||||||
choice = input("\n请选择操作 (1-4): ").strip()
|
choice = input("\n请选择操作 (1-4): ").strip()
|
||||||
|
|
||||||
if choice == '1':
|
if choice == '1':
|
||||||
success, error = client.interactive_login()
|
success, error = asyncio.run(client.interactive_login())
|
||||||
if success:
|
if success:
|
||||||
print("\n登录成功!")
|
print("\n登录成功!")
|
||||||
client.show_login_info()
|
client.show_login_info()
|
||||||
|
|||||||
Reference in new issue
Block a user