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:
snowy-yang committed 2026-09-26 00:32:45 +08:00
1 parent 4962542e78
commit ce7ea66d6a
8 files changed
+520 -490

No files matched your search

+21 -24
View File
@@ -6,12 +6,9 @@
import hmac
import secrets
import sqlite3
import time
from typing import Optional, Tuple
from flask import request, send_file
import auth_system as auth
@@ -304,7 +301,7 @@ def register_auth_routes(app) -> None:
return _ok({'user': auth._user_public(fresh),
'duration_text': auth.format_credits(fresh['duration_seconds']),
'redirect': 'admin' if fresh['role'] == 'admin' else 'user'},
f'注册成功' + (f',已赠送 {credits} 次下载次数' if credits else ''))
'注册成功' + (f',已赠送 {credits} 次下载次数' if credits else ''))
except Exception:
return _err('注册失败,请稍后重试', 500)
@@ -366,7 +363,7 @@ def register_auth_routes(app) -> None:
'密码验证通过,请完成邮箱验证')
auth.login_session(user)
# 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:
auth.logout_session()
return _err('检测到账号在多个省份异常登录,已自动封禁,请联系管理员')
@@ -442,7 +439,7 @@ def register_auth_routes(app) -> None:
return _err('账号已被禁用,请联系管理员')
auth.login_session(user)
# 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:
auth.logout_session()
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))
return _ok(message='用户已删除')
except Exception as e:
return _err(f'删除失败: {str(e)}', 500)
return _err(f'删除失败: {e!s}', 500)
@app.route('/admin/site/user/duration', methods=['POST'])
@auth.admin_required
@@ -576,9 +573,9 @@ def register_auth_routes(app) -> None:
f'{target["username"]} → {count} 次')
return _ok(message=f'已修改为 {count} 次')
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':
return _err('不能对管理员账号执行该操作')
@@ -622,7 +619,7 @@ def register_auth_routes(app) -> None:
f'{username} · {credits} 次')
return _ok(message=f'用户 {username} 创建成功({credits} 次)')
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'])
@auth.admin_required
@@ -653,7 +650,7 @@ def register_auth_routes(app) -> None:
auth.add_log('success', _actor(), auth._client_ip(), '重置密码', target['username'])
return _ok(message=f'已重置 {target["username"]} 的密码')
except Exception as e:
return _err(f'重置失败: {str(e)}', 500)
return _err(f'重置失败: {e!s}', 500)
@app.route('/admin/site/user/toggle', methods=['POST'])
@auth.admin_required
@@ -674,7 +671,7 @@ def register_auth_routes(app) -> None:
return _ok({'status': new_status},
message=f'已{"启用" if new_status else "禁用"} {target["username"]}')
except Exception as e:
return _err(f'操作失败: {str(e)}', 500)
return _err(f'操作失败: {e!s}', 500)
@app.route('/admin/site/user/ban', methods=['POST'])
@auth.admin_required
@@ -704,7 +701,7 @@ def register_auth_routes(app) -> None:
auth._client_ip(), '解封用户', row['username'])
return _ok(message=f'已解封用户「{row["username"]}」')
except Exception as e:
return _err(f'操作失败: {str(e)}', 500)
return _err(f'操作失败: {e!s}', 500)
@app.route('/admin/site/user/cookie', methods=['POST'])
@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}')
return _ok(message='SMTP 配置已保存')
except Exception as e:
return _err(f'保存失败: {str(e)}', 500)
return _err(f'保存失败: {e!s}', 500)
@app.route('/admin/site/smtp/test', methods=['POST'])
@auth.admin_required
@@ -762,7 +759,7 @@ def register_auth_routes(app) -> None:
return _ok(message='测试邮件已发送,请查收')
except Exception as 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'])
@auth.admin_required
@@ -815,7 +812,7 @@ def register_auth_routes(app) -> None:
return _ok({'codes': codes, 'credits': credits, 'count': len(codes)},
f'已生成 {len(codes)} 张卡密')
except Exception as e:
return _err(f'生成失败: {str(e)}', 500)
return _err(f'生成失败: {e!s}', 500)
@app.route('/admin/site/cards/list', methods=['POST'])
@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 ''))
return _ok(message='公告已保存')
except Exception as e:
return _err(f'保存失败: {str(e)}', 500)
return _err(f'保存失败: {e!s}', 500)
# -------------------- 播放 API 密钥 --------------------
@@ -917,7 +914,7 @@ def register_auth_routes(app) -> None:
auth.add_log('success', _actor(), auth._client_ip(), '数据库备份', name)
return _ok({'name': name}, f'备份完成:{name}')
except Exception as e:
return _err(f'备份失败: {str(e)}', 500)
return _err(f'备份失败: {e!s}', 500)
@app.route('/admin/site/backup/list', methods=['POST'])
@auth.admin_required
@@ -979,7 +976,7 @@ def register_auth_routes(app) -> None:
dest.unlink()
except Exception:
pass
return _err(f'上传失败: {str(e)}', 500)
return _err(f'上传失败: {e!s}', 500)
@app.route('/admin/site/backup/restore', methods=['POST'])
@auth.admin_required
@@ -992,10 +989,10 @@ def register_auth_routes(app) -> None:
except ValueError as e:
# 第 95 条(P3-1):客户端可纠正错误(备份不存在/校验失败)返回 400,
# 与 delete 的 404 口径对齐——原实现一律 500
return _err(f'恢复失败: {str(e)}', 400)
return _err(f'恢复失败: {e!s}', 400)
except Exception as 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'])
@auth.admin_required
@@ -1006,7 +1003,7 @@ def register_auth_routes(app) -> None:
return _ok(message='备份已删除')
return _err('备份文件不存在', 404)
except Exception as e:
return _err(f'删除失败: {str(e)}', 500)
return _err(f'删除失败: {e!s}', 500)
@app.route('/admin/site/backup/auto', methods=['POST'])
@auth.admin_required
@@ -1023,10 +1020,10 @@ def register_auth_routes(app) -> None:
if hours < 0:
return _err('间隔不能为负')
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 '定时备份已关闭')
except Exception as e:
return _err(f'设置失败: {str(e)}', 500)
return _err(f'设置失败: {e!s}', 500)
# ============ 辅助 ============
+33 -32
View File
@@ -10,14 +10,14 @@
数据存储:SQLite(data/site.db),会话:Flask 签名 Cookie(secret_key 持久化)
"""
import base64
import hashlib
import shutil
import json
import math
import base64
import os
import re
import secrets
import shutil
import smtplib
import sqlite3
import ssl
@@ -29,9 +29,9 @@ from email.mime.text import MIMEText
from email.utils import formataddr
from functools import wraps
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')
@@ -59,7 +59,7 @@ def get_conn() -> sqlite3.Connection:
return conn
def _q(sql: str, params: Tuple = ()) -> List[sqlite3.Row]:
def _q(sql: str, params: tuple = ()) -> list[sqlite3.Row]:
with _DB_LOCK:
conn = get_conn()
try:
@@ -68,7 +68,7 @@ def _q(sql: str, params: Tuple = ()) -> List[sqlite3.Row]:
conn.close()
def _x(sql: str, params: Tuple = ()) -> int:
def _x(sql: str, params: tuple = ()) -> int:
"""执行写操作,返回 lastrowid / rowcount"""
with _DB_LOCK:
conn = get_conn()
@@ -209,7 +209,6 @@ def init_db() -> None:
row = conn.execute("SELECT id FROM users WHERE role = 'admin' LIMIT 1").fetchone()
if not row:
initial_pass = secrets.token_urlsafe(12)
salt = secrets.token_hex(8)
conn.execute(
'INSERT INTO users (username, password_hash, salt, role, duration_seconds, created_at) '
'VALUES (?, ?, ?, ?, -1, ?)',
@@ -270,7 +269,7 @@ def _hash_new(password: str, salt: str = '', iterations: int = _PBKDF2_ITERS) ->
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)"""
if stored.startswith('pbkdf2$'):
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,
role: str = 'user', device_id: str = '', ip: str = '', ua: str = '',
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 这类"假不限"
try:
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 注册数检查挪进
# _DB_LOCK 事务内与 INSERT 原子完成,堵住"锁外检查+并发插入"的 TOCTOU 刷号;
# 管理建号与不限制场景传 0 关闭。超限抛 ValueError 由调用方转 400/429。
salt = secrets.token_hex(8)
now = int(time.time())
with _DB_LOCK:
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)
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,))
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:注册/建号邮箱查重的友好提示;库侧另有唯一索引兜底并发)"""
email = _normalize_email(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)
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,))
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,路由的"账号已被禁用"分支永不可达)"""
@@ -388,7 +386,7 @@ def verify_login(username: str, password: str) -> Optional[sqlite3.Row]:
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')
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,)))
_PROVINCE_CACHE: Dict[str, str] = {}
_PROVINCE_CACHE: dict[str, str] = {}
def _is_private_ip(ip: str) -> bool:
@@ -484,8 +482,9 @@ def query_ip_province(ip: str) -> str:
return _PROVINCE_CACHE[ip]
province = ''
try:
import requests as _rq
import urllib.parse as _ul
import requests as _rq
r = _rq.get('http://ip-api.com/json/' + _ul.quote(ip),
params={'lang': 'zh-CN', 'fields': 'status,regionName'},
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))
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 = {
'id': r['id'],
'username': r['username'],
@@ -636,7 +635,7 @@ def logout_session() -> None:
pass
def current_user() -> Optional[sqlite3.Row]:
def current_user() -> sqlite3.Row | None:
"""当前登录用户(每请求只查一次库:结果缓存到 flask.g。
一次下载流程会调用本函数 5-6 次——原实现每次都新建 SQLite 连接,公网下是纯浪费。
缓存含 None:未登录同样只查一次。请求内登录态不会变化(登录/注册路由不走本函数)。)"""
@@ -648,7 +647,7 @@ def current_user() -> Optional[sqlite3.Row]:
return user
def _load_current_user() -> Optional[sqlite3.Row]:
def _load_current_user() -> sqlite3.Row | None:
uid = session.get(SESSION_KEY)
if not uid:
return None
@@ -848,7 +847,7 @@ def ledger_open(task_id: str, user_id: int, reserved: int) -> None:
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:预扣与开账同一事务(原两步之间存在崩溃丢账窗口)。
足额:扣减+开账并返回剩余;不限/管理员:开账(reserved=0)返回 -1;不足:返回 None(不开账)。"""
count = max(1, int(count))
@@ -941,7 +940,7 @@ def ledger_supplement(task_id: str, extra: int) -> bool:
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),))
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)
@@ -1174,8 +1173,9 @@ _SMTP_ENC_PREFIX = 'enc1:' # 第 95 条:信封加密标记前缀(存量明
def _smtp_password_encrypt(plaintext: str) -> str:
"""用 data/secret.key 派生 Fernet 密钥加密 SMTP 授权码(第 95 条 P2-7:
原明文落库+随备份导出=泄露半径扩大;注释编号笔误已按第 97 条纠正)。"""
from cryptography.fernet import Fernet
import hashlib as _hl
from cryptography.fernet import Fernet
key = _hl.sha256(get_secret_key().encode('utf-8')).digest()
f = Fernet(base64.urlsafe_b64encode(key))
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:
if not ciphertext.startswith(_SMTP_ENC_PREFIX):
return ciphertext # 存量明文:透明兼容,下次保存时升级为密文
from cryptography.fernet import Fernet
import hashlib as _hl
from cryptography.fernet import Fernet
key = _hl.sha256(get_secret_key().encode('utf-8')).digest()
f = Fernet(base64.urlsafe_b64encode(key))
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')
cfg = dict(rows[0]) if rows else {'host': '', 'port': 587, 'sender': '', 'password': ''}
# 第 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 = '',
prefix: str = '', note: str = '', valid_days: int = 0) -> List[str]:
prefix: str = '', note: str = '', valid_days: int = 0) -> list[str]:
"""生成次数卡密(credits = 每张卡可下载次数)
prefix: 自定义卡密前缀(默认 VIP-)
note: 备注信息
@@ -1425,7 +1426,7 @@ def generate_cards(count: int, credits: int, created_by: str = '',
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,管理路由透传)"""
limit = max(1, min(int(limit), 500))
offset = max(0, int(offset))
@@ -1485,7 +1486,7 @@ def delete_card(code: str) -> None:
conn.close()
def redeem_card(code: str, username: str) -> Tuple[int, int]:
def redeem_card(code: str, username: str) -> tuple[int, int]:
"""兑换卡密,返回 (卡密次数, 新剩余次数)(F25:标记已用与加余额同事务,失败整体回滚不吞卡)"""
code = (code or '').strip().upper()
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')
return dict(rows[0]) if rows else {'title': '', 'content': '', 'popup_mode': 'off'}
@@ -1606,7 +1607,7 @@ def create_backup() -> str:
conn.close()
def list_backups() -> List[Dict[str, Any]]:
def list_backups() -> list[dict[str, Any]]:
"""备份列表(F44:按文件修改时间倒序——按名字排序会让 upload_ 永远排前,误删新自动备份)"""
if not BACKUP_DIR.exists():
return []
@@ -1622,7 +1623,7 @@ def list_backups() -> List[Dict[str, Any]]:
return out
def backup_path(name: str) -> Optional[Path]:
def backup_path(name: str) -> Path | None:
name = Path(name).name # 防目录穿越
p = BACKUP_DIR / name
return p if p.exists() and p.suffix == '.db' else None
+169 -149
View File
@@ -9,16 +9,13 @@
- 健康检查
"""
import base64
import json
import collections
import hashlib
import hmac
import json
import logging
import os
import re
import requests
import secrets
import shutil
import sys
import threading
@@ -26,27 +23,45 @@ import time
import traceback
import uuid
import zipfile
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, Any, Optional, Tuple, List
from urllib.parse import quote, urlsplit, parse_qs
from flask import Flask, request, send_file, render_template, Response, jsonify, stream_with_context, make_response, g
from typing import Any
from urllib.parse import parse_qs, quote, urlsplit
import requests
from flask import Flask, Response, g, make_response, render_template, request, send_file, stream_with_context
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 podcast_api
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:
# 坑 11:导入失败发生在 _safe_console_print 定义之前,用纯 ASCII 保证任何编码下可打印
print("ImportError: " + str(e))
@@ -58,35 +73,46 @@ except ImportError as e:
# 常量/配置、运行时状态、纯工具、音频签名已迁出至 core 包,以**同一对象/同一实现**
# 供 Flask 与迁移目标 FastAPI 共用。此处以原名称再导出(别名),既有 250+ 调用点零改动。
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,
PAUSE_AFTER_SECONDS, CANCEL_AFTER_SECONDS, HEARTBEAT_INTERVAL,
MAX_ACTIVE_TASKS_PER_USER, MAX_TOTAL_TASKS, MAX_TERMINAL_TASKS,
BATCH_ZIP_CONCURRENCY, VALID_QUALITY_LEVELS,
VALID_QUALITY_LEVELS,
Settings,
)
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 (
RATE_BUCKETS as _RATE_BUCKETS, RATE_LOCK as _RATE_LOCK,
BATCH_DURATIONS, BATCH_DURATIONS_LOCK,
DOWNLOAD_TASKS, DOWNLOAD_TASKS_LOCK,
DEVICE_LAST_SEEN, DEVICE_LAST_SEEN_LOCK,
REQUEST_STATS, STATS_LOCK as _STATS_LOCK, REQ_LOG,
GLOBAL_MUSIC_U_CACHE as _GLOBAL_MUSIC_U_CACHE,
VIP_CACHE as _VIP_CACHE, VIP_USER_CACHE as _VIP_USER_CACHE,
SERVER_NICK_CACHE as _SERVER_NICK_CACHE, SERVER_NICK_LOCK as _SERVER_NICK_LOCK,
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,
BATCH_DURATIONS,
BATCH_DURATIONS_LOCK,
DEVICE_LAST_SEEN,
DEVICE_LAST_SEEN_LOCK,
DOWNLOAD_TASKS,
DOWNLOAD_TASKS_LOCK,
REQ_LOG,
REQUEST_STATS,
)
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 = Settings
@@ -94,9 +120,9 @@ APIConfig = Settings
class APIResponse:
"""API响应工具类"""
@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 = {
'status': status_code,
@@ -106,9 +132,9 @@ class APIResponse:
if data is not None:
response['data'] = data
return response, status_code
@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 = {
'status': status_code,
@@ -122,7 +148,7 @@ class APIResponse:
class MusicAPIService:
"""音乐API服务类"""
def __init__(self, config: APIConfig):
self.config = config
self.logger = self._setup_logger()
@@ -136,12 +162,12 @@ class MusicAPIService:
self.downloads_path.mkdir(exist_ok=True)
self.logger.info(f"音乐API服务初始化完成,下载目录: {self.downloads_path.absolute()}")
def _setup_logger(self) -> logging.Logger:
"""设置日志记录器"""
logger = logging.getLogger('music_api')
logger.setLevel(getattr(logging, self.config.log_level.upper()))
if not logger.handlers:
# 控制台处理器
console_handler = logging.StreamHandler()
@@ -150,7 +176,7 @@ class MusicAPIService:
)
console_handler.setFormatter(console_formatter)
logger.addHandler(console_handler)
# 文件处理器(轮转:单文件 5MB,保留 3 份,防止长期运行无限膨胀)
try:
from logging.handlers import RotatingFileHandler
@@ -163,10 +189,10 @@ class MusicAPIService:
logger.addHandler(file_handler)
except Exception as e:
logger.warning(f"无法创建日志文件: {e}")
return logger
def _get_cookies(self) -> Dict[str, str]:
def _get_cookies(self) -> dict[str, str]:
"""直接从 cookie.txt 读取并解析为 dict"""
try:
cookies = load_cookies()
@@ -176,7 +202,7 @@ class MusicAPIService:
except Exception as e:
self.logger.error(f"读取 cookie.txt 异常: {e}")
return {}
def _extract_music_id(self, id_or_url: str) -> str:
"""提取音乐ID(F7/F74:正规 URL 解析替代子串/删除非数字启发式)
- 短链仅当整串就是一个 163cn.tv 短链 URL 时才请求展开(防 SSRF)
@@ -207,7 +233,7 @@ class MusicAPIService:
except Exception as e:
self.logger.error(f"提取音乐ID失败: {e}")
return str(id_or_url or '').strip()
def _format_file_size(self, size_bytes) -> str:
"""格式化文件大小(上游 size 可能为 null/垃圾值:容错返回"大小未知"而非 500)"""
try:
@@ -225,12 +251,12 @@ class MusicAPIService:
unit_index += 1
return f"{size:.2f}{units[unit_index]}"
def _get_quality_display_name(self, quality: str) -> str:
"""获取音质显示名称"""
quality_names = {
'standard': "标准音质",
'exhigh': "极高音质",
'exhigh': "极高音质",
'lossless': "无损音质",
'hires': "Hi-Res音质",
'sky': "沉浸环绕声",
@@ -239,15 +265,15 @@ class MusicAPIService:
'dolby': "杜比全景声"
}
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():
if not param_value:
return APIResponse.error(f"参数 '{param_name}' 不能为空", 400)
return None
def _safe_get_request_data(self) -> Dict[str, Any]:
def _safe_get_request_data(self) -> dict[str, Any]:
"""安全获取请求数据"""
try:
if request.method == 'GET':
@@ -541,7 +567,6 @@ def api_task_cancel():
try:
data = api_service._safe_get_request_data()
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:
return APIResponse.error("缺少 task_id 参数")
with DOWNLOAD_TASKS_LOCK:
@@ -685,7 +710,7 @@ BATCH_ZIP_SEM = CountingSemaphore(BATCH_ZIP_CONCURRENCY)
# 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。
- F05:任务必须绑定站点用户(提交接口都有 login_required,这里再取一次会话)
@@ -759,7 +784,7 @@ def _register_download_task(task: Dict[str, Any], reserved: int, task_id_hint: s
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 记录)。
A2:退款只走账本(本处 settle 后装饰器看到账已关,不会重复退)。
R5-14:线程句柄存入任务字典——监控循环对 4h 超时任务若发现 worker 线程已死
@@ -784,7 +809,7 @@ def _start_task_thread(task_id: str, target, args=()) -> Optional[str]:
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 仅作辅助)"""
user = auth_system.current_user()
if not user:
@@ -823,7 +848,7 @@ def _mark_task_terminal(task_id: str, status: str, msg: str = '') -> None:
pass
def _task_files(task: Dict[str, Any]) -> List[str]:
def _task_files(task: dict[str, Any]) -> list[str]:
"""任务当前关联的可清理文件路径(交付 ZIP/交付副本;缓存文件不在此列)"""
result = task.get('result') or {}
files = []
@@ -875,9 +900,9 @@ def _task_monitor_loop() -> None:
# ---- 终态任务清理(F38/F106:按完成时刻;删除失败保留记录下轮重试,最多 8 次)----
# 第 91 条(P2-1):锁内只收集过期清单,文件删除移到锁外——GB 级任务目录
# 的删除曾阻塞锁数十秒,期间全站进度上报/提交/取消/领取全部停摆
finished_done: List[str] = []
finished_retry: List[str] = []
expired: List[tuple] = []
finished_done: list[str] = []
finished_retry: list[str] = []
expired: list[tuple] = []
with DOWNLOAD_TASKS_LOCK:
for tid, task in DOWNLOAD_TASKS.items():
if task.get('status') not in ('done', 'error', 'canceled'):
@@ -890,7 +915,7 @@ def _task_monitor_loop() -> None:
continue
expired.append((tid, _task_files(task),
api_service.downloads_path / 'tasks' / tid))
cleanup_results: List[tuple] = []
cleanup_results: list[tuple] = []
for tid, files, task_dir in expired:
all_removed = True
for f in files:
@@ -929,7 +954,7 @@ def _task_monitor_loop() -> None:
if finished_retry:
api_service.logger.info(f"监控线程保留清理失败任务待重试: {finished_retry}")
# ---- 长时间未结束的任务:置取消(含 cancel Event,唤醒可能的阻塞等待)----
timeout_settle: List[str] = []
timeout_settle: list[str] = []
with DOWNLOAD_TASKS_LOCK:
for tid, task in DOWNLOAD_TASKS.items():
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]
def _parse_cookie_str(raw) -> Dict[str, str]:
def _parse_cookie_str(raw) -> dict[str, str]:
"""将 'k=v;k2=v2' 格式的 Cookie 字符串解析为字典"""
cookies: Dict[str, str] = {}
cookies: dict[str, str] = {}
if not raw:
return cookies
for part in str(raw).split(';'):
@@ -1154,7 +1179,7 @@ def _global_music_u_cache_reset() -> None:
_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(不落服务器,杜绝跨设备访问他人账号)。
第 92 条(owner 拍板):全局账号(cookie.txt)在发评/收藏/下载等个人身份操作上
零权限、零功能、零入口——浏览器提交的 MUSIC_U 若与全局账号一致(全局 Cookie
@@ -1177,7 +1202,7 @@ def _resolve_user_cookies(data: Dict[str, Any]) -> Dict[str, str]:
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', '未知歌曲')
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 同名共享)
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)"""
_user = auth_system.current_user()
if _user is not None and _user['role'] == 'admin':
@@ -1219,7 +1244,7 @@ def _vip_name(vip_type: int) -> str:
}.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 精确),并始终用音质探测接口实测可解析列表。
第 100 条:探测单飞锁(有界等待 0.5s)——上游黑洞时并发请求不再各自串行
@@ -1488,7 +1513,7 @@ def health_check():
}
return APIResponse.success(health_info, "API服务运行正常")
except Exception as e:
api_service.logger.error(f"健康检查失败: {e}")
return APIResponse.error("服务暂时不可用,请稍后再试", 500)
@@ -1506,23 +1531,23 @@ def get_song_info():
url = data.get('url')
level = data.get('level', 'lossless')
info_type = data.get('type', 'url')
# 参数验证
if not song_ids and not url:
return APIResponse.error("必须提供 'ids'、'id' 或 'url' 参数")
# 提取音乐ID
music_id = api_service._extract_music_id(song_ids or url)
# P2-2:ID 白名单(与 /album、/artist_songs、/comments 同口径——
# 垃圾输入不再透传上游并把上游报错原文回显给公网)
if not str(music_id).strip().isdigit():
return APIResponse.error("无效的歌曲ID,请输入数字ID或分享链接")
# 验证音质参数
valid_levels = ['standard', 'exhigh', 'lossless', 'hires', 'sky', 'jyeffect', 'jymaster', 'dolby']
if level not in valid_levels:
return APIResponse.error(f"无效的音质参数,支持: {', '.join(valid_levels)}")
# 验证类型参数
valid_types = ['url', 'name', 'lyric', 'json']
if info_type not in valid_types:
@@ -1536,7 +1561,7 @@ def get_song_info():
return APIResponse.error("请先扫码登录个人账号后再使用个人解析", 401)
else:
cookies = api_service._get_cookies()
# 根据类型获取不同信息
if info_type == 'url':
result = url_v1(music_id, level, cookies)
@@ -1559,15 +1584,15 @@ def get_song_info():
return APIResponse.success(response_data, "获取歌曲URL成功")
else:
return APIResponse.error("获取音乐URL失败,可能是版权限制或音质不支持", 404)
elif info_type == 'name':
result = name_v1(music_id)
return APIResponse.success(result, "获取歌曲信息成功")
elif info_type == 'lyric':
result = lyric_v1(music_id, cookies)
return APIResponse.success(result, "获取歌词成功")
elif info_type == 'json':
# 获取完整的歌曲信息(用于前端解析)
song_info = name_v1(music_id)
@@ -1576,10 +1601,10 @@ def get_song_info():
lyric_info = lyric_v1(music_id, cookies)
except Exception:
lyric_info = None # 歌词接口抖动不阻断解析
if not song_info or 'songs' not in song_info or not song_info['songs']:
return APIResponse.error("未找到歌曲信息", 404)
song_data = song_info['songs'][0]
# S1/F94:可选字段为 null(而非缺失)时同样容错,不让有效歌曲解析失败
@@ -1598,7 +1623,7 @@ def get_song_info():
'tlyric': ((lyric_info or {}).get('tlyric') or {}).get('lyric') or '',
'yrc': ((lyric_info or {}).get('yrc') or {}).get('lyric') or ''
}
# 添加URL和大小信息
if url_info and url_info.get('data') and len(url_info['data']) > 0:
url_data = url_info['data'][0]
@@ -1612,7 +1637,7 @@ def get_song_info():
'url': '',
'size': '获取失败'
})
# 记录用户解析操作(IP + 歌曲名)
try:
_record_log('解析', auth_system._client_ip(), response_data.get('name') or str(music_id))
@@ -1620,7 +1645,7 @@ def get_song_info():
pass
return APIResponse.success(response_data, "获取歌曲信息成功")
except APIException as e:
api_service.logger.error(f"API调用失败: {e}")
return APIResponse.error("服务暂时不可用,请稍后再试", 500)
@@ -1641,20 +1666,20 @@ def search_music_api():
limit = max(1, _safe_int(data.get('limit'), 30))
offset = max(0, _safe_int(data.get('offset'), 0))
search_type = data.get('type', '1') # 1-歌曲, 10-专辑, 100-歌手, 1000-歌单
# 参数验证
validation_error = api_service._validate_request_params({'keyword': keyword})
if validation_error:
return validation_error
# 限制搜索数量
if limit > 100:
limit = 100
cookies = api_service._get_cookies()
# F45:offset 透传给上游,翻页不再永远从第一页开始
result = search_music(keyword, cookies, limit, int(search_type), offset=offset)
# 向后兼容:歌曲搜索结果补充 artist_string 字段;歌单结果遮蔽服务器账号昵称
if result:
for song in result:
@@ -1665,11 +1690,11 @@ def search_music_api():
song['creator'] = _mask_server_nickname(song['creator'])
if song.get('name'):
song['name'] = _mask_server_nickname(song['name'])
return APIResponse.success(result, "搜索完成")
except ValueError as e:
return APIResponse.error(f"参数格式错误: {str(e)}")
return APIResponse.error(f"参数格式错误: {e!s}")
except Exception as e:
api_service.logger.error(f"搜索音乐异常: {e}\n{traceback.format_exc()}")
return APIResponse.error("服务暂时不可用,请稍后再试", 500)
@@ -1684,7 +1709,7 @@ def get_playlist():
# 获取请求参数
data = api_service._safe_get_request_data()
playlist_id = data.get('id')
# 参数验证
validation_error = api_service._validate_request_params({'playlist_id': playlist_id})
if validation_error:
@@ -1709,9 +1734,9 @@ def get_playlist():
'status': 'success',
'playlist': result
}
return APIResponse.success(response_data, "获取歌单详情成功")
except Exception as e:
api_service.logger.error(f"获取歌单异常: {e}\n{traceback.format_exc()}")
return APIResponse.error("服务暂时不可用,请稍后再试", 500)
@@ -1740,15 +1765,15 @@ def get_album():
cookies = api_service._get_cookies()
result = album_detail(album_id, cookies)
# 适配前端期望的响应格式
response_data = {
'status': 200,
'album': result
}
return APIResponse.success(response_data, "获取专辑详情成功")
except Exception as e:
api_service.logger.error(f"获取专辑异常: {e}\n{traceback.format_exc()}")
return APIResponse.error("服务暂时不可用,请稍后再试", 500)
@@ -2219,8 +2244,8 @@ def _podcast_stream_download(url: str, target: Path, total_size: int,
return done
def _podcast_resolve_audio(program: Dict[str, Any], quality: str,
cookies: Dict[str, str]) -> Dict[str, Any]:
def _podcast_resolve_audio(program: dict[str, Any], quality: str,
cookies: dict[str, str]) -> dict[str, Any]:
"""解析单集音频直链,失败抛 DownloadException"""
song_id = program.get('main_song_id')
if not song_id:
@@ -2255,7 +2280,7 @@ def get_song_comments_api():
result = song_comments(song_id, limit, offset)
return APIResponse.success(result, "获取评论成功")
except ValueError as e:
return APIResponse.error(f"参数格式错误: {str(e)}")
return APIResponse.error(f"参数格式错误: {e!s}")
except APIException as e:
api_service.logger.error(f"获取评论失败: {e}")
return APIResponse.error("服务暂时不可用,请稍后再试", 500)
@@ -2687,8 +2712,7 @@ def user_comment():
# ============ 全局账号歌单(管理员 cookie.txt,无需游客扫码) ============
VALID_QUALITY_LEVELS = ['standard', 'exhigh', 'lossless', 'hires', 'sky', 'jyeffect', 'jymaster', 'dolby']
# VALID_QUALITY_LEVELS 已迁至 core.config(顶部 import 同名共享,此处不再重复定义)
@app.route('/global/playlists', methods=['GET', 'POST'])
@@ -2942,7 +2966,7 @@ def download_music_api():
music_id = data.get('id')
quality = data.get('quality', 'lossless')
return_format = data.get('format', 'file') # file 或 json
# 参数验证
validation_error = api_service._validate_request_params({'music_id': music_id})
if validation_error:
@@ -2951,16 +2975,16 @@ def download_music_api():
music_id = str(music_id).strip()
if not music_id.isdigit():
return APIResponse.error("无效的歌曲 ID")
# 验证音质参数
valid_qualities = ['standard', 'exhigh', 'lossless', 'hires', 'sky', 'jyeffect', 'jymaster', 'dolby']
if quality not in valid_qualities:
return APIResponse.error(f"无效的音质参数,支持: {', '.join(valid_qualities)}")
# 验证返回格式
if return_format not in ['file', 'json']:
return APIResponse.error("返回格式只支持 'file' 或 'json'")
music_id = api_service._extract_music_id(music_id)
# 解析账号选择:user=游客个人账号,默认 admin=全局管理员账号
@@ -3007,7 +3031,6 @@ def download_music_api():
# R5-P3:对外泛化(error_message 可能含上游细节),原文留日志
api_service.logger.error(f"下载失败: {download_result.error_message}")
return APIResponse.error("下载失败,请稍后重试或更换音质", 500)
file_path = Path(download_result.file_path)
api_service.logger.info(f"下载完成: {filename}")
except DownloadException as e:
api_service.logger.error(f"下载异常: {e}")
@@ -3265,10 +3288,9 @@ def _batch_zip_worker(task_id: str) -> None:
import tempfile as _tempfile
from concurrent.futures import ThreadPoolExecutor, as_completed
zip_tmp_path: Optional[str] = None
zipped_files: List[str] = []
zip_tmp_path: str | None = None
ok_count = 0
failed: List[str] = []
failed: list[str] = []
used_arcnames = set() # ZIP 内已占用文件名(同名歌曲去重,防止包内互相覆盖)
info_pool = None
info_futures = {}
@@ -3562,7 +3584,7 @@ def _batch_zip_worker(task_id: str) -> None:
refunded = _settle_batch_billing(task_id, delivered, 'error')
if delivered > 0 and zip_tmp_path:
_mark_task_terminal(task_id, 'error',
f"部分歌曲已打包完成,可领取" +
"部分歌曲已打包完成,可领取" +
(f",退回 {refunded} 次" if refunded else ''))
with DOWNLOAD_TASKS_LOCK:
t = DOWNLOAD_TASKS.get(task_id)
@@ -3603,9 +3625,9 @@ def _podcast_download_worker(task_id: str) -> None:
"""
import tempfile as _tempfile
file_path_str: Optional[str] = None
zip_tmp_path: Optional[str] = None
task_dir: Optional[Path] = None
file_path_str: str | None = None
zip_tmp_path: str | None = None
task_dir: Path | None = None
try:
with DOWNLOAD_TASKS_LOCK:
task = DOWNLOAD_TASKS[task_id]
@@ -3643,7 +3665,7 @@ def _podcast_download_worker(task_id: str) -> None:
raise DownloadCanceled("任务已取消")
file_path_str = str(target)
result: Dict[str, Any]
result: dict[str, Any]
if need_zip:
fd, zip_tmp_path = _tempfile.mkstemp(prefix='podcast_zip_', suffix='.zip',
dir=str(task_dir))
@@ -3737,7 +3759,7 @@ def _podcast_download_worker(task_id: str) -> None:
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:
if not p:
@@ -3759,9 +3781,9 @@ def _podcast_zip_worker(task_id: str) -> None:
"""整台播客打包:逐集 下载→入包即删,支持取消与断线暂停(复用批量任务机制)"""
import tempfile as _tempfile
zip_tmp_path: Optional[str] = None
zip_tmp_path: str | None = None
ok_count = 0
failed: List[str] = []
failed: list[str] = []
# 第 97 条(P1-1):zip_valid 必须在函数头初始化——空电台/无单集/补扣失败等
# 正常业务错误在 try 内早期 raise,异常处理器引用未绑定名会二次崩溃 worker 线程
zip_valid = False
@@ -4064,7 +4086,6 @@ def download_batch_zip_result():
try:
data = api_service._safe_get_request_data()
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:
return APIResponse.error("缺少 task_id 参数")
with DOWNLOAD_TASKS_LOCK:
@@ -4127,9 +4148,9 @@ def _single_download_worker(task_id: str) -> None:
"""后台单曲下载:下载音频(可选 ZIP 打包),支持取消;完成后由 /download/result 交付,35 分钟保留期清理"""
import tempfile as _tempfile
file_path_str: Optional[str] = None
zip_tmp_path: Optional[str] = None
task_dir_path: Optional[str] = None
file_path_str: str | None = None
zip_tmp_path: str | None = None
task_dir_path: str | None = None
try:
with DOWNLOAD_TASKS_LOCK:
task = DOWNLOAD_TASKS[task_id]
@@ -4308,7 +4329,6 @@ def download_single_result():
try:
data = api_service._safe_get_request_data()
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:
return APIResponse.error("缺少 task_id 参数")
with DOWNLOAD_TASKS_LOCK:
@@ -4384,9 +4404,9 @@ def api_info():
'max_body_bytes': '2MB(备份上传 200MB)'
}
}
return APIResponse.success(info, "API信息获取成功")
except Exception as e:
api_service.logger.error(f"获取API信息异常: {e}")
return APIResponse.error("服务暂时不可用,请稍后再试", 500)
@@ -4437,21 +4457,21 @@ def start_api_server():
_safe_console_print(f"📁 下载目录: {api_service.downloads_path.absolute()}")
_safe_console_print(f"📋 日志级别: {config.log_level}")
_safe_console_print("\n📚 API端点:")
_safe_console_print(f" ├─ GET /health - 健康检查")
_safe_console_print(f" ├─ POST /search - 搜索音乐(支持歌曲/歌单/专辑/歌手)")
_safe_console_print(f" ├─ POST /song - 获取歌曲信息")
_safe_console_print(f" ├─ POST /playlist - 获取歌单详情")
_safe_console_print(f" ├─ POST /album - 获取专辑详情")
_safe_console_print(f" ├─ POST /toplist - 获取官方榜单列表")
_safe_console_print(f" ├─ POST /artist_songs - 获取歌手信息与热门歌曲")
_safe_console_print(f" ├─ POST /comments - 获取歌曲评论")
_safe_console_print(f" ├─ POST /download - 下载音乐")
_safe_console_print(f" ├─ GET/POST /global/playlists - 全局账号歌单列表")
_safe_console_print(f" ├─ GET/POST /global/playlist - 全局账号歌单详情")
_safe_console_print(f" ├─ GET/POST /api/playlist/play - 公开歌单播放API")
_safe_console_print(f" └─ GET /api/info - API信息")
_safe_console_print(" ├─ GET /health - 健康检查")
_safe_console_print(" ├─ POST /search - 搜索音乐(支持歌曲/歌单/专辑/歌手)")
_safe_console_print(" ├─ POST /song - 获取歌曲信息")
_safe_console_print(" ├─ POST /playlist - 获取歌单详情")
_safe_console_print(" ├─ POST /album - 获取专辑详情")
_safe_console_print(" ├─ POST /toplist - 获取官方榜单列表")
_safe_console_print(" ├─ POST /artist_songs - 获取歌手信息与热门歌曲")
_safe_console_print(" ├─ POST /comments - 获取歌曲评论")
_safe_console_print(" ├─ POST /download - 下载音乐")
_safe_console_print(" ├─ GET/POST /global/playlists - 全局账号歌单列表")
_safe_console_print(" ├─ GET/POST /global/playlist - 全局账号歌单详情")
_safe_console_print(" ├─ GET/POST /api/playlist/play - 公开歌单播放API")
_safe_console_print(" └─ GET /api/info - API信息")
_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(f"⏰ 启动时间: {time.strftime('%Y-%m-%d %H:%M:%S')}")
_safe_console_print("🌟 服务已就绪,等待请求...\n")
+199 -196
View File
File diff suppressed because it is too large. Load diff
+40 -44
View File
@@ -15,7 +15,6 @@
from __future__ import annotations
import contextlib
import logging
import os
import re
@@ -27,13 +26,12 @@ import time
from collections import deque
from dataclasses import dataclass
from enum import Enum
from io import BytesIO
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
import requests
from requests.adapters import HTTPAdapter
from core.async_bridge import run_sync
from music_api import APIException, NeteaseAPI, load_cookies
# P2-5:worker 线程的输出一律走 logger(坑 11——NSSM GBK 重定向下裸 print
@@ -80,7 +78,7 @@ def _sniff_audio_header(path) -> bool:
def get_http_session() -> requests.Session:
"""获取当前线程的共享 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:
session = requests.Session()
adapter = HTTPAdapter(pool_connections=8, pool_maxsize=32, max_retries=0)
@@ -96,7 +94,7 @@ def get_http_session() -> requests.Session:
# F78:锁表带引用计数与回收——无人引用时从表中移除,防止长期运行无界增长
# A3:计数在注册表守卫锁内完成("取出引用"即计数 +1),消除"取引用后、acquire 前
# 被另一个线程回收、第三个线程拿到新锁对象"的互斥破坏窗口
_FILE_LOCKS: Dict[str, "RefCountedFileLock"] = {}
_FILE_LOCKS: dict[str, RefCountedFileLock] = {}
_FILE_LOCKS_GUARD = threading.Lock()
@@ -125,7 +123,7 @@ class _FileLockHandle:
self._lock = None
self._key = ''
def __enter__(self) -> "RefCountedFileLock":
def __enter__(self) -> RefCountedFileLock:
key = str(self._path)
with _FILE_LOCKS_GUARD:
lock = _FILE_LOCKS.get(key)
@@ -159,18 +157,18 @@ class _FileLockHandle:
self._lock = None
def get_file_lock(path: Path) -> "_FileLockHandle":
def get_file_lock(path: Path) -> _FileLockHandle:
"""兼容入口:返回可用 with 语句的锁句柄(A3:引用计数在守卫锁内登记,无回收竞态)"""
return _FileLockHandle(path)
# 封面字节缓存:同一专辑封面只从网络拉取一次(ffmpeg 写元数据 与 ZIP 打包 共用)
_COVER_CACHE: Dict[str, bytes] = {}
_COVER_CACHE: dict[str, bytes] = {}
_COVER_CACHE_ORDER: deque = deque(maxlen=128)
_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,拒绝超大盘点阵/伪装文件)"""
if not pic_url:
return None
@@ -243,17 +241,17 @@ class MusicInfo:
quality: str
lyric: str = ""
tlyric: str = ""
publish_year: Optional[int] = None
publish_year: int | None = None
@dataclass
class DownloadResult:
"""下载结果数据类"""
success: bool
file_path: Optional[str] = None
file_path: str | None = None
file_size: int = 0
error_message: str = ""
music_info: Optional[MusicInfo] = None
music_info: MusicInfo | None = None
class DownloadException(Exception):
@@ -330,7 +328,7 @@ class CountingSemaphore:
with self._lock:
self.active = max(0, self.active - 1)
def __enter__(self) -> "CountingSemaphore":
def __enter__(self) -> CountingSemaphore:
self.acquire()
return self
@@ -378,7 +376,7 @@ class MusicDownloader:
self,
download_dir: str = "downloads",
max_concurrent: int = 3,
ffmpeg_path: Optional[str] = None,
ffmpeg_path: str | None = None,
global_max_concurrent: int = 10,
):
"""
@@ -461,7 +459,7 @@ class MusicDownloader:
return ".mp3"
def _resolve_ffmpeg(self) -> Optional[str]:
def _resolve_ffmpeg(self) -> str | None:
"""懒加载解析 ffmpeg 路径
每次写入前调用一次:若启动时未检测到,但中途已安装,下一次就能用。
@@ -484,31 +482,29 @@ class MusicDownloader:
# ----------- 信息获取 -----------
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 个接口并行请求,加快单曲/批量启动)"""
try:
cookies = cookies if cookies is not None else load_cookies()
# 3 个接口并行请求,单曲下载/批量预取都能明显提速
# F99:URL/详情拿到后立即 shutdown(cancel_futures),
# 已知失败不再被无关的歌词请求拖着等完才报错
from concurrent.futures import ThreadPoolExecutor
_pool = ThreadPoolExecutor(max_workers=3)
url_f = _pool.submit(self.api.get_song_url, music_id, quality, cookies)
detail_f = _pool.submit(self.api.get_song_detail, music_id)
lyric_f = _pool.submit(self.api.get_lyric, music_id, cookies)
try:
url_result = url_f.result()
detail_result = detail_f.result()
try:
lyric_result = lyric_f.result()
except Exception:
lyric_result = None # 歌词接口抖动不阻断解析/下载
finally:
try:
_pool.shutdown(wait=False, cancel_futures=True)
except TypeError:
_pool.shutdown(wait=False)
# F99:URL/详情拿到后立即取消未完成的歌词请求,已知失败不再被拖着等完
# 异步化:改用 asyncio.gather 并发(api 方法已异步),失败语义与旧线程池一致
import asyncio
async def _gather_info():
url_t = self.api.get_song_url(music_id, quality, cookies)
detail_t = self.api.get_song_detail(music_id)
lyric_t = self.api.get_lyric(music_id, cookies)
return await asyncio.gather(url_t, detail_t, lyric_t, return_exceptions=True)
url_result, detail_result, lyric_result = run_sync(_gather_info())
if isinstance(url_result, BaseException):
raise url_result
if isinstance(detail_result, BaseException):
raise detail_result
if isinstance(lyric_result, BaseException):
lyric_result = None # 歌词接口抖动不阻断解析/下载
if not url_result.get("data") or not url_result["data"]:
raise DownloadException(f"无法获取音乐ID {music_id} 的播放链接")
@@ -602,11 +598,11 @@ class MusicDownloader:
music_id: int,
quality: str = "standard",
write_meta: bool = True,
meta_fields: Optional[List[str]] = None,
cookies: Optional[Dict[str, str]] = None,
meta_fields: list[str] | None = None,
cookies: dict[str, str] | None = None,
retries: int = 2,
cancel_event: Optional[threading.Event] = None,
music_info: Optional[MusicInfo] = None,
cancel_event: threading.Event | None = None,
music_info: MusicInfo | None = None,
progress_cb=None,
) -> DownloadResult:
"""同步下载音乐到本地
@@ -719,7 +715,7 @@ class MusicDownloader:
progress_cb(resume_from, music_info.file_size or 0)
except Exception:
pass # 进度回调绝不影响下载主流程
resp: Optional[requests.Response] = None
resp: requests.Response | None = None
try:
resp = get_http_session().get(
music_info.download_url,
@@ -878,7 +874,7 @@ class MusicDownloader:
self,
file_path: Path,
music_info: MusicInfo,
fields: Optional[List[str]] = None,
fields: list[str] | None = None,
) -> bool:
"""使用 ffmpeg 写入音频元数据
@@ -909,7 +905,7 @@ class MusicDownloader:
file_path.stem + ".tagging" + file_path.suffix
)
# 临时封面文件
cover_tmp: Optional[str] = None
cover_tmp: str | None = None
cover_ok = False # F114:创建成功与否与"路径是否需要清理"分离管理
try:
@@ -934,7 +930,7 @@ class MusicDownloader:
# 构造 ffmpeg 命令
# 顺序: global opts -> -i input1 -> -i input2 -> metadata -> mapping -> -f format -> output
output_format = EXT_TO_FFMPEG_FORMAT.get(file_ext, "")
cmd: List[str] = [
cmd: list[str] = [
ffmpeg_path, "-y", "-loglevel", "error",
"-i", str(file_path),
]
@@ -1030,7 +1026,7 @@ class MusicDownloader:
except OSError:
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 / 编码参数
F34:不写封面时用 -map 0 -c copy 保留全部既有流(含已有封面),
原实现 -map 0:a 会把 MP3/M4A 已有封面静默删掉。"""
+39 -32
View File
@@ -19,8 +19,7 @@
import json
import re
import time
from random import randrange
from typing import Dict, List, Optional, Any
from typing import Any
import requests
@@ -54,7 +53,7 @@ class PodcastConstants:
_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"""
headers = {
'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
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:
if not ts:
@@ -88,7 +95,7 @@ def _ts_to_date(ts: Optional[int]) -> str:
return ''
def _fmt_duration(ms: Optional[int]) -> str:
def _fmt_duration(ms: int | None) -> str:
"""毫秒 → mm:ss / hh:mm:ss"""
try:
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}"
def _fmt_count(n: Optional[int]) -> str:
def _fmt_count(n: int | None) -> str:
"""数量 → 万单位简写"""
try:
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 {}
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)信息"""
radio = raw.get('radio') 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 解析 ============
_SHORTLINK_CACHE: Dict[str, str] = {}
_SHORTLINK_CACHE: dict[str, str] = {}
def _resolve_short_link(url: str) -> str:
@@ -201,7 +208,7 @@ def _resolve_short_link(url: str) -> str:
return real
def extract_podcast_ref(raw: str) -> Dict[str, str]:
def extract_podcast_ref(raw: str) -> dict[str, str]:
"""从用户输入中提取播客引用(T4:正规 URL 解析,替代子串匹配与非数字剔除)
Returns:
@@ -210,7 +217,7 @@ def extract_podcast_ref(raw: str) -> Dict[str, str]:
- program: program?id= / sound?id= 链接
- unknown: 纯数字 ID(需依次尝试单集/电台)
"""
from urllib.parse import urlsplit, parse_qs
from urllib.parse import parse_qs, urlsplit
text = str(raw or '').strip()
# 短链先还原(仅当整串就是一个短链 URL)
if '163cn.tv' in text.lower():
@@ -247,9 +254,9 @@ def extract_podcast_ref(raw: str) -> Dict[str, str]:
# ============ 核心 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,
search_type: str = 'all') -> Dict[str, Any]:
search_type: str = 'all') -> dict[str, Any]:
"""搜索播客
Args:
@@ -267,9 +274,9 @@ def search_podcast(keywords: str, cookies: Optional[Dict[str, str]] = None,
raise APIException('搜索关键词不能为空')
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,
{'s': keywords, 'type': stype, 'limit': limit, 'offset': offset},
cookies)
@@ -288,7 +295,7 @@ def search_podcast(keywords: str, cookies: Optional[Dict[str, str]] = None,
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)详情"""
try:
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)
def get_radio_programs(radio_id: Any, cookies: Optional[Dict[str, str]] = None,
limit: int = 30, offset: int = 0, asc: bool = True) -> Dict[str, Any]:
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]:
"""获取电台下的单集列表(分页)
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)详情"""
try:
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)
def get_categories() -> List[Dict[str, Any]]:
def get_categories() -> list[dict[str, Any]]:
"""获取播客分类列表(GET 接口)
Returns:
@@ -387,9 +394,9 @@ def get_categories() -> List[Dict[str, Any]]:
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,
cate_id: Optional[int] = None) -> List[Dict[str, Any]]:
cate_id: int | None = None) -> list[dict[str, Any]]:
"""获取分类下热门播客
Args:
@@ -400,7 +407,7 @@ def get_hot_radios(cookies: Optional[Dict[str, str]] = None,
电台列表(已规范化)
"""
limit = max(1, min(int(limit or 30), 100))
cate_ids: List[int] = []
cate_ids: list[int] = []
user_cate = False
if cate_id:
try:
@@ -425,8 +432,8 @@ def get_hot_radios(cookies: Optional[Dict[str, str]] = None,
return []
def resolve_episode_url(song_id: Any, cookies: Optional[Dict[str, str]] = None,
level: str = 'auto') -> Optional[Dict[str, Any]]:
def resolve_episode_url(song_id: Any, cookies: dict[str, str] | None = None,
level: str = 'auto') -> dict[str, Any] | None:
"""解析单集音频直链(带音质降级)
Args:
@@ -439,7 +446,7 @@ def resolve_episode_url(song_id: Any, cookies: Optional[Dict[str, str]] = None,
"""
# F83:降级阶梯只向下不向上——原实现指定 standard 会回退到更高的 exhigh
_FALLDOWN_LADDER = ['hires', 'lossless', 'exhigh', 'standard']
order: List[str] = []
order: list[str] = []
level = str(level or 'auto').strip()
if level and level != 'auto':
order.append(level)
@@ -454,7 +461,7 @@ def resolve_episode_url(song_id: Any, cookies: Optional[Dict[str, str]] = None,
else:
order.extend(PodcastConstants.QUALITY_LADDER)
# 去重保序
dedup: List[str] = []
dedup: list[str] = []
for lv in order:
if lv not in dedup:
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,
page_size: int = 30) -> Dict[str, Any]:
def parse_podcast_ref(raw: str, cookies: dict[str, str] | None = None,
page_size: int = 30) -> dict[str, Any]:
"""智能解析用户输入:电台链接 → 电台详情+单集列表;单集链接 → 单集详情
Returns:
@@ -515,15 +522,15 @@ def parse_podcast_ref(raw: str, cookies: Optional[Dict[str, str]] = None,
return {'kind': 'radio', 'radio': radio, **page}
def list_radio_all_program_ids(radio_id: Any, cookies: Optional[Dict[str, str]] = None,
max_count: int = 200) -> List[Dict[str, Any]]:
def list_radio_all_program_ids(radio_id: Any, cookies: dict[str, str] | None = None,
max_count: int = 200) -> list[dict[str, Any]]:
"""拉取电台全部单集(用于整台打包,正序),返回 [{'id','name','main_song_id','serial_num','cover_url'}]
最多拉取 max_count 集,防止超大电台拖垮磁盘/任务。
F98:按节目 ID 去重;一页没有任何新增(短页/重复页/列表变动)即有界停止,
不再固定 offset+=100 盲目翻页。
"""
out: List[Dict[str, Any]] = []
out: list[dict[str, Any]] = []
seen_ids = set()
offset = 0
page_size = 100
+6
View File
@@ -25,6 +25,12 @@ ignore = [
"S603", "S607", # subprocess(打包/运维脚本)
"UP009", # 保留 `# -*- coding: utf-8 -*-` 文件头(全项目既有约定)
"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]
+13 -13
View File
@@ -7,17 +7,17 @@
- 用户友好的交互界面
"""
import asyncio
import datetime
import logging
import os
import shutil
import sys
import time
import shutil
import logging
import datetime
from pathlib import Path
from typing import Optional, Tuple
try:
from music_api import QRLoginManager, APIException, load_cookies
from music_api import APIException, QRLoginManager, load_cookies
except ImportError as e:
print(f"导入模块失败: {e}")
print("请确保 music_api.py 文件存在且可用")
@@ -87,7 +87,7 @@ class QRLoginClient:
shutil.copy2(backup, self.cookie_file)
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():
return None
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}")
return False
def interactive_login(self) -> Tuple[bool, Optional[str]]:
async def interactive_login(self) -> tuple[bool, str | None]:
"""交互式二维码登录"""
try:
print("\n=== 网易云音乐二维码登录 ===")
@@ -186,7 +186,7 @@ class QRLoginClient:
print("\n开始二维码登录流程...")
print("正在生成二维码...")
# 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:
error_msg = "生成二维码失败"
@@ -203,7 +203,7 @@ class QRLoginClient:
while attempt < max_attempts:
try:
# 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:
musu_u = cookie_dict.get('MUSIC_U', '')
@@ -233,7 +233,7 @@ class QRLoginClient:
if code == 801 and attempt % 10 == 0:
print(f"等待扫码中... ({attempt + 1}/{max_attempts})")
time.sleep(5)
await asyncio.sleep(5)
attempt += 1
except KeyboardInterrupt:
@@ -241,7 +241,7 @@ class QRLoginClient:
return False, "用户取消登录"
except Exception as e:
self.logger.error(f"检查登录状态时发生错误: {e}")
time.sleep(5)
await asyncio.sleep(5)
attempt += 1
print("\n[超时] 登录超时,请重新尝试")
@@ -325,7 +325,7 @@ def main():
command = sys.argv[1].lower()
if command == 'login':
success, error = client.interactive_login()
success, error = asyncio.run(client.interactive_login())
if success:
print("\n登录完成!")
client.show_login_info()
@@ -368,7 +368,7 @@ def main():
choice = input("\n请选择操作 (1-4): ").strip()
if choice == '1':
success, error = client.interactive_login()
success, error = asyncio.run(client.interactive_login())
if success:
print("\n登录成功!")
client.show_login_info()