diff --git a/.dockerignore b/.dockerignore index a8c2f35..761b329 100644 --- a/.dockerignore +++ b/.dockerignore @@ -18,7 +18,7 @@ _test_podcast_api.py # 真实凭据、用户数据库、密钥、日志、交接文档、运维脚本与审查工作目录 cookie.txt user_cookie.txt -# Cookie 备份(qr_login._backup_cookie 产生,含真实 MUSIC_U): +# Cookie 备份(music_api.qr_client._backup_cookie 产生,含真实 MUSIC_U): # 原模式 *.cookie.backup.* 与实际文件名 cookie.backup.*.txt 不匹配,排除形同虚设 cookie.*.txt user_cookie.*.txt diff --git a/_mkpkg.py b/_mkpkg.py index 00d29bf..602e2a5 100644 --- a/_mkpkg.py +++ b/_mkpkg.py @@ -24,7 +24,7 @@ ignore = shutil.ignore_patterns( '_audit_work*', # .zcode 为 AI 工具工作目录(内部规划/会话文件),不得进任何分发包 '.zcode*', - # Cookie 备份文件(qr_login._backup_cookie 产生,含真实 MUSIC_U): + # Cookie 备份文件(music_api.qr_client._backup_cookie 产生,含真实 MUSIC_U): # 'cookie.*.txt' 匹配 cookie.prewrite/backup/logout.*.txt 但不匹配 cookie.txt 本身 'cookie.*.txt', 'user_cookie.*.txt') diff --git a/podcast_api.py b/music_api/podcast.py similarity index 99% rename from podcast_api.py rename to music_api/podcast.py index 384f15f..4550df9 100644 --- a/podcast_api.py +++ b/music_api/podcast.py @@ -22,7 +22,10 @@ import time from typing import Any from core import sync_http as requests # 同步门面(底层 aiohttp) -from music_api import APIConstants, APIException, url_v1 + +from .compat import url_v1 +from .constants import APIConstants +from .errors import APIException class PodcastConstants: diff --git a/qr_login.py b/music_api/qr_client.py similarity index 97% rename from qr_login.py rename to music_api/qr_client.py index 176e810..a79eb7e 100644 --- a/qr_login.py +++ b/music_api/qr_client.py @@ -16,13 +16,9 @@ import sys import time from pathlib import Path -try: - from music_api import APIException, QRLoginManager, load_cookies -except ImportError as e: - print(f"导入模块失败: {e}") - print("请确保 music_api.py 文件存在且可用") - sys.exit(1) - +from .cookies import load_cookies +from .errors import APIException +from .qr import QRLoginManager # 判断 cookie 是否"有效":包含任一关键字段即可 IMPORTANT_COOKIE_KEYS = ("MUSIC_U", "__csrf", "NMTID") @@ -342,7 +338,7 @@ def main(): elif command in ('help', '-h', '--help'): print("网易云音乐二维码登录工具") - print("\n用法: python qr_login.py [命令]") + print("\n用法: python -m music_api.qr_client [命令]") print("\n命令:") print(" login - 执行二维码登录") print(" status - 显示登录状态") @@ -353,7 +349,7 @@ def main(): else: print(f"未知命令: {command}") - print("使用 'python qr_login.py help' 查看帮助") + print("使用 'python -m music_api.qr_client help' 查看帮助") sys.exit(1) # 交互模式 diff --git a/pyproject.toml b/pyproject.toml index fe718cd..f2b6b0c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,7 +57,7 @@ ignore = [ "RUF012", # 常量类可变默认值:APIConstants/PodcastConstants 是常量表,非实例状态 "S305", # AES-ECB:网易云 weapi 协议**要求**的加密模式,非可自由选择 "S311", # randrange:生成上游 requestId(协议随机数),非安全用途 - "ASYNC250", # input():qr_login 交互式 CLI 的人类输入,异步化无意义 + "ASYNC250", # input():music_api.qr_client 交互式 CLI 的人类输入,异步化无意义 "S310", # urllib:仅用于音频 Range 代理的 URL 校验路径 ] @@ -88,8 +88,6 @@ include = [ "music_api", "auth_system", "music_downloader", - "podcast_api.py", - "qr_login.py", "_mkpkg.py", ] exclude = [".venv", ".zcode", "data", "downloads", "templates", "static"] diff --git a/services/__init__.py b/services/__init__.py index a58399a..d95dcd7 100644 --- a/services/__init__.py +++ b/services/__init__.py @@ -21,7 +21,7 @@ import importlib import sys -for _dep in ('auth_system', 'podcast_api', 'music_api', 'music_downloader', 'qr_login'): +for _dep in ('auth_system', 'music_api', 'music_downloader'): try: importlib.import_module(_dep) except ImportError as _exc: diff --git a/services/downloads.py b/services/downloads.py index 9bfa2a5..6580670 100644 --- a/services/downloads.py +++ b/services/downloads.py @@ -24,12 +24,12 @@ from music_api import ( account_info, detect_vip_by_probe, ) +from music_api.qr_client import QRLoginClient from music_downloader import ( DownloadCanceled, DownloadException, fetch_cover_bytes, ) -from qr_login import QRLoginClient from ._shared import ( _build_download_base, diff --git a/services/podcast.py b/services/podcast.py index 0696fbb..bcbabf3 100644 --- a/services/podcast.py +++ b/services/podcast.py @@ -12,13 +12,13 @@ from pathlib import Path from typing import Any import auth_system -import podcast_api from core.state import ( DOWNLOAD_TASKS, DOWNLOAD_TASKS_LOCK, ) from music_api import ( APIException, + podcast, ) from music_downloader import ( DownloadCanceled, @@ -121,7 +121,7 @@ def _podcast_resolve_audio(program: dict[str, Any], quality: str, song_id = program.get('main_song_id') if not song_id: raise DownloadException("该单集缺少音频信息(mainSong)") - url_info = podcast_api.resolve_episode_url(song_id, cookies, quality) + url_info = podcast.resolve_episode_url(song_id, cookies, quality) if not url_info or not url_info.get('url'): raise DownloadException("未获取到音频直链(单集可能已下架或受版权限制)") return url_info @@ -151,7 +151,7 @@ def _podcast_download_worker(task_id: str) -> None: cookies = params['cookies'] remote_ip = params.get('remote_ip') or '-' - program = podcast_api.get_program_detail(program_id, cookies) + program = podcast.get_program_detail(program_id, cookies) url_info = _podcast_resolve_audio(program, quality, cookies) serial = int(program.get('serial_num') or 0) @@ -315,14 +315,14 @@ def _podcast_zip_worker(task_id: str) -> None: # 组装单集清单:整台电台(正序全量)或指定单集ID列表 if radio_id: - episodes = podcast_api.list_radio_all_program_ids(radio_id, cookies, max_count=limit) + episodes = podcast.list_radio_all_program_ids(radio_id, cookies, max_count=limit) if not episodes: raise DownloadException("该电台暂无可下载的单集") else: episodes = [] for pid in (preset_ids or []): try: - prog = podcast_api.get_program_detail(pid, cookies) + prog = podcast.get_program_detail(pid, cookies) episodes.append({ 'id': prog['id'], 'name': prog['name'], @@ -394,7 +394,7 @@ def _podcast_zip_worker(task_id: str) -> None: label = f"[{idx}/{len(episodes)}] {ep['name'][:24]}" _update_progress(f"{label} 解析中...") try: - prog = ep.get('detail') or podcast_api.get_program_detail(ep['id'], cookies) + prog = ep.get('detail') or podcast.get_program_detail(ep['id'], cookies) url_info = _podcast_resolve_audio(prog, quality, cookies) _update_progress(f"{label} 下载中...") ext = (url_info.get('type') or 'mp3').lower().lstrip('.') diff --git a/tests/test_zip_workers.py b/tests/test_zip_workers.py index 3885ddb..a17ae7a 100644 --- a/tests/test_zip_workers.py +++ b/tests/test_zip_workers.py @@ -383,7 +383,7 @@ def podcast_stub(monkeypatch, sandbox): monkeypatch.setattr(podcast_svc, '_podcast_resolve_audio', fake_resolve) monkeypatch.setattr(podcast_svc, '_podcast_stream_download', fake_stream) monkeypatch.setattr(podcast_svc, 'fetch_cover_bytes', lambda _u: None) - monkeypatch.setattr(podcast_svc.podcast_api, 'get_program_detail', fake_detail) + monkeypatch.setattr(podcast_svc.podcast, 'get_program_detail', fake_detail) return sandbox diff --git a/web/routers/admin.py b/web/routers/admin.py index bb3ae6b..e2746bd 100644 --- a/web/routers/admin.py +++ b/web/routers/admin.py @@ -47,7 +47,7 @@ AdminUser = Annotated[SiteUser, Depends(_require_admin)] def _admin_client(): - from qr_login import QRLoginClient + from music_api.qr_client import QRLoginClient return QRLoginClient() diff --git a/web/routers/download.py b/web/routers/download.py index 19e02e1..f5171ff 100644 --- a/web/routers/download.py +++ b/web/routers/download.py @@ -316,10 +316,10 @@ async def podcast_download(user: Annotated[SiteUser | None, Depends(current_user if not raw: await billing.refund_open('rejected') return error("参数 'id' 不能为空", 400) - import podcast_api + from music_api import podcast quality = _podcast_quality(data) need_zip = str(data.get('zip', 'false')).lower() in ('1', 'true', 'yes') - ref = podcast_api.extract_podcast_ref(raw) + ref = podcast.extract_podcast_ref(raw) if ref['kind'] == 'radio': await billing.refund_open('rejected') return error('这是电台链接,请先解析电台后选择单集下载,或使用「打包整个播客」') @@ -388,16 +388,16 @@ async def podcast_batch_zip(user: Annotated[SiteUser | None, Depends(current_use # 引用它(radio_id 为假则取 id_list)——先绑定,避免异常/类型检查下的未绑定名 id_list: list[str] = [] - import podcast_api + from music_api import podcast if raw_radio: - ref = podcast_api.extract_podcast_ref(raw_radio) + ref = podcast.extract_podcast_ref(raw_radio) if ref['kind'] == 'program': await billing.refund_open('rejected') return error('这是单集链接,请使用「下载单集」或直接输入电台链接/ID') radio_id = ref['id'] radio_name = '' try: - radio_name = podcast_api.get_radio_detail(radio_id, cookies).get('name', '') + radio_name = podcast.get_radio_detail(radio_id, cookies).get('name', '') except APIException: pass else: diff --git a/web/routers/podcast.py b/web/routers/podcast.py index e31ca9e..fb1ad59 100644 --- a/web/routers/podcast.py +++ b/web/routers/podcast.py @@ -7,7 +7,7 @@ - POST/GET /api/v1/podcast/search ↔ Flask /podcast/search - POST/GET /api/v1/podcast/hot ↔ Flask /podcast/hot -说明:podcast_api 仍是同步模块(requests),此处以 asyncio.to_thread 包装, +说明:music_api.podcast 是同步模块(底层 core.sync_http),此处以 asyncio.to_thread 包装, 让阻塞的上游调用不占用事件循环——行为不变,且不阻塞其它请求。 """ import asyncio @@ -15,9 +15,8 @@ from typing import Annotated from fastapi import APIRouter, Depends, Request -import podcast_api from core import media -from music_api import APIException +from music_api import APIException, podcast from web import ratelimit from web.deps import SiteUser, require_login from web.request_utils import cookies as _cookies @@ -53,7 +52,7 @@ async def podcast_parse(_user: LoginUser, request: Request): cookies = _cookies() try: result = await asyncio.to_thread( - podcast_api.parse_podcast_ref, raw, cookies, page_size=page_size) + podcast.parse_podcast_ref, raw, cookies, page_size=page_size) except APIException as e: return error(str(e), 404) except Exception: @@ -66,7 +65,7 @@ async def podcast_parse(_user: LoginUser, request: Request): if song_id: try: url_info = await asyncio.to_thread( - podcast_api.resolve_episode_url, song_id, cookies, quality) + podcast.resolve_episode_url, song_id, cookies, quality) except Exception: url_info = None if url_info: @@ -112,7 +111,7 @@ async def podcast_programs(_user: LoginUser, request: Request): return error('无效的电台 ID') try: result = await asyncio.to_thread( - podcast_api.get_radio_programs, radio_id, _cookies(), + podcast.get_radio_programs, radio_id, _cookies(), limit=limit, offset=offset, asc=asc) return success(result, '获取单集列表成功') except APIException as e: @@ -136,7 +135,7 @@ async def podcast_search(_user: LoginUser, request: Request): search_type = 'all' try: result = await asyncio.to_thread( - podcast_api.search_podcast, keyword, _cookies(), + podcast.search_podcast, keyword, _cookies(), limit=limit, offset=offset, search_type=search_type) return success(result, '播客搜索完成') except APIException as e: @@ -155,13 +154,13 @@ async def podcast_hot(_user: LoginUser, request: Request): cate_id = data.get('cate_id') or None try: radios = await asyncio.to_thread( - podcast_api.get_hot_radios, _cookies(), limit=limit, offset=offset, cate_id=cate_id) + podcast.get_hot_radios, _cookies(), limit=limit, offset=offset, cate_id=cate_id) except APIException as e: return error(str(e), 404) except Exception: return error('服务暂时不可用,请稍后再试', 500) try: - categories = await asyncio.to_thread(podcast_api.get_categories) + categories = await asyncio.to_thread(podcast.get_categories) except APIException: categories = [] # 满页视为可能还有更多 diff --git a/web/routers/user.py b/web/routers/user.py index afd17bd..cb377ab 100644 --- a/web/routers/user.py +++ b/web/routers/user.py @@ -81,7 +81,7 @@ async def _mask(nick: str, user: SiteUser | None) -> str: async def user_qr_create(request: Request): """生成个人登录二维码(返回 SVG)。""" await ratelimit.guard(request, bucket='free', limit=30, window=60) - from qr_login import QRLoginClient + from music_api.qr_client import QRLoginClient try: client = QRLoginClient('user_cookie.txt') @@ -116,7 +116,7 @@ async def user_qr_check( unikey = data.get('unikey') if not unikey: return error('缺少 unikey 参数') - from qr_login import QRLoginClient + from music_api.qr_client import QRLoginClient try: client = QRLoginClient('user_cookie.txt')