├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── LICENSE ├── NEMbox ├── __init__.py ├── __main__.py ├── api.py ├── cache.py ├── cmd_parser.py ├── config.py ├── const.py ├── encrypt.py ├── kill_thread.py ├── logger.py ├── menu.py ├── osdlyrics.py ├── player.py ├── scrollstring.py ├── singleton.py ├── storage.py ├── ui.py └── utils.py ├── README.md ├── poetry.lock ├── pyproject.toml ├── setup.cfg └── tests ├── __init__.py └── test_api.py /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Build 5 | 6 | on: 7 | push: 8 | branches: [master] 9 | pull_request: 10 | branches: [master] 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | strategy: 16 | matrix: 17 | python-version: [3.6, 3.7, 3.8, 3.9] 18 | 19 | steps: 20 | - uses: actions/checkout@v2 21 | - name: Set up Python ${{ matrix.python-version }} 22 | uses: actions/setup-python@v2 23 | with: 24 | python-version: ${{ matrix.python-version }} 25 | - uses: abatilo/actions-poetry@v2.0.0 26 | with: 27 | poetry-version: 1.1.4 28 | - uses: actions/cache@v2 29 | with: 30 | path: | 31 | ~/.cache/pypoetry/virtualenvs 32 | key: ${{ runner.os }}-${{ matrix.python-version }}-${{ hashFiles('**/poetry.lock') }} 33 | - name: Install dependencies 34 | run: | 35 | poetry install 36 | poetry build 37 | - name: Test with pytest 38 | run: poetry run pytest -s 39 | - name: Run cli 40 | run: poetry run musicbox -v 41 | env: 42 | TERM: xterm 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 98 | __pypackages__/ 99 | 100 | # Celery stuff 101 | celerybeat-schedule 102 | celerybeat.pid 103 | 104 | # SageMath parsed files 105 | *.sage.py 106 | 107 | # Environments 108 | .env 109 | .venv 110 | env/ 111 | venv/ 112 | ENV/ 113 | env.bak/ 114 | venv.bak/ 115 | 116 | # Spyder project settings 117 | .spyderproject 118 | .spyproject 119 | 120 | # Rope project settings 121 | .ropeproject 122 | 123 | # mkdocs documentation 124 | /site 125 | 126 | # mypy 127 | .mypy_cache/ 128 | .dmypy.json 129 | dmypy.json 130 | 131 | # Pyre type checker 132 | .pyre/ 133 | 134 | # pytype static type analyzer 135 | .pytype/ 136 | 137 | # Cython debug symbols 138 | cython_debug/ 139 | 140 | # vscode 141 | .vscode/ 142 | 143 | nemcache.sqlite 144 | 145 | #Jetbrains 146 | .idea 147 | 148 | # Vim 149 | .undodir 150 | Session.vim 151 | .vim 152 | 153 | test_login.py 154 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/ambv/black 3 | rev: 20.8b1 4 | hooks: 5 | - id: black 6 | exclude: venv 7 | 8 | - repo: https://github.com/pre-commit/pre-commit-hooks 9 | rev: v3.4.0 10 | hooks: 11 | - id: trailing-whitespace 12 | - id: check-yaml 13 | 14 | - repo: https://github.com/asottile/reorder_python_imports 15 | rev: v2.3.6 16 | hooks: 17 | - id: reorder-python-imports 18 | args: ["--py3-plus"] 19 | 20 | - repo: https://gitlab.com/pycqa/flake8 21 | rev: 3.8.4 22 | hooks: 23 | - id: flake8 24 | # allow "imported but unused" for pre-commit, forbid it elsewhere e.g. in vscode 25 | args: ["--config=setup.cfg", "--ignore=E402,F401"] 26 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 更新日志 2 | 3 | 2018-05-21 版本 0.2.4.3 更新依赖,错误修复 4 | 5 | 2017-11-28 版本 0.2.4.2 更新获取歌曲列表的api 6 | 7 | 2017-06-03 版本 0.2.4.1 修正mpg123状态异常导致的cpu占用,增加歌词双行显示功能 8 | 9 | 2017-03-17 版本 0.2.4.0 修复通知可能造成的崩溃 10 | 11 | 2017-03-03 版本 0.2.3.9 邮箱用户登录修复 12 | 13 | 2017-03-02 版本 0.2.3.8 登录接口修复 14 | 15 | 2016-11-24 版本 0.2.3.7 新增背景色设置 16 | 17 | 2016-11-07 版本 0.2.3.6 已知错误修复 18 | 19 | 2016-10-16 版本 0.2.3.5 新增进入歌曲专辑功能 20 | 21 | 2016-10-13 版本 0.2.3.4 新增查看歌曲评论 22 | 23 | 2016-09-26 版本 0.2.3.3 keybinder 错误修复 24 | 25 | 2016-09-15 版本 0.2.3.2 已知错误修复 26 | 27 | 2016-09-12 版本 0.2.3.1 已知错误修复 28 | 29 | 2016-09-11 版本 0.2.3.0 Python 2 和 3 支持 30 | 31 | 2016-05-09 版本 0.2.2.10 修复最后一行歌名过长的问题 32 | 33 | 2016-05-08 版本 0.2.2.9 缓存问题修复 34 | 35 | 2016-05-07 版本 0.2.2.8 解决通知在Gnome桌面持续驻留(#303)的问题 36 | 37 | 2016-05-07 版本 0.2.2.6 已知错误修复 38 | 39 | 2016-05-05 版本 0.2.2.5 已知错误修复 40 | 41 | 2016-05-04 版本 0.2.2.4 修复因更换 API 导致版权歌曲播放崩溃 42 | 43 | 2016-05-04 版本 0.2.2.3 修复部分歌曲跳过问题 44 | 45 | 2016-04-12 版本 0.2.2.2 修复 OS X 系统桌面歌词置顶和隐藏边框不能共存的问题 46 | 47 | 2016-03-25 版本 0.2.2.1 桌面歌词可置顶或隐藏边框 48 | 49 | 2016-03-25 版本 0.2.2.0 新增桌面歌词功能 50 | 51 | 2016-03-10 版本 0.2.1.6 已知错误修复,代码优化 52 | 53 | 2016-03-02 版本 0.2.1.4 新增自动签到功能 54 | 55 | 2016-02-29 版本 0.2.1.3 修复离线错误 56 | 57 | 2016-02-25 版本 0.2.1.2 增加歌词翻译开关 58 | 59 | 2016-02-19 版本 0.2.1.1 已知错误修复 60 | 61 | 2016-02-10 版本 0.2.1.0 新增外文歌曲歌词翻译显示 62 | 63 | 2015-12-31 版本 0.2.0.8 修复每日推荐因 API 更换导致的错误 64 | 65 | 2015-12-13 版本 0.2.0.7 优化歌曲提醒显示 66 | 67 | 2015-12-02 版本 0.2.0.6 新增手动缓存功能 68 | 69 | 2015-11-28 版本 0.2.0.5 已知错误修复 70 | 71 | 2015-11-10 版本 0.2.0.4 优化切换歌曲时歌单显示, 新增显示歌曲信息功能 72 | 73 | 2015-11-09 版本 0.2.0.2 修复崩溃错误, 优化榜单排序 74 | 75 | 2015-11-05 版本 0.2.0.1 优化列表翻页功能 76 | 77 | 2015-10-31 版本 0.2.0.0 新增部分操作的提醒功能 78 | 79 | 2015-10-28 版本 0.1.9.9 修复缓存链接过期问题 80 | 81 | 2015-10-17 版本 0.1.9.8 新增歌曲播放提醒开关 82 | 83 | 2015-10-14 版本 0.1.9.7 新增歌曲播放提醒 84 | 85 | 2015-10-13 版本 0.1.9.6 修复因 cookie 过期导致的登录问题 86 | 87 | 2015-10-13 版本 0.1.9.5 新增自定义全局快捷键功能 88 | 89 | 2015-09-25 版本 0.1.9.4 修复部分列表无法暂停问题 90 | 91 | 2015-09-25 版本 0.1.9.2 新增版本检查和更新提醒功能 92 | 93 | 2015-09-24 版本 0.1.9.0 优化登录逻辑,修复每日推荐的登录问题 94 | 95 | 2015-09-23 版本 0.1.8.5 优化电台FM功能逻辑 96 | 97 | 2015-09-22 版本 0.1.8.4 修复未开启缓存功能无法加❤的问题 98 | 99 | 2015-09-22 版本 0.1.8.2 新增加❤功能 100 | 101 | 2015-09-21 版本 0.1.8.1 隐藏不常用的收藏功能,新增电台FM功能 102 | 103 | 2015-09-20 版本 0.1.8.0 隐藏不常用的打碟功能,新增每日推荐功能 104 | 105 | 2015-08-29 版本 0.1.7.6 使用SSL登录 106 | 107 | 2015-08-17 版本 0.1.7.5 新增用户配置文件 108 | 109 | 2015-08-09 版本 0.1.7.1 修正无法暂停错误 110 | 111 | 2015-08-08 版本 0.1.7.0 新增歌词显示,优化本地信息 112 | 113 | 2015-08-02 版本 0.1.6.5 新增播放模式切换 114 | 115 | 2015-08-02 版本 0.1.6.2 新增显示播放进度 116 | 117 | 2015-07-30 版本 0.1.6.0 修复由于接口更换导致的用户登录问题 118 | 119 | 2015-06-17 版本 0.1.5.6 优化对过长歌曲信息的显示 120 | 121 | 2015-05-26 版本 0.1.5.5 修复海外用户无法搜索的问题 122 | 123 | 2015-05-05 版本 0.1.5.4 优化log文件路径 124 | 125 | 2015-04-02 版本 0.1.5.3 修复个别歌单崩溃错误 126 | 127 | 2015-03-31 版本 0.1.5.2 新增22个歌曲排行榜 128 | 129 | 2015-03-30 版本 0.1.5.1 新增一键 P 回到历史播放列表功能 (感谢Catofes提交) 130 | 131 | 2015-03-27 版本 0.1.5.0 使用全新动态UI绘制方法,提高稳定性 (感谢Will Jiang提交) 132 | 133 | 2015-03-25 版本 0.1.4.0 优先使用320kbps音源,优化线路,显示当前音乐音质 (感谢chaserhkj反馈) 134 | 135 | 2015-03-24 版本 0.1.3.4 增加向上/向下移动项目功能 (感谢chaserhkj提交) 136 | 137 | 2015-03-18 版本 0.1.3.3 修复Ubuntu等系统SSL登录报错问题 138 | 139 | 2015-02-28 版本 0.1.3.2 修复170等新增号段手机用户无法登录的问题 140 | 141 | 2015-02-05 版本 0.1.3.1 修复登录无法保存的问题 142 | 143 | 2015-01-30 版本 0.1.3.0 修复UI调整后就Crash的问题,修改登录UI (感谢尘埃提交) 144 | 145 | 2015-01-28 版本 0.1.2.4 修改搜索UI (感谢尘埃提交) 146 | 147 | 2015-01-08 版本 0.1.2.3 增加手气不错,微调音量控制 148 | 149 | 2015-01-08 版本 0.1.2.0 增加音量控制 150 | 151 | 2015-01-03 版本 0.1.1.1 修复部分仅手机注册用户登录无法登录 (感谢Catofes反馈) 152 | 153 | 2015-01-02 版本 0.1.1.0 新增退出并清除用户信息功能 154 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 omi <4399.omi@gmail.com> 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /NEMbox/__init__.py: -------------------------------------------------------------------------------- 1 | r""" 2 | __ ___________________________________________ 3 | | \ ||______ | |______|_____||______|______ 4 | | \_||______ | |______| |______||______ 5 | 6 | ________ __________________________ _____ _ _ 7 | | | || ||______ | | |_____]| | \___/ 8 | | | ||_____|______|__|__|_____ |_____]|_____|_/ \_ 9 | 10 | 11 | + ------------------------------------------ + 12 | | NetEase-MusicBox 320kbps | 13 | + ------------------------------------------ + 14 | | | 15 | | ++++++++++++++++++++++++++++++++++++++ | 16 | | ++++++++++++++++++++++++++++++++++++++ | 17 | | ++++++++++++++++++++++++++++++++++++++ | 18 | | ++++++++++++++++++++++++++++++++++++++ | 19 | | ++++++++++++++++++++++++++++++++++++++ | 20 | | | 21 | | A sexy cli musicbox based on Python | 22 | | Music resource from music.163.com | 23 | | | 24 | | Built with love to music by omi | 25 | | | 26 | + ------------------------------------------ + 27 | 28 | """ 29 | from importlib_metadata import version 30 | 31 | from .const import Constant 32 | from .utils import create_dir 33 | from .utils import create_file 34 | 35 | __version__ = version("NetEase-MusicBox") 36 | 37 | 38 | def create_config(): 39 | create_dir(Constant.conf_dir) 40 | create_dir(Constant.download_dir) 41 | create_file(Constant.storage_path) 42 | create_file(Constant.log_path, default="") 43 | create_file(Constant.cookie_path, default="# Netscape HTTP Cookie File\n") 44 | 45 | 46 | create_config() 47 | -------------------------------------------------------------------------------- /NEMbox/__main__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | import _curses 4 | import argparse 5 | import curses 6 | import sys 7 | import traceback 8 | 9 | from . import __version__ 10 | from .menu import Menu 11 | 12 | 13 | def start(): 14 | version = __version__ 15 | 16 | parser = argparse.ArgumentParser() 17 | parser.add_argument( 18 | "-v", "--version", help="show this version and exit", action="store_true" 19 | ) 20 | args = parser.parse_args() 21 | 22 | if args.version: 23 | latest = Menu().check_version() 24 | try: 25 | curses.endwin() 26 | except _curses.error: 27 | pass 28 | print("NetEase-MusicBox installed version:" + version) 29 | if latest != version: 30 | print("NetEase-MusicBox latest version:" + str(latest)) 31 | sys.exit() 32 | 33 | nembox_menu = Menu() 34 | try: 35 | nembox_menu.start_fork(version) 36 | except (OSError, TypeError, ValueError, KeyError, IndexError): 37 | # clean up terminal while failed 38 | try: 39 | curses.echo() 40 | curses.nocbreak() 41 | curses.endwin() 42 | except _curses.error: 43 | pass 44 | traceback.print_exc() 45 | 46 | 47 | if __name__ == "__main__": 48 | start() 49 | -------------------------------------------------------------------------------- /NEMbox/api.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # @Author: omi 4 | # @Date: 2014-08-24 21:51:57 5 | """ 6 | 网易云音乐 Api 7 | """ 8 | import json 9 | import platform 10 | import time 11 | from collections import OrderedDict 12 | from http.cookiejar import Cookie 13 | from http.cookiejar import MozillaCookieJar 14 | 15 | import requests 16 | import requests_cache 17 | 18 | from .config import Config 19 | from .const import Constant 20 | from .encrypt import encrypted_request 21 | from .logger import getLogger 22 | from .storage import Storage 23 | 24 | requests_cache.install_cache(Constant.cache_path, expire_after=3600) 25 | 26 | log = getLogger(__name__) 27 | 28 | # 歌曲榜单地址 29 | TOP_LIST_ALL = { 30 | 0: ["云音乐新歌榜", "3779629"], 31 | 1: ["云音乐热歌榜", "3778678"], 32 | 2: ["网易原创歌曲榜", "2884035"], 33 | 3: ["云音乐飙升榜", "19723756"], 34 | 4: ["云音乐电音榜", "10520166"], 35 | 5: ["UK排行榜周榜", "180106"], 36 | 6: ["美国Billboard周榜", "60198"], 37 | 7: ["KTV嗨榜", "21845217"], 38 | 8: ["iTunes榜", "11641012"], 39 | 9: ["Hit FM Top榜", "120001"], 40 | 10: ["日本Oricon周榜", "60131"], 41 | 11: ["韩国Melon排行榜周榜", "3733003"], 42 | 12: ["韩国Mnet排行榜周榜", "60255"], 43 | 13: ["韩国Melon原声周榜", "46772709"], 44 | 14: ["中国TOP排行榜(港台榜)", "112504"], 45 | 15: ["中国TOP排行榜(内地榜)", "64016"], 46 | 16: ["香港电台中文歌曲龙虎榜", "10169002"], 47 | 17: ["华语金曲榜", "4395559"], 48 | 18: ["中国嘻哈榜", "1899724"], 49 | 19: ["法国 NRJ EuroHot 30周榜", "27135204"], 50 | 20: ["台湾Hito排行榜", "112463"], 51 | 21: ["Beatport全球电子舞曲榜", "3812895"], 52 | 22: ["云音乐ACG音乐榜", "71385702"], 53 | 23: ["云音乐嘻哈榜", "991319590"], 54 | } 55 | 56 | 57 | PLAYLIST_CLASSES = OrderedDict( 58 | [ 59 | ("语种", ["华语", "欧美", "日语", "韩语", "粤语", "小语种"]), 60 | ( 61 | "风格", 62 | [ 63 | "流行", 64 | "摇滚", 65 | "民谣", 66 | "电子", 67 | "舞曲", 68 | "说唱", 69 | "轻音乐", 70 | "爵士", 71 | "乡村", 72 | "R&B/Soul", 73 | "古典", 74 | "民族", 75 | "英伦", 76 | "金属", 77 | "朋克", 78 | "蓝调", 79 | "雷鬼", 80 | "世界音乐", 81 | "拉丁", 82 | "另类/独立", 83 | "New Age", 84 | "古风", 85 | "后摇", 86 | "Bossa Nova", 87 | ], 88 | ), 89 | ( 90 | "场景", 91 | ["清晨", "夜晚", "学习", "工作", "午休", "下午茶", "地铁", "驾车", "运动", "旅行", "散步", "酒吧"], 92 | ), 93 | ( 94 | "情感", 95 | [ 96 | "怀旧", 97 | "清新", 98 | "浪漫", 99 | "性感", 100 | "伤感", 101 | "治愈", 102 | "放松", 103 | "孤独", 104 | "感动", 105 | "兴奋", 106 | "快乐", 107 | "安静", 108 | "思念", 109 | ], 110 | ), 111 | ( 112 | "主题", 113 | [ 114 | "影视原声", 115 | "ACG", 116 | "儿童", 117 | "校园", 118 | "游戏", 119 | "70后", 120 | "80后", 121 | "90后", 122 | "网络歌曲", 123 | "KTV", 124 | "经典", 125 | "翻唱", 126 | "吉他", 127 | "钢琴", 128 | "器乐", 129 | "榜单", 130 | "00后", 131 | ], 132 | ), 133 | ] 134 | ) 135 | 136 | DEFAULT_TIMEOUT = 10 137 | 138 | BASE_URL = "http://music.163.com" 139 | 140 | 141 | class Parse(object): 142 | @classmethod 143 | def _song_url_by_id(cls, sid): 144 | # 128k 145 | url = "http://music.163.com/song/media/outer/url?id={}.mp3".format(sid) 146 | quality = "LD 128k" 147 | return url, quality 148 | 149 | @classmethod 150 | def song_url(cls, song): 151 | if "url" in song: 152 | # songs_url resp 153 | url = song["url"] 154 | if url is None: 155 | return Parse._song_url_by_id(song["id"]) 156 | br = song["br"] 157 | if br >= 320000: 158 | quality = "HD" 159 | elif br >= 192000: 160 | quality = "MD" 161 | else: 162 | quality = "LD" 163 | return url, "{} {}k".format(quality, br // 1000) 164 | else: 165 | # songs_detail resp 166 | return Parse._song_url_by_id(song["id"]) 167 | 168 | @classmethod 169 | def song_album(cls, song): 170 | # 对新老接口进行处理 171 | if "al" in song: 172 | if song["al"] is not None: 173 | album_name = song["al"]["name"] 174 | album_id = song["al"]["id"] 175 | else: 176 | album_name = "未知专辑" 177 | album_id = "" 178 | elif "album" in song: 179 | if song["album"] is not None: 180 | album_name = song["album"]["name"] 181 | album_id = song["album"]["id"] 182 | else: 183 | album_name = "未知专辑" 184 | album_id = "" 185 | else: 186 | raise ValueError 187 | return album_name, album_id 188 | 189 | @classmethod 190 | def song_artist(cls, song): 191 | artist = "" 192 | # 对新老接口进行处理 193 | if "ar" in song: 194 | artist = ", ".join([a["name"] for a in song["ar"] if a["name"] is not None]) 195 | # 某些云盘的音乐会出现 'ar' 的 'name' 为 None 的情况 196 | # 不过会多个 ’pc' 的字段 197 | # {'name': '简单爱', 'id': 31393663, 'pst': 0, 't': 1, 'ar': [{'id': 0, 'name': None, 'tns': [], 'alias': []}], 198 | # 'alia': [], 'pop': 0.0, 'st': 0, 'rt': None, 'fee': 0, 'v': 5, 'crbt': None, 'cf': None, 199 | # 'al': {'id': 0, 'name': None, 'picUrl': None, 'tns': [], 'pic': 0}, 'dt': 273000, 'h': None, 'm': None, 200 | # 'l': {'br': 193000, 'fid': 0, 'size': 6559659, 'vd': 0.0}, 'a': None, 'cd': None, 'no': 0, 'rtUrl': None, 201 | # 'ftype': 0, 'rtUrls': [], 'djId': 0, 'copyright': 0, 's_id': 0, 'rtype': 0, 'rurl': None, 'mst': 9, 202 | # 'cp': 0, 'mv': 0, 'publishTime': 0, 203 | # 'pc': {'nickname': '', 'br': 192, 'fn': '简单爱.mp3', 'cid': '', 'uid': 41533322, 'alb': 'The One 演唱会', 204 | # 'sn': '简单爱', 'version': 2, 'ar': '周杰伦'}, 'url': None, 'br': 0} 205 | if artist == "" and "pc" in song: 206 | artist = "未知艺术家" if song["pc"]["ar"] is None else song["pc"]["ar"] 207 | elif "artists" in song: 208 | artist = ", ".join([a["name"] for a in song["artists"]]) 209 | else: 210 | artist = "未知艺术家" 211 | 212 | return artist 213 | 214 | @classmethod 215 | def songs(cls, songs): 216 | song_info_list = [] 217 | for song in songs: 218 | url, quality = Parse.song_url(song) 219 | if not url: 220 | continue 221 | 222 | album_name, album_id = Parse.song_album(song) 223 | song_info = { 224 | "song_id": song["id"], 225 | "artist": Parse.song_artist(song), 226 | "song_name": song["name"], 227 | "album_name": album_name, 228 | "album_id": album_id, 229 | "mp3_url": url, 230 | "quality": quality, 231 | "expires": song["expires"], 232 | "get_time": song["get_time"], 233 | } 234 | song_info_list.append(song_info) 235 | return song_info_list 236 | 237 | @classmethod 238 | def artists(cls, artists): 239 | return [ 240 | { 241 | "artist_id": artist["id"], 242 | "artists_name": artist["name"], 243 | "alias": "".join(artist["alias"]), 244 | } 245 | for artist in artists 246 | ] 247 | 248 | @classmethod 249 | def albums(cls, albums): 250 | return [ 251 | { 252 | "album_id": album["id"], 253 | "albums_name": album["name"], 254 | "artists_name": album["artist"]["name"], 255 | } 256 | for album in albums 257 | ] 258 | 259 | @classmethod 260 | def playlists(cls, playlists): 261 | return [ 262 | { 263 | "playlist_id": pl["id"], 264 | "playlist_name": pl["name"], 265 | "creator_name": pl["creator"]["nickname"], 266 | } 267 | for pl in playlists 268 | ] 269 | 270 | 271 | class NetEase(object): 272 | def __init__(self): 273 | self.header = { 274 | "Accept": "*/*", 275 | "Accept-Encoding": "gzip,deflate,sdch", 276 | "Accept-Language": "zh-CN,zh;q=0.8,gl;q=0.6,zh-TW;q=0.4", 277 | "Connection": "keep-alive", 278 | "Content-Type": "application/x-www-form-urlencoded", 279 | "Host": "music.163.com", 280 | "Referer": "http://music.163.com", 281 | "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 11_1_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.87 Safari/537.36", 282 | } 283 | 284 | self.storage = Storage() 285 | cookie_jar = MozillaCookieJar(self.storage.cookie_path) 286 | cookie_jar.load() 287 | self.session = requests.Session() 288 | self.session.cookies = cookie_jar 289 | for cookie in cookie_jar: 290 | if cookie.is_expired(): 291 | cookie_jar.clear() 292 | self.storage.database["user"] = { 293 | "username": "", 294 | "password": "", 295 | "user_id": "", 296 | "nickname": "", 297 | } 298 | self.storage.save() 299 | break 300 | 301 | @property 302 | def toplists(self): 303 | return [item[0] for item in TOP_LIST_ALL.values()] 304 | 305 | def logout(self): 306 | self.session.cookies.clear() 307 | self.storage.database["user"] = { 308 | "username": "", 309 | "password": "", 310 | "user_id": "", 311 | "nickname": "", 312 | } 313 | self.session.cookies.save() 314 | self.storage.save() 315 | 316 | def _raw_request(self, method, endpoint, data=None): 317 | resp = None 318 | if method == "GET": 319 | resp = self.session.get( 320 | endpoint, params=data, headers=self.header, timeout=DEFAULT_TIMEOUT 321 | ) 322 | elif method == "POST": 323 | resp = self.session.post( 324 | endpoint, data=data, headers=self.header, timeout=DEFAULT_TIMEOUT 325 | ) 326 | return resp 327 | 328 | # 生成Cookie对象 329 | def make_cookie(self, name, value): 330 | return Cookie( 331 | version=0, 332 | name=name, 333 | value=value, 334 | port=None, 335 | port_specified=False, 336 | domain="music.163.com", 337 | domain_specified=True, 338 | domain_initial_dot=False, 339 | path="/", 340 | path_specified=True, 341 | secure=False, 342 | expires=None, 343 | discard=False, 344 | comment=None, 345 | comment_url=None, 346 | rest={}, 347 | ) 348 | 349 | def request(self, method, path, params={}, default={"code": -1}, custom_cookies={}): 350 | endpoint = "{}{}".format(BASE_URL, path) 351 | csrf_token = "" 352 | for cookie in self.session.cookies: 353 | if cookie.name == "__csrf": 354 | csrf_token = cookie.value 355 | break 356 | params.update({"csrf_token": csrf_token}) 357 | data = default 358 | 359 | for key, value in custom_cookies.items(): 360 | cookie = self.make_cookie(key, value) 361 | self.session.cookies.set_cookie(cookie) 362 | 363 | params = encrypted_request(params) 364 | resp = None 365 | try: 366 | resp = self._raw_request(method, endpoint, params) 367 | data = resp.json() 368 | except requests.exceptions.RequestException as e: 369 | log.error(e) 370 | except ValueError: 371 | log.error("Path: {}, response: {}".format(path, resp.text[:200])) 372 | finally: 373 | return data 374 | 375 | def login(self, username, password): 376 | self.session.cookies.load() 377 | if username.isdigit(): 378 | path = "/weapi/login/cellphone" 379 | params = dict( 380 | phone=username, 381 | password=password, 382 | countrycode="86", 383 | rememberLogin="true", 384 | ) 385 | else: 386 | path = "/weapi/login" 387 | params = dict( 388 | username=username, 389 | password=password, 390 | rememberLogin="true", 391 | ) 392 | data = self.request("POST", path, params, custom_cookies={"os": "pc"}) 393 | self.session.cookies.save() 394 | return data 395 | 396 | # 每日签到 397 | def daily_task(self, is_mobile=True): 398 | path = "/weapi/point/dailyTask" 399 | params = dict(type=0 if is_mobile else 1) 400 | return self.request("POST", path, params) 401 | 402 | # 用户歌单 403 | def user_playlist(self, uid, offset=0, limit=50): 404 | path = "/weapi/user/playlist" 405 | params = dict(uid=uid, offset=offset, limit=limit) 406 | return self.request("POST", path, params).get("playlist", []) 407 | 408 | # 每日推荐歌单 409 | def recommend_resource(self): 410 | path = "/weapi/v1/discovery/recommend/resource" 411 | return self.request("POST", path).get("recommend", []) 412 | 413 | # 每日推荐歌曲 414 | def recommend_playlist(self, total=True, offset=0, limit=20): 415 | path = "/weapi/v1/discovery/recommend/songs" 416 | params = dict(total=total, offset=offset, limit=limit) 417 | return self.request("POST", path, params).get("recommend", []) 418 | 419 | # 私人FM 420 | def personal_fm(self): 421 | path = "/weapi/v1/radio/get" 422 | return self.request("POST", path).get("data", []) 423 | 424 | # like 425 | def fm_like(self, songid, like=True, time=25, alg="itembased"): 426 | path = "/weapi/radio/like" 427 | params = dict( 428 | alg=alg, trackId=songid, like="true" if like else "false", time=time 429 | ) 430 | return self.request("POST", path, params)["code"] == 200 431 | 432 | # FM trash 433 | def fm_trash(self, songid, time=25, alg="RT"): 434 | path = "/weapi/radio/trash/add" 435 | params = dict(songId=songid, alg=alg, time=time) 436 | return self.request("POST", path, params)["code"] == 200 437 | 438 | # 搜索单曲(1),歌手(100),专辑(10),歌单(1000),用户(1002) *(type)* 439 | def search(self, keywords, stype=1, offset=0, total="true", limit=50): 440 | path = "/weapi/search/get" 441 | params = dict(s=keywords, type=stype, offset=offset, total=total, limit=limit) 442 | return self.request("POST", path, params).get("result", {}) 443 | 444 | # 新碟上架 445 | def new_albums(self, offset=0, limit=50): 446 | path = "/weapi/album/new" 447 | params = dict(area="ALL", offset=offset, total=True, limit=limit) 448 | return self.request("POST", path, params).get("albums", []) 449 | 450 | # 歌单(网友精选碟) hot||new http://music.163.com/#/discover/playlist/ 451 | def top_playlists(self, category="全部", order="hot", offset=0, limit=50): 452 | path = "/weapi/playlist/list" 453 | params = dict( 454 | cat=category, order=order, offset=offset, total="true", limit=limit 455 | ) 456 | return self.request("POST", path, params).get("playlists", []) 457 | 458 | def playlist_catelogs(self): 459 | path = "/weapi/playlist/catalogue" 460 | return self.request("POST", path) 461 | 462 | # 歌单详情 463 | def playlist_songlist(self, playlist_id): 464 | path = "/weapi/v3/playlist/detail" 465 | params = dict(id=playlist_id, total="true", limit=1000, n=1000, offest=0) 466 | # cookie添加os字段 467 | custom_cookies = dict(os=platform.system()) 468 | return ( 469 | self.request("POST", path, params, {"code": -1}, custom_cookies) 470 | .get("playlist", {}) 471 | .get("trackIds", []) 472 | ) 473 | 474 | # 热门歌手 http://music.163.com/#/discover/artist/ 475 | def top_artists(self, offset=0, limit=100): 476 | path = "/weapi/artist/top" 477 | params = dict(offset=offset, total=True, limit=limit) 478 | return self.request("POST", path, params).get("artists", []) 479 | 480 | # 热门单曲 http://music.163.com/discover/toplist?id= 481 | def top_songlist(self, idx=0, offset=0, limit=100): 482 | playlist_id = TOP_LIST_ALL[idx][1] 483 | return self.playlist_songlist(playlist_id) 484 | 485 | # 歌手单曲 486 | def artists(self, artist_id): 487 | path = "/weapi/v1/artist/{}".format(artist_id) 488 | return self.request("POST", path).get("hotSongs", []) 489 | 490 | def get_artist_album(self, artist_id, offset=0, limit=50): 491 | path = "/weapi/artist/albums/{}".format(artist_id) 492 | params = dict(offset=offset, total=True, limit=limit) 493 | return self.request("POST", path, params).get("hotAlbums", []) 494 | 495 | # album id --> song id set 496 | def album(self, album_id): 497 | path = "/weapi/v1/album/{}".format(album_id) 498 | return self.request("POST", path).get("songs", []) 499 | 500 | def song_comments(self, music_id, offset=0, total="false", limit=100): 501 | path = "/weapi/v1/resource/comments/R_SO_4_{}/".format(music_id) 502 | params = dict(rid=music_id, offset=offset, total=total, limit=limit) 503 | return self.request("POST", path, params) 504 | 505 | # song ids --> song urls ( details ) 506 | def songs_detail(self, ids): 507 | path = "/weapi/v3/song/detail" 508 | params = dict(c=json.dumps([{"id": _id} for _id in ids]), ids=json.dumps(ids)) 509 | return self.request("POST", path, params).get("songs", []) 510 | 511 | def songs_url(self, ids): 512 | quality = Config().get("music_quality") 513 | rate_map = {0: 320000, 1: 192000, 2: 128000} 514 | 515 | path = "/weapi/song/enhance/player/url" 516 | params = dict(ids=ids, br=rate_map[quality]) 517 | return self.request("POST", path, params).get("data", []) 518 | 519 | # lyric http://music.163.com/api/song/lyric?os=osx&id= &lv=-1&kv=-1&tv=-1 520 | def song_lyric(self, music_id): 521 | path = "/weapi/song/lyric" 522 | params = dict(os="osx", id=music_id, lv=-1, kv=-1, tv=-1) 523 | lyric = self.request("POST", path, params).get("lrc", {}).get("lyric", []) 524 | if not lyric: 525 | return [] 526 | else: 527 | return lyric.split("\n") 528 | 529 | def song_tlyric(self, music_id): 530 | path = "/weapi/song/lyric" 531 | params = dict(os="osx", id=music_id, lv=-1, kv=-1, tv=-1) 532 | lyric = self.request("POST", path, params).get("tlyric", {}).get("lyric", []) 533 | if not lyric: 534 | return [] 535 | else: 536 | return lyric.split("\n") 537 | 538 | # 今日最热(0), 本周最热(10),历史最热(20),最新节目(30) 539 | def djRadios(self, offset=0, limit=50): 540 | path = "/weapi/djradio/hot/v1" 541 | params = dict(limit=limit, offset=offset) 542 | return self.request("POST", path, params).get("djRadios", []) 543 | 544 | def djprograms(self, radio_id, asc=False, offset=0, limit=50): 545 | path = "/weapi/dj/program/byradio" 546 | params = dict(asc=asc, radioId=radio_id, offset=offset, limit=limit) 547 | programs = self.request("POST", path, params).get("programs", []) 548 | return [p["mainSong"] for p in programs] 549 | 550 | def alldjprograms(self, radio_id, asc=False, offset=0, limit=500): 551 | programs = [] 552 | ps = self.djprograms(radio_id, asc=asc, offset=offset, limit=limit) 553 | while ps: 554 | programs.extend(ps) 555 | offset += limit 556 | ps = self.djprograms(radio_id, asc=asc, offset=offset, limit=limit) 557 | return programs 558 | 559 | # 获取版本 560 | def get_version(self): 561 | action = "https://pypi.org/pypi/NetEase-MusicBox/json" 562 | try: 563 | return requests.get(action).json() 564 | except requests.exceptions.RequestException as e: 565 | log.error(e) 566 | return {} 567 | 568 | def dig_info(self, data, dig_type): 569 | if not data: 570 | return [] 571 | if dig_type == "songs" or dig_type == "fmsongs" or dig_type == "djprograms": 572 | sids = [x["id"] for x in data] 573 | # 可能因网络波动,返回空值,在Parse.songs中引发KeyError 574 | # 导致日志记录大量can't get song url的可能原因 575 | urls = [] 576 | for i in range(0, len(sids), 350): 577 | urls.extend(self.songs_url(sids[i : i + 350])) 578 | # songs_detail api会返回空的电台歌名,故使用原数据 579 | sds = [] 580 | if dig_type == "djprograms": 581 | sds.extend(data) 582 | # 支持超过1000首歌曲的歌单 583 | else: 584 | for i in range(0, len(sids), 500): 585 | sds.extend(self.songs_detail(sids[i : i + 500])) 586 | # api 返回的 urls 的 id 顺序和 data 的 id 顺序不一致 587 | # 为了获取到对应 id 的 url,对返回的 urls 做一个 id2index 的缓存 588 | # 同时保证 data 的 id 顺序不变 589 | url_id_index = {} 590 | for index, url in enumerate(urls): 591 | url_id_index[url["id"]] = index 592 | 593 | timestamp = time.time() 594 | for s in sds: 595 | url_index = url_id_index.get(s["id"]) 596 | if url_index is None: 597 | log.error("can't get song url, id: %s", s["id"]) 598 | return [] 599 | s["url"] = urls[url_index]["url"] 600 | s["br"] = urls[url_index]["br"] 601 | s["expires"] = urls[url_index]["expi"] 602 | s["get_time"] = timestamp 603 | return Parse.songs(sds) 604 | 605 | elif dig_type == "refresh_urls": 606 | urls_info = [] 607 | for i in range(0, len(data), 350): 608 | urls_info.extend(self.songs_url(data[i : i + 350])) 609 | timestamp = time.time() 610 | 611 | songs = [] 612 | for url_info in urls_info: 613 | song = {} 614 | song["song_id"] = url_info["id"] 615 | song["mp3_url"] = url_info["url"] 616 | song["expires"] = url_info["expi"] 617 | song["get_time"] = timestamp 618 | songs.append(song) 619 | return songs 620 | 621 | elif dig_type == "artists": 622 | return Parse.artists(data) 623 | 624 | elif dig_type == "albums": 625 | return Parse.albums(data) 626 | 627 | elif dig_type == "playlists" or dig_type == "top_playlists": 628 | return Parse.playlists(data) 629 | 630 | elif dig_type == "playlist_classes": 631 | return list(PLAYLIST_CLASSES.keys()) 632 | 633 | elif dig_type == "playlist_class_detail": 634 | return PLAYLIST_CLASSES[data] 635 | 636 | elif dig_type == "djRadios": 637 | return data 638 | else: 639 | raise ValueError("Invalid dig type") 640 | -------------------------------------------------------------------------------- /NEMbox/cache.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # @Author: Catofes 3 | # @Date: 2015-08-15 4 | """ 5 | Class to cache songs into local storage. 6 | """ 7 | import os 8 | import signal 9 | import subprocess 10 | import threading 11 | 12 | from . import logger 13 | from .api import NetEase 14 | from .config import Config 15 | from .const import Constant 16 | from .singleton import Singleton 17 | 18 | log = logger.getLogger(__name__) 19 | 20 | 21 | class Cache(Singleton): 22 | def __init__(self): 23 | if hasattr(self, "_init"): 24 | return 25 | self._init = True 26 | 27 | self.const = Constant() 28 | self.config = Config() 29 | self.download_lock = threading.Lock() 30 | self.check_lock = threading.Lock() 31 | self.downloading = [] 32 | self.aria2c = None 33 | self.wget = None 34 | self.stop = False 35 | self.enable = self.config.get("cache") 36 | self.aria2c_parameters = self.config.get("aria2c_parameters") 37 | 38 | def _is_cache_successful(self): 39 | def succ(x): 40 | return x and x.returncode == 0 41 | 42 | return succ(self.aria2c) or succ(self.wget) 43 | 44 | def _kill_all(self): 45 | def _kill(p): 46 | if p: 47 | os.kill(p.pid, signal.SIGKILL) 48 | 49 | _kill(self.aria2c) 50 | _kill(self.wget) 51 | 52 | def start_download(self): 53 | check = self.download_lock.acquire(False) 54 | if not check: 55 | return False 56 | while True: 57 | if self.stop: 58 | break 59 | if not self.enable: 60 | break 61 | self.check_lock.acquire() 62 | if len(self.downloading) <= 0: 63 | self.check_lock.release() 64 | break 65 | data = self.downloading.pop() 66 | self.check_lock.release() 67 | song_id = data[0] 68 | song_name = data[1] 69 | artist = data[2] 70 | url = data[3] 71 | onExit = data[4] 72 | output_path = Constant.download_dir 73 | output_file = str(artist) + " - " + str(song_name) + ".mp3" 74 | full_path = os.path.join(output_path, output_file) 75 | 76 | new_url = NetEase().songs_url([song_id])[0]["url"] 77 | if new_url: 78 | log.info("Old:{}. New:{}".format(url, new_url)) 79 | try: 80 | para = [ 81 | "aria2c", 82 | "--auto-file-renaming=false", 83 | "--allow-overwrite=true", 84 | "-d", 85 | output_path, 86 | "-o", 87 | output_file, 88 | new_url, 89 | ] 90 | para.extend(self.aria2c_parameters) 91 | log.debug(para) 92 | self.aria2c = subprocess.Popen( 93 | para, 94 | stdin=subprocess.PIPE, 95 | stdout=subprocess.PIPE, 96 | stderr=subprocess.PIPE, 97 | ) 98 | self.aria2c.wait() 99 | except OSError as e: 100 | log.warning( 101 | "{}.\tAria2c is unavailable, fall back to wget".format(e) 102 | ) 103 | 104 | para = ["wget", "-O", full_path, new_url] 105 | self.wget = subprocess.Popen( 106 | para, 107 | stdin=subprocess.PIPE, 108 | stdout=subprocess.PIPE, 109 | stderr=subprocess.PIPE, 110 | ) 111 | self.wget.wait() 112 | 113 | if self._is_cache_successful(): 114 | log.debug(str(song_id) + " Cache OK") 115 | onExit(song_id, full_path) 116 | self.download_lock.release() 117 | 118 | def add(self, song_id, song_name, artist, url, onExit): 119 | self.check_lock.acquire() 120 | self.downloading.append([song_id, song_name, artist, url, onExit]) 121 | self.check_lock.release() 122 | 123 | def quit(self): 124 | self.stop = True 125 | try: 126 | self._kill_all() 127 | except (AttributeError, OSError) as e: 128 | log.error(e) 129 | pass 130 | -------------------------------------------------------------------------------- /NEMbox/cmd_parser.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # coding=utf-8 3 | # __author__='walker' 4 | """ 5 | 捕获类似curses键盘输入流,生成指令流 6 | """ 7 | import curses 8 | from copy import deepcopy 9 | from functools import wraps 10 | 11 | from .config import Config 12 | 13 | ERASE_SPEED = 5 # 屏幕5秒刷新一次 去除错误的显示 14 | 15 | __all__ = ["cmd_parser", "parse_keylist", "coroutine", "erase_coroutine"] 16 | 17 | KEY_MAP = Config().get("keymap") 18 | 19 | 20 | def coroutine(func): 21 | @wraps(func) 22 | def primer(*args, **kwargs): 23 | gen = func(*args, **kwargs) 24 | next(gen) 25 | return gen 26 | 27 | return primer 28 | 29 | 30 | def _cmd_parser(): 31 | """ 32 | A generator receive key value typed by user return constant keylist. 33 | 输入键盘输入流,输出指令流,以curses默认-1为信号终止. 34 | """ 35 | pre_key = -1 36 | keylist = [] 37 | while 1: 38 | key = yield 39 | if key > 0 and pre_key == -1: 40 | keylist.append(key) 41 | elif key > 0 and pre_key > 0: 42 | keylist.append(key) 43 | elif curses.keyname(key).decode("utf-8") in KEY_MAP.values() and pre_key > 0: 44 | keylist.append(key) 45 | return keylist 46 | pre_key = key 47 | 48 | 49 | def cmd_parser(results): 50 | """ 51 | A generator manager which can catch StopIteration and start a new Generator. 52 | 生成器管理对象,可以优雅地屏蔽生成器的终止信号,并重启生成器 53 | """ 54 | while 1: 55 | results.clear() 56 | results += yield from _cmd_parser() 57 | yield results 58 | 59 | 60 | def _erase_coroutine(): 61 | keylist = [] 62 | while 1: 63 | key = yield 64 | keylist.append(key) 65 | if len(set(keylist)) > 1: 66 | return keylist 67 | elif len(keylist) >= ERASE_SPEED * 2: 68 | return keylist 69 | 70 | 71 | def erase_coroutine(erase_cmd_list): 72 | while 1: 73 | erase_cmd_list.clear() 74 | erase_cmd_list += yield from _erase_coroutine() 75 | yield erase_cmd_list 76 | 77 | 78 | def parse_keylist(keylist): 79 | """ 80 | '2' '3' '4' 'j' ----> 234 j 81 | supoort keys [ ] j k 82 | """ 83 | keylist = deepcopy(keylist) 84 | if keylist == []: 85 | return None 86 | if (set(keylist) | {ord(KEY_MAP["prevSong"]), ord(KEY_MAP["nextSong"])}) == { 87 | ord(KEY_MAP["prevSong"]), 88 | ord(KEY_MAP["nextSong"]), 89 | }: 90 | delta_key = keylist.count(ord(KEY_MAP["nextSong"])) - keylist.count( 91 | ord(KEY_MAP["prevSong"]) 92 | ) 93 | if delta_key < 0: 94 | return (-delta_key, ord(KEY_MAP["prevSong"])) 95 | return (delta_key, ord(KEY_MAP["nextSong"])) 96 | tail_cmd = keylist.pop() 97 | if tail_cmd in range(48, 58) and (set(keylist) | set(range(48, 58))) == set( 98 | range(48, 58) 99 | ): 100 | return int("".join([chr(i) for i in keylist] + [chr(tail_cmd)])) 101 | 102 | if len(keylist) == 0: 103 | return (0, tail_cmd) 104 | if ( 105 | tail_cmd 106 | in ( 107 | ord(KEY_MAP["prevSong"]), 108 | ord(KEY_MAP["nextSong"]), 109 | ord(KEY_MAP["down"]), 110 | ord(KEY_MAP["up"]), 111 | ) 112 | and max(keylist) <= 57 113 | and min(keylist) >= 48 114 | ): 115 | return (int("".join([chr(i) for i in keylist])), tail_cmd) 116 | return None 117 | 118 | 119 | def main(data): 120 | """ 121 | tset code 122 | 测试代码 123 | """ 124 | results = [] 125 | group = cmd_parser(results) 126 | next(group) 127 | for i in data: 128 | group.send(i) 129 | group.send(-1) 130 | print(results) 131 | next(group) 132 | for i in data: 133 | group.send(i) 134 | group.send(-1) 135 | print(results) 136 | x = _cmd_parser() 137 | print("-----------") 138 | print(x.send(None)) 139 | print(x.send(1)) 140 | print(x.send(2)) 141 | print(x.send(3)) 142 | print(x.send(3)) 143 | print(x.send(3)) 144 | try: 145 | print(x.send(-1)) 146 | except Exception as e: 147 | print(e.value) 148 | 149 | 150 | if __name__ == "__main__": 151 | main(list(range(1, 12)[::-1])) 152 | -------------------------------------------------------------------------------- /NEMbox/config.py: -------------------------------------------------------------------------------- 1 | # encoding: UTF-8 2 | import json 3 | import os 4 | 5 | from .const import Constant 6 | from .singleton import Singleton 7 | from .utils import utf8_data_to_file 8 | 9 | 10 | class Config(Singleton): 11 | def __init__(self): 12 | if hasattr(self, "_init"): 13 | return 14 | self._init = True 15 | 16 | self.path = Constant.config_path 17 | self.default_config = { 18 | "version": 8, 19 | "page_length": { 20 | "value": 10, 21 | "default": 10, 22 | "describe": ( 23 | "Entries each page has. " "Set 0 to adjust automatically." 24 | ), 25 | }, 26 | "cache": { 27 | "value": False, 28 | "default": False, 29 | "describe": ( 30 | "A toggle to enable cache function or not. " 31 | "Set value to true to enable it." 32 | ), 33 | }, 34 | "mpg123_parameters": { 35 | "value": [], 36 | "default": [], 37 | "describe": "The additional parameters when mpg123 start.", 38 | }, 39 | "aria2c_parameters": { 40 | "value": [], 41 | "default": [], 42 | "describe": ( 43 | "The additional parameters when " 44 | "aria2c start to download something." 45 | ), 46 | }, 47 | "music_quality": { 48 | "value": 0, 49 | "default": 0, 50 | "describe": ( 51 | "Select the quality of the music. " 52 | "May be useful when network is terrible. " 53 | "0 for high quality, 1 for medium and 2 for low." 54 | ), 55 | }, 56 | "global_play_pause": { 57 | "value": "p", 58 | "default": "p", 59 | "describe": "Global keybind for play/pause." 60 | "Uses gtk notation for keybinds.", 61 | }, 62 | "global_next": { 63 | "value": "j", 64 | "default": "j", 65 | "describe": "Global keybind for next song." 66 | "Uses gtk notation for keybinds.", 67 | }, 68 | "global_previous": { 69 | "value": "k", 70 | "default": "k", 71 | "describe": "Global keybind for previous song." 72 | "Uses gtk notation for keybinds.", 73 | }, 74 | "notifier": { 75 | "value": True, 76 | "default": True, 77 | "describe": "Notifier when switching songs.", 78 | }, 79 | "translation": { 80 | "value": True, 81 | "default": True, 82 | "describe": "Foreign language lyrics translation.", 83 | }, 84 | "osdlyrics": { 85 | "value": False, 86 | "default": False, 87 | "describe": "Desktop lyrics for musicbox.", 88 | }, 89 | "osdlyrics_transparent": { 90 | "value": False, 91 | "default": False, 92 | "describe": "Desktop lyrics transparent bg.", 93 | }, 94 | "osdlyrics_color": { 95 | "value": [225, 248, 113], 96 | "default": [225, 248, 113], 97 | "describe": "Desktop lyrics RGB Color.", 98 | }, 99 | "osdlyrics_size": { 100 | "value": [600, 60], 101 | "default": [600, 60], 102 | "describe": "Desktop lyrics area size.", 103 | }, 104 | "osdlyrics_font": { 105 | "value": ["Decorative", 16], 106 | "default": ["Decorative", 16], 107 | "describe": "Desktop lyrics font-family and font-size.", 108 | }, 109 | "osdlyrics_background": { 110 | "value": "rgba(100, 100, 100, 120)", 111 | "default": "rgba(100, 100, 100, 120)", 112 | "describe": "Desktop lyrics background color.", 113 | }, 114 | "osdlyrics_on_top": { 115 | "value": True, 116 | "default": True, 117 | "describe": "Desktop lyrics OnTopHint.", 118 | }, 119 | "curses_transparency": { 120 | "value": False, 121 | "default": False, 122 | "describe": "Set true to make curses transparency.", 123 | }, 124 | "left_margin_ratio": { 125 | "value": 5, 126 | "default": 5, 127 | "describe": ( 128 | "Controls the ratio between width and left margin." 129 | "Set to 0 to minimize the margin." 130 | ), 131 | }, 132 | "right_margin_ratio": { 133 | "value": 5, 134 | "default": 5, 135 | "describe": ( 136 | "Controls the ratio between width and right margin." 137 | "Set to 0 to minimize the margin." 138 | ), 139 | }, 140 | "mouse_movement": { 141 | "value": False, 142 | "default": False, 143 | "describe": "Use mouse or touchpad to move.", 144 | }, 145 | "input_timeout": { 146 | "value": 500, 147 | "default": 500, 148 | "describe": "The time wait for the next key.", 149 | }, 150 | "colors": { 151 | "value": { 152 | "pair1": [22, 148], 153 | "pair2": [231, 24], 154 | "pair3": [231, 9], 155 | "pair4": [231, 14], 156 | "pair5": [231, 237], 157 | }, 158 | "default": { 159 | "pair1": [22, 148], 160 | "pair2": [231, 24], 161 | "pair3": [231, 9], 162 | "pair4": [231, 14], 163 | "pair5": [231, 237], 164 | }, 165 | "describe": "xterm-256color theme.", 166 | }, 167 | "keymap": { 168 | "value": { 169 | "down": "j", 170 | "up": "k", 171 | "back": "h", 172 | "forward": "l", 173 | "prevPage": "u", 174 | "nextPage": "d", 175 | "search": "f", 176 | "prevSong": "[", 177 | "nextSong": "]", 178 | "jumpIndex": "G", 179 | "playPause": " ", 180 | "shuffle": "?", 181 | "volume+": "+", 182 | "volume-": "-", 183 | "menu": "m", 184 | "presentHistory": "p", 185 | "musicInfo": "i", 186 | "playingMode": "P", 187 | "enterAlbum": "A", 188 | "add": "a", 189 | "djList": "z", 190 | "star": "s", 191 | "collection": "c", 192 | "remove": "r", 193 | "moveDown": "J", 194 | "moveUp": "K", 195 | "like": ",", 196 | "cache": "C", 197 | "trashFM": ".", 198 | "nextFM": "/", 199 | "quit": "q", 200 | "quitClear": "w", 201 | "help": "y", 202 | "top": "g", 203 | "bottom": "G", 204 | "countDown": "t", 205 | }, 206 | "describe": "Keys and function.", 207 | }, 208 | } 209 | self.config = {} 210 | if not os.path.isfile(self.path): 211 | self.generate_config_file() 212 | 213 | with open(self.path, "r") as config_file: 214 | try: 215 | self.config = json.load(config_file) 216 | except ValueError: 217 | self.generate_config_file() 218 | 219 | def generate_config_file(self): 220 | with open(self.path, "w") as config_file: 221 | utf8_data_to_file(config_file, json.dumps(self.default_config, indent=2)) 222 | 223 | def save_config_file(self): 224 | with open(self.path, "w") as config_file: 225 | utf8_data_to_file(config_file, json.dumps(self.config, indent=2)) 226 | 227 | def get(self, name): 228 | if name not in self.config.keys(): 229 | self.config[name] = self.default_config[name] 230 | return self.default_config[name]["value"] 231 | return self.config[name]["value"] 232 | -------------------------------------------------------------------------------- /NEMbox/const.py: -------------------------------------------------------------------------------- 1 | # encoding: UTF-8 2 | # KenHuang: 使配置文件夹符合XDG标准 3 | import os 4 | 5 | 6 | class Constant(object): 7 | if "XDG_CONFIG_HOME" in os.environ: 8 | conf_dir = os.path.join(os.environ["XDG_CONFIG_HOME"], "netease-musicbox") 9 | else: 10 | conf_dir = os.path.join(os.path.expanduser("~"), ".netease-musicbox") 11 | config_path = os.path.join(conf_dir, "config.json") 12 | if "XDG_CACHE_HOME" in os.environ: 13 | cacheDir = os.path.join(os.environ["XDG_CACHE_HOME"], "netease-musicbox") 14 | if not os.path.exists(cacheDir): 15 | os.mkdir(cacheDir) 16 | download_dir = os.path.join(cacheDir, "cached") 17 | cache_path = os.path.join(cacheDir, "nemcache") 18 | else: 19 | download_dir = os.path.join(conf_dir, "cached") 20 | cache_path = os.path.join(conf_dir, "nemcache") 21 | if "XDG_DATA_HOME" in os.environ: 22 | dataDir = os.path.join(os.environ["XDG_DATA_HOME"], "netease-musicbox") 23 | if not os.path.exists(dataDir): 24 | os.mkdir(dataDir) 25 | cookie_path = os.path.join(dataDir, "cookie.txt") 26 | log_path = os.path.join(dataDir, "musicbox.log") 27 | storage_path = os.path.join(dataDir, "database.json") 28 | else: 29 | cookie_path = os.path.join(conf_dir, "cookie.txt") 30 | log_path = os.path.join(conf_dir, "musicbox.log") 31 | storage_path = os.path.join(conf_dir, "database.json") 32 | -------------------------------------------------------------------------------- /NEMbox/encrypt.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | import base64 4 | import binascii 5 | import hashlib 6 | import json 7 | import os 8 | 9 | from Cryptodome.Cipher import AES 10 | 11 | __all__ = ["encrypted_id", "encrypted_request"] 12 | 13 | MODULUS = ( 14 | "00e0b509f6259df8642dbc35662901477df22677ec152b5ff68ace615bb7" 15 | "b725152b3ab17a876aea8a5aa76d2e417629ec4ee341f56135fccf695280" 16 | "104e0312ecbda92557c93870114af6c9d05c4f7f0c3685b7a46bee255932" 17 | "575cce10b424d813cfe4875d3e82047b97ddef52741d546b8e289dc6935b" 18 | "3ece0462db0a22b8e7" 19 | ) 20 | PUBKEY = "010001" 21 | NONCE = b"0CoJUm6Qyw8W8jud" 22 | 23 | 24 | # 歌曲加密算法, 基于https://github.com/yanunon/NeteaseCloudMusic 25 | def encrypted_id(id): 26 | magic = bytearray("3go8&$8*3*3h0k(2)2", "u8") 27 | song_id = bytearray(id, "u8") 28 | magic_len = len(magic) 29 | for i, sid in enumerate(song_id): 30 | song_id[i] = sid ^ magic[i % magic_len] 31 | m = hashlib.md5(song_id) 32 | result = m.digest() 33 | result = base64.b64encode(result).replace(b"/", b"_").replace(b"+", b"-") 34 | return result.decode("utf-8") 35 | 36 | 37 | # 登录加密算法, 基于https://github.com/stkevintan/nw_musicbox 38 | def encrypted_request(text): 39 | # type: (str) -> dict 40 | data = json.dumps(text).encode("utf-8") 41 | secret = create_key(16) 42 | params = aes(aes(data, NONCE), secret) 43 | encseckey = rsa(secret, PUBKEY, MODULUS) 44 | return {"params": params, "encSecKey": encseckey} 45 | 46 | 47 | def aes(text, key): 48 | pad = 16 - len(text) % 16 49 | text = text + bytearray([pad] * pad) 50 | encryptor = AES.new(key, 2, b"0102030405060708") 51 | ciphertext = encryptor.encrypt(text) 52 | return base64.b64encode(ciphertext) 53 | 54 | 55 | def rsa(text, pubkey, modulus): 56 | text = text[::-1] 57 | rs = pow(int(binascii.hexlify(text), 16), int(pubkey, 16), int(modulus, 16)) 58 | return format(rs, "x").zfill(256) 59 | 60 | 61 | def create_key(size): 62 | return binascii.hexlify(os.urandom(size))[:16] 63 | -------------------------------------------------------------------------------- /NEMbox/kill_thread.py: -------------------------------------------------------------------------------- 1 | import ctypes 2 | import inspect 3 | import threading 4 | import time 5 | 6 | __all__ = ["stop_thread"] 7 | 8 | 9 | def _async_raise(tid, exctype): 10 | """raises the exception, performs cleanup if needed""" 11 | tid = ctypes.c_long(tid) 12 | if not inspect.isclass(exctype): 13 | exctype = type(exctype) 14 | res = ctypes.pythonapi.PyThreadState_SetAsyncExc(tid, ctypes.py_object(exctype)) 15 | if res == 0: 16 | raise ValueError("invalid thread id") 17 | elif res != 1: 18 | # """if it returns a number greater than one, you're in trouble, 19 | # and you should call it again with exc=NULL to revert the effect""" 20 | ctypes.pythonapi.PyThreadState_SetAsyncExc(tid, None) 21 | raise SystemError("PyThreadState_SetAsyncExc failed") 22 | 23 | 24 | def stop_thread(thread): 25 | _async_raise(thread.ident, SystemExit) 26 | 27 | 28 | def test(): 29 | while True: 30 | print("-------") 31 | time.sleep(0.5) 32 | 33 | 34 | if __name__ == "__main__": 35 | t = threading.Thread(target=test) 36 | t.start() 37 | time.sleep(5.2) 38 | print("main thread sleep finish") 39 | stop_thread(t) 40 | -------------------------------------------------------------------------------- /NEMbox/logger.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # @Author: omi 4 | # @Date: 2014-08-24 21:51:57 5 | import logging 6 | 7 | from . import const 8 | 9 | FILE_NAME = const.Constant.log_path 10 | 11 | 12 | with open(FILE_NAME, "a+") as f: 13 | f.write("#" * 80) 14 | f.write("\n") 15 | 16 | 17 | def getLogger(name): 18 | log = logging.getLogger(name) 19 | log.setLevel(logging.DEBUG) 20 | 21 | # File output handler 22 | fh = logging.FileHandler(FILE_NAME) 23 | fh.setLevel(logging.DEBUG) 24 | fh.setFormatter( 25 | logging.Formatter( 26 | "%(asctime)s - %(levelname)s - %(name)s:%(lineno)s: %(message)s" 27 | ) 28 | ) 29 | log.addHandler(fh) 30 | 31 | return log 32 | -------------------------------------------------------------------------------- /NEMbox/menu.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # @Author: omi 4 | # @Date: 2014-08-24 21:51:57 5 | # KenHuang: 6 | # 1.增加按键映射功能; 7 | # 2.修复搜索按键功能映射错误; 8 | # 3.使用定时器实现自动关闭功能; 9 | """ 10 | 网易云音乐 Menu 11 | """ 12 | import curses as C 13 | import locale 14 | import os 15 | import signal 16 | import sys 17 | import threading 18 | import time 19 | import webbrowser 20 | from collections import namedtuple 21 | from copy import deepcopy 22 | from threading import Timer 23 | 24 | from fuzzywuzzy import process 25 | 26 | from . import logger 27 | from .api import NetEase 28 | from .cache import Cache 29 | from .cmd_parser import cmd_parser 30 | from .cmd_parser import erase_coroutine 31 | from .cmd_parser import parse_keylist 32 | from .config import Config 33 | from .osdlyrics import pyqt_activity 34 | from .osdlyrics import show_lyrics_new_process 35 | from .osdlyrics import stop_lyrics_process 36 | from .player import Player 37 | from .storage import Storage 38 | from .ui import Ui 39 | from .utils import notify 40 | 41 | 42 | locale.setlocale(locale.LC_ALL, "") 43 | 44 | log = logger.getLogger(__name__) 45 | 46 | 47 | def carousel(left, right, x): 48 | # carousel x in [left, right] 49 | if x > right: 50 | return left 51 | elif x < left: 52 | return right 53 | else: 54 | return x 55 | 56 | 57 | KEY_MAP = Config().get("keymap") 58 | COMMAND_LIST = list(map(ord, KEY_MAP.values())) 59 | 60 | if Config().get("mouse_movement"): 61 | KEY_MAP["mouseUp"] = 259 62 | KEY_MAP["mouseDown"] = 258 63 | else: 64 | KEY_MAP["mouseUp"] = -259 65 | KEY_MAP["mouseDown"] = -258 66 | 67 | shortcut = [ 68 | [KEY_MAP["down"], "Down", "下移"], 69 | [KEY_MAP["up"], "Up", "上移"], 70 | ["+" + KEY_MAP["up"], " Up", "上移num"], 71 | ["+" + KEY_MAP["down"], " Down", "下移num"], 72 | [KEY_MAP["back"], "Back", "后退"], 73 | [KEY_MAP["forward"], "Forward", "前进"], 74 | [KEY_MAP["prevPage"], "Prev page", "上一页"], 75 | [KEY_MAP["nextPage"], "Next page", "下一页"], 76 | [KEY_MAP["search"], "Search", "快速搜索"], 77 | [KEY_MAP["prevSong"], "Prev song", "上一曲"], 78 | [KEY_MAP["nextSong"], "Next song", "下一曲"], 79 | ["+" + KEY_MAP["nextSong"], " Next Song", "下num曲"], 80 | ["+" + KEY_MAP["prevSong"], " Prev song", "上num曲"], 81 | ["", "Goto song ", "跳转指定歌曲id"], 82 | [KEY_MAP["playPause"], "Play/Pause", "播放/暂停"], 83 | [KEY_MAP["shuffle"], "Shuffle", "手气不错"], 84 | [KEY_MAP["volume+"], "Volume+", "音量增加"], 85 | [KEY_MAP["volume-"], "Volume-", "音量减少"], 86 | [KEY_MAP["menu"], "Menu", "主菜单"], 87 | [KEY_MAP["presentHistory"], "Present/History", "当前/历史播放列表"], 88 | [KEY_MAP["musicInfo"], "Music Info", "当前音乐信息"], 89 | [KEY_MAP["playingMode"], "Playing Mode", "播放模式切换"], 90 | [KEY_MAP["enterAlbum"], "Enter album", "进入专辑"], 91 | [KEY_MAP["add"], "Add", "添加曲目到打碟"], 92 | [KEY_MAP["djList"], "DJ list", "打碟列表(退出后清空)"], 93 | [KEY_MAP["star"], "Star", "添加到本地收藏"], 94 | [KEY_MAP["collection"], "Collection", "本地收藏列表"], 95 | [KEY_MAP["remove"], "Remove", "删除当前条目"], 96 | [KEY_MAP["moveDown"], "Move Down", "向下移动当前条目"], 97 | [KEY_MAP["moveUp"], "Move Up", "向上移动当前条目"], 98 | [KEY_MAP["like"], "Like", "喜爱"], 99 | [KEY_MAP["cache"], "Cache", "缓存歌曲到本地"], 100 | [KEY_MAP["nextFM"], "Next FM", "下一 FM"], 101 | [KEY_MAP["trashFM"], "Trash FM", "删除 FM"], 102 | [KEY_MAP["quit"], "Quit", "退出"], 103 | [KEY_MAP["quitClear"], "Quit&Clear", "退出并清除用户信息"], 104 | [KEY_MAP["help"], "Help", "帮助"], 105 | [KEY_MAP["top"], "Top", "回到顶部"], 106 | [KEY_MAP["bottom"], "Bottom", "跳转到底部"], 107 | [KEY_MAP["countDown"], "Count Down", "定时"], 108 | ] 109 | 110 | 111 | class Menu(object): 112 | def __init__(self): 113 | self.quit = False 114 | self.config = Config() 115 | self.datatype = "main" 116 | self.title = "网易云音乐" 117 | self.datalist = [ 118 | {"entry_name": "排行榜"}, 119 | {"entry_name": "艺术家"}, 120 | {"entry_name": "新碟上架"}, 121 | {"entry_name": "精选歌单"}, 122 | {"entry_name": "我的歌单"}, 123 | {"entry_name": "主播电台"}, 124 | {"entry_name": "每日推荐歌曲"}, 125 | {"entry_name": "每日推荐歌单"}, 126 | {"entry_name": "私人FM"}, 127 | {"entry_name": "搜索"}, 128 | {"entry_name": "帮助"}, 129 | ] 130 | self.offset = 0 131 | self.index = 0 132 | self.storage = Storage() 133 | self.storage.load() 134 | self.collection = self.storage.database["collections"] 135 | self.player = Player() 136 | self.player.playing_song_changed_callback = self.song_changed_callback 137 | self.cache = Cache() 138 | self.ui = Ui() 139 | self.api = NetEase() 140 | self.screen = C.initscr() 141 | self.screen.keypad(1) 142 | self.step = Config().get("page_length") 143 | if self.step == 0: 144 | self.step = max(int(self.ui.y * 4 / 5) - 10, 1) 145 | self.stack = [] 146 | self.djstack = [] 147 | self.at_playing_list = False 148 | self.enter_flag = True 149 | signal.signal(signal.SIGWINCH, self.change_term) 150 | signal.signal(signal.SIGINT, self.send_kill) 151 | signal.signal(signal.SIGTERM, self.send_kill) 152 | self.menu_starts = time.time() 153 | self.countdown_start = time.time() 154 | self.countdown = -1 155 | self.is_in_countdown = False 156 | self.timer = 0 157 | self.key_list = [] 158 | self.pre_keylist = [] 159 | self.parser = None 160 | self.at_search_result = False 161 | 162 | @property 163 | def user(self): 164 | return self.storage.database["user"] 165 | 166 | @property 167 | def account(self): 168 | return self.user["username"] 169 | 170 | @property 171 | def md5pass(self): 172 | return self.user["password"] 173 | 174 | @property 175 | def userid(self): 176 | return self.user["user_id"] 177 | 178 | @property 179 | def username(self): 180 | return self.user["nickname"] 181 | 182 | def login(self): 183 | if self.account and self.md5pass: 184 | account, md5pass = self.account, self.md5pass 185 | else: 186 | account, md5pass = self.ui.build_login() 187 | 188 | resp = self.api.login(account, md5pass) 189 | if resp["code"] == 200: 190 | userid = resp["account"]["id"] 191 | nickname = resp["profile"]["nickname"] 192 | self.storage.login(account, md5pass, userid, nickname) 193 | return True 194 | else: 195 | self.storage.logout() 196 | x = self.ui.build_login_error() 197 | if x >= 0 and C.keyname(x).decode("utf-8") != KEY_MAP["forward"]: 198 | return False 199 | return self.login() 200 | 201 | def in_place_search(self): 202 | self.ui.screen.timeout(-1) 203 | prompt = "模糊搜索:" 204 | keyword = self.ui.get_param(prompt) 205 | if not keyword: 206 | return [], "" 207 | if self.datalist == []: 208 | return [], keyword 209 | origin_index = 0 210 | for item in self.datalist: 211 | item["origin_index"] = origin_index 212 | origin_index += 1 213 | try: 214 | search_result = process.extract( 215 | keyword, self.datalist, limit=max(10, 2 * self.step) 216 | ) 217 | if not search_result: 218 | return search_result, keyword 219 | search_result.sort(key=lambda ele: ele[1], reverse=True) 220 | return (list(map(lambda ele: ele[0], search_result)), keyword) 221 | except Exception as e: 222 | log.warn(e) 223 | 224 | def search(self, category): 225 | self.ui.screen.timeout(-1) 226 | SearchArg = namedtuple("SearchArg", ["prompt", "api_type", "post_process"]) 227 | category_map = { 228 | "songs": SearchArg("搜索歌曲:", 1, lambda datalist: datalist), 229 | "albums": SearchArg("搜索专辑:", 10, lambda datalist: datalist), 230 | "artists": SearchArg("搜索艺术家:", 100, lambda datalist: datalist), 231 | "playlists": SearchArg("搜索网易精选集:", 1000, lambda datalist: datalist), 232 | "djRadios": SearchArg("搜索主播电台:", 1009, lambda datalist: datalist), 233 | } 234 | 235 | prompt, api_type, post_process = category_map[category] 236 | keyword = self.ui.get_param(prompt) 237 | if not keyword: 238 | return [] 239 | 240 | data = self.api.search(keyword, api_type) 241 | if not data: 242 | return data 243 | 244 | datalist = post_process(data.get(category, [])) 245 | return self.api.dig_info(datalist, category) 246 | 247 | def change_term(self, signum, frame): 248 | self.ui.screen.clear() 249 | self.ui.screen.refresh() 250 | 251 | def send_kill(self, signum, fram): 252 | if pyqt_activity: 253 | stop_lyrics_process() 254 | self.player.stop() 255 | self.cache.quit() 256 | self.storage.save() 257 | C.endwin() 258 | sys.exit() 259 | 260 | def update_alert(self, version): 261 | latest = Menu().check_version() 262 | if str(latest) > str(version) and latest != 0: 263 | notify("MusicBox Update == available", 1) 264 | time.sleep(0.5) 265 | notify( 266 | "NetEase-MusicBox installed version:" 267 | + version 268 | + "\nNetEase-MusicBox latest version:" 269 | + latest, 270 | 0, 271 | ) 272 | 273 | def check_version(self): 274 | # 检查更新 && 签到 275 | try: 276 | mobile = self.api.daily_task(is_mobile=True) 277 | pc = self.api.daily_task(is_mobile=False) 278 | 279 | if mobile["code"] == 200: 280 | notify("移动端签到成功", 1) 281 | if pc["code"] == 200: 282 | notify("PC端签到成功", 1) 283 | 284 | data = self.api.get_version() 285 | return data["info"]["version"] 286 | except KeyError: 287 | return 0 288 | 289 | def start_fork(self, version): 290 | pid = os.fork() 291 | if pid == 0: 292 | Menu().update_alert(version) 293 | else: 294 | Menu().start() 295 | 296 | def next_song(self): 297 | if self.player.is_empty: 298 | return 299 | self.player.next() 300 | 301 | def previous_song(self): 302 | if self.player.is_empty: 303 | return 304 | self.player.prev() 305 | 306 | def prev_key_event(self): 307 | self.player.prev_idx() 308 | 309 | def next_key_event(self): 310 | self.player.next_idx() 311 | 312 | def up_key_event(self): 313 | datalist = self.datalist 314 | offset = self.offset 315 | idx = self.index 316 | step = self.step 317 | if idx == offset: 318 | if offset == 0: 319 | return 320 | self.offset -= step 321 | # 移动光标到最后一列 322 | self.index = offset - 1 323 | else: 324 | self.index = carousel( 325 | offset, min(len(datalist), offset + step) - 1, idx - 1 326 | ) 327 | self.menu_starts = time.time() 328 | 329 | def down_key_event(self): 330 | datalist = self.datalist 331 | offset = self.offset 332 | idx = self.index 333 | step = self.step 334 | if idx == min(len(datalist), offset + step) - 1: 335 | if offset + step >= len(datalist): 336 | return 337 | self.offset += step 338 | # 移动光标到第一列 339 | self.index = offset + step 340 | else: 341 | self.index = carousel( 342 | offset, min(len(datalist), offset + step) - 1, idx + 1 343 | ) 344 | self.menu_starts = time.time() 345 | 346 | def space_key_event_in_search_result(self): 347 | origin_index = self.datalist[self.index]["origin_index"] 348 | (datatype, title, datalist, offset, index) = self.stack[-1] 349 | if datatype == "songs": 350 | self.player.new_player_list("songs", title, datalist, -1) 351 | self.player.end_callback = None 352 | self.player.play_or_pause(origin_index, self.at_playing_list) 353 | self.at_playing_list = False 354 | elif datatype == "djprograms": 355 | self.player.new_player_list("djprograms", title, datalist, -1) 356 | self.player.end_callback = None 357 | self.player.play_or_pause(origin_index, self.at_playing_list) 358 | self.at_playing_list = False 359 | elif datatype == "fmsongs": 360 | self.player.change_mode(0) 361 | self.player.new_player_list("fmsongs", title, datalist, -1) 362 | self.player.end_callback = self.fm_callback 363 | self.player.play_or_pause(origin_index, self.at_playing_list) 364 | self.at_playing_list = False 365 | else: 366 | # 所在列表类型不是歌曲 367 | is_not_songs = True 368 | self.player.play_or_pause(self.player.info["idx"], is_not_songs) 369 | self.build_menu_processbar() 370 | 371 | def space_key_event(self): 372 | idx = self.index 373 | datatype = self.datatype 374 | if not self.datalist: 375 | return 376 | if idx < 0 or idx >= len(self.datalist): 377 | self.player.info["idx"] = 0 378 | 379 | # If change to a new playing list. Add playing list and play. 380 | datatype_callback = { 381 | "songs": None, 382 | "djprograms": None, 383 | "fmsongs": self.fm_callback, 384 | } 385 | 386 | if datatype in ["songs", "djprograms", "fmsongs"]: 387 | self.player.new_player_list(datatype, self.title, self.datalist, -1) 388 | self.player.end_callback = datatype_callback[datatype] 389 | self.player.play_or_pause(idx, self.at_playing_list) 390 | self.at_playing_list = True 391 | 392 | else: 393 | # 所在列表类型不是歌曲 394 | is_not_songs = True 395 | self.player.play_or_pause(self.player.info["idx"], is_not_songs) 396 | self.build_menu_processbar() 397 | 398 | def like_event(self): 399 | return_data = self.request_api(self.api.fm_like, self.player.playing_id) 400 | if return_data: 401 | song_name = self.player.playing_name 402 | notify("%s added successfully!" % song_name, 0) 403 | else: 404 | notify("Adding song failed!", 0) 405 | 406 | def back_page_event(self): 407 | if len(self.stack) == 1: 408 | return 409 | self.menu_starts = time.time() 410 | ( 411 | self.datatype, 412 | self.title, 413 | self.datalist, 414 | self.offset, 415 | self.index, 416 | ) = self.stack.pop() 417 | self.at_playing_list = False 418 | self.at_search_result = False 419 | 420 | def enter_page_event(self): 421 | idx = self.index 422 | self.enter_flag = True 423 | if len(self.datalist) <= 0: 424 | return 425 | if self.datatype == "comments": 426 | return 427 | self.menu_starts = time.time() 428 | self.ui.build_loading() 429 | self.dispatch_enter(idx) 430 | if self.enter_flag: 431 | self.index = 0 432 | self.offset = 0 433 | 434 | def album_key_event(self): 435 | datatype = self.datatype 436 | title = self.title 437 | datalist = self.datalist 438 | offset = self.offset 439 | idx = self.index 440 | step = self.step 441 | if datatype == "album": 442 | return 443 | if datatype in ["songs", "fmsongs"]: 444 | song_id = datalist[idx]["song_id"] 445 | album_id = datalist[idx]["album_id"] 446 | album_name = datalist[idx]["album_name"] 447 | elif self.player.playing_flag: 448 | song_id = self.player.playing_id 449 | song_info = self.player.songs.get(str(song_id), {}) 450 | album_id = song_info.get("album_id", "") 451 | album_name = song_info.get("album_name", "") 452 | else: 453 | album_id = 0 454 | if album_id: 455 | self.stack.append([datatype, title, datalist, offset, self.index]) 456 | songs = self.api.album(album_id) 457 | self.datatype = "songs" 458 | self.datalist = self.api.dig_info(songs, "songs") 459 | self.title = "网易云音乐 > 专辑 > %s" % album_name 460 | for i in range(len(self.datalist)): 461 | if self.datalist[i]["song_id"] == song_id: 462 | self.offset = i - i % step 463 | self.index = i 464 | return 465 | self.build_menu_processbar() 466 | 467 | def num_jump_key_event(self): 468 | # 键盘映射ascii编码 91 [ 93 ] 258 259 106 j 107 k 469 | # 歌单快速跳跃 470 | result = parse_keylist(self.key_list) 471 | num, cmd = result 472 | if num == 0: # 0j -> 1j 473 | num = 1 474 | for _ in range(num): 475 | if cmd in (KEY_MAP["mouseUp"], ord(KEY_MAP["up"])): 476 | self.up_key_event() 477 | elif cmd in (KEY_MAP["mouseDown"], ord(KEY_MAP["down"])): 478 | self.down_key_event() 479 | elif cmd == ord(KEY_MAP["nextSong"]): 480 | self.next_key_event() 481 | elif cmd == ord(KEY_MAP["prevSong"]): 482 | self.prev_key_event() 483 | if cmd in (ord(KEY_MAP["nextSong"]), ord(KEY_MAP["prevSong"])): 484 | self.player.stop() 485 | self.player.replay() 486 | self.build_menu_processbar() 487 | 488 | def digit_key_song_event(self): 489 | """直接跳到指定id 歌曲""" 490 | step = self.step 491 | self.key_list.pop() 492 | song_index = parse_keylist(self.key_list) 493 | if self.index != song_index: 494 | self.index = song_index 495 | self.offset = self.index - self.index % step 496 | self.build_menu_processbar() 497 | self.ui.screen.refresh() 498 | 499 | def time_key_event(self): 500 | self.countdown_start = time.time() 501 | countdown = self.ui.build_timing() 502 | if not countdown.isdigit(): 503 | notify("The input should be digit") 504 | 505 | countdown = int(countdown) 506 | if countdown > 0: 507 | notify("The musicbox will exit in {} minutes".format(countdown)) 508 | self.countdown = countdown * 60 509 | self.is_in_countdown = True 510 | self.timer = Timer(self.countdown, self.stop, ()) 511 | self.timer.start() 512 | else: 513 | notify("The timing exit has been canceled") 514 | self.is_in_countdown = False 515 | if self.timer: 516 | self.timer.cancel() 517 | self.build_menu_processbar() 518 | 519 | def down_page_event(self): 520 | offset = self.offset 521 | datalist = self.datalist 522 | step = self.step 523 | if offset + step >= len(datalist): 524 | return 525 | self.menu_starts = time.time() 526 | self.offset += step 527 | 528 | # e.g. 23 + 10 = 33 --> 30 529 | self.index = (self.index + step) // step * step 530 | 531 | def up_page_event(self): 532 | offset = self.offset 533 | step = self.step 534 | if offset == 0: 535 | return 536 | self.menu_starts = time.time() 537 | self.offset -= step 538 | 539 | # e.g. 23 - 10 = 13 --> 10 540 | self.index = (self.index - step) // step * step 541 | 542 | def resize_key_event(self): 543 | self.player.update_size() 544 | 545 | def build_menu_processbar(self): 546 | self.ui.build_process_bar( 547 | self.player.current_song, 548 | self.player.process_location, 549 | self.player.process_length, 550 | self.player.playing_flag, 551 | self.player.info["playing_mode"], 552 | ) 553 | self.ui.build_menu( 554 | self.datatype, 555 | self.title, 556 | self.datalist, 557 | self.offset, 558 | self.index, 559 | self.step, 560 | self.menu_starts, 561 | ) 562 | 563 | def quit_event(self): 564 | self.config.save_config_file() 565 | sys.exit(0) 566 | 567 | def stop(self): 568 | self.quit = True 569 | self.player.stop() 570 | self.cache.quit() 571 | self.storage.save() 572 | C.endwin() 573 | 574 | def start(self): 575 | self.menu_starts = time.time() 576 | self.ui.build_menu( 577 | self.datatype, 578 | self.title, 579 | self.datalist, 580 | self.offset, 581 | self.index, 582 | self.step, 583 | self.menu_starts, 584 | ) 585 | self.stack.append( 586 | [self.datatype, self.title, self.datalist, self.offset, self.index] 587 | ) 588 | if pyqt_activity: 589 | show_lyrics_new_process() 590 | pre_key = -1 591 | keylist = self.key_list 592 | self.parser = cmd_parser(keylist) 593 | erase_cmd_list = [] 594 | erase_coro = erase_coroutine(erase_cmd_list) 595 | next(self.parser) # start generator 596 | next(erase_coro) 597 | while not self.quit: 598 | datatype = self.datatype 599 | title = self.title 600 | datalist = self.datalist 601 | offset = self.offset 602 | idx = self.index 603 | step = self.step 604 | self.screen.timeout(self.config.get("input_timeout")) 605 | key = self.screen.getch() 606 | 607 | if ( 608 | key in COMMAND_LIST 609 | and key != ord(KEY_MAP["nextSong"]) 610 | and key != ord(KEY_MAP["prevSong"]) 611 | ): 612 | if not ( 613 | ( 614 | set(self.pre_keylist) 615 | | {ord(KEY_MAP["prevSong"]), ord(KEY_MAP["nextSong"])} 616 | ) 617 | == {ord(KEY_MAP["prevSong"]), ord(KEY_MAP["nextSong"])} 618 | ): 619 | self.pre_keylist.append(key) 620 | self.key_list = deepcopy(self.pre_keylist) 621 | self.pre_keylist.clear() 622 | elif ( 623 | key in range(48, 58) 624 | or key == ord(KEY_MAP["nextSong"]) 625 | or key == ord(KEY_MAP["prevSong"]) 626 | ): 627 | self.pre_keylist.append(key) 628 | elif key == -1 and ( 629 | pre_key == ord(KEY_MAP["nextSong"]) 630 | or pre_key == ord(KEY_MAP["prevSong"]) 631 | ): 632 | self.key_list = deepcopy(self.pre_keylist) 633 | self.pre_keylist.clear() 634 | # 取消当前输入 635 | elif key == 27: 636 | self.pre_keylist.clear() 637 | self.key_list.clear() 638 | 639 | keylist = self.key_list 640 | 641 | # 如果 keylist 全都是数字 + G 642 | if keylist and ( 643 | set(keylist) | set(range(48, 58)) | {ord(KEY_MAP["jumpIndex"])} 644 | ) == set(range(48, 58)) | {ord(KEY_MAP["jumpIndex"])}: 645 | # 歌曲数字映射 646 | self.digit_key_song_event() 647 | self.key_list.clear() 648 | continue 649 | 650 | # 如果 keylist 只有 [ ] 651 | if len(keylist) > 0 and ( 652 | set(keylist) | {ord(KEY_MAP["prevSong"]), ord(KEY_MAP["nextSong"])} 653 | ) == {ord(KEY_MAP["prevSong"]), ord(KEY_MAP["nextSong"])}: 654 | self.player.stop() 655 | self.player.replay() 656 | self.key_list.clear() 657 | continue 658 | 659 | # 如果是 数字+ [ ] j k 660 | if len(keylist) > 1: 661 | if parse_keylist(keylist): 662 | self.num_jump_key_event() 663 | self.key_list.clear() 664 | continue 665 | # if self.is_in_countdown: 666 | # if time.time() - self.countdown_start > self.countdown: 667 | # break 668 | if key == -1: 669 | self.player.update_size() 670 | 671 | # 退出 672 | elif C.keyname(key).decode("utf-8") == KEY_MAP["quit"]: 673 | if pyqt_activity: 674 | stop_lyrics_process() 675 | break 676 | 677 | # 退出并清除用户信息 678 | elif C.keyname(key).decode("utf-8") == KEY_MAP["quitClear"]: 679 | if pyqt_activity: 680 | stop_lyrics_process() 681 | self.api.logout() 682 | break 683 | 684 | # 上移 685 | elif C.keyname(key).decode("utf-8") == KEY_MAP[ 686 | "up" 687 | ] and pre_key not in range(ord("0"), ord("9")): 688 | self.up_key_event() 689 | elif self.config.get("mouse_movement") and key == KEY_MAP["mouseUp"]: 690 | self.up_key_event() 691 | 692 | # 下移 693 | elif C.keyname(key).decode("utf-8") == KEY_MAP[ 694 | "down" 695 | ] and pre_key not in range(ord("0"), ord("9")): 696 | self.down_key_event() 697 | elif self.config.get("mouse_movement") and key == KEY_MAP["mouseDown"]: 698 | self.down_key_event() 699 | 700 | # 向上翻页 701 | elif C.keyname(key).decode("utf-8") == KEY_MAP["prevPage"]: 702 | self.up_page_event() 703 | 704 | # 向下翻页 705 | elif C.keyname(key).decode("utf-8") == KEY_MAP["nextPage"]: 706 | self.down_page_event() 707 | 708 | # 前进 709 | elif C.keyname(key).decode("utf-8") == KEY_MAP["forward"] or key == 10: 710 | self.enter_page_event() 711 | 712 | # 回退 713 | elif C.keyname(key).decode("utf-8") == KEY_MAP["back"]: 714 | self.back_page_event() 715 | 716 | # 模糊搜索 717 | elif C.keyname(key).decode("utf-8") == KEY_MAP["search"]: 718 | if self.at_search_result: 719 | self.back_page_event() 720 | self.stack.append( 721 | [self.datatype, self.title, self.datalist, self.offset, self.index] 722 | ) 723 | self.datalist, keyword = self.in_place_search() 724 | self.title += " > " + keyword + " 的搜索结果" 725 | self.offset = 0 726 | self.index = 0 727 | self.at_search_result = True 728 | 729 | # 播放下一曲 730 | elif C.keyname(key).decode("utf-8") == KEY_MAP[ 731 | "nextSong" 732 | ] and pre_key not in range(ord("0"), ord("9")): 733 | self.next_key_event() 734 | 735 | # 播放上一曲 736 | elif C.keyname(key).decode("utf-8") == KEY_MAP[ 737 | "prevSong" 738 | ] and pre_key not in range(ord("0"), ord("9")): 739 | self.prev_key_event() 740 | 741 | # 增加音量 742 | elif C.keyname(key).decode("utf-8") == KEY_MAP["volume+"]: 743 | self.player.volume_up() 744 | 745 | # 减少音量 746 | elif C.keyname(key).decode("utf-8") == KEY_MAP["volume-"]: 747 | self.player.volume_down() 748 | 749 | # 随机播放 750 | elif C.keyname(key).decode("utf-8") == KEY_MAP["shuffle"]: 751 | if len(self.player.info["player_list"]) == 0: 752 | continue 753 | self.player.shuffle() 754 | 755 | # 喜爱 756 | elif C.keyname(key).decode("utf-8") == KEY_MAP["like"]: 757 | return_data = self.request_api(self.api.fm_like, self.player.playing_id) 758 | if return_data: 759 | song_name = self.player.playing_name 760 | notify("%s added successfully!" % song_name, 0) 761 | else: 762 | notify("Adding song failed!", 0) 763 | 764 | # 删除FM 765 | elif C.keyname(key).decode("utf-8") == KEY_MAP["trashFM"]: 766 | if self.datatype == "fmsongs": 767 | if len(self.player.info["player_list"]) == 0: 768 | continue 769 | self.player.next() 770 | return_data = self.request_api( 771 | self.api.fm_trash, self.player.playing_id 772 | ) 773 | if return_data: 774 | notify("Deleted successfully!", 0) 775 | 776 | # 更多FM 777 | elif C.keyname(key).decode("utf-8") == KEY_MAP["nextFM"]: 778 | if self.datatype == "fmsongs": 779 | # if len(self.player.info['player_list']) == 0: 780 | # continue 781 | if self.player.end_callback: 782 | self.player.end_callback() 783 | else: 784 | self.datalist.extend(self.get_new_fm()) 785 | self.build_menu_processbar() 786 | self.index = len(self.datalist) - 1 787 | self.offset = self.index - self.index % self.step 788 | 789 | # 播放、暂停 790 | elif C.keyname(key).decode("utf-8") == KEY_MAP["playPause"]: 791 | if self.at_search_result: 792 | self.space_key_event_in_search_result() 793 | else: 794 | self.space_key_event() 795 | 796 | # 加载当前播放列表 797 | elif C.keyname(key).decode("utf-8") == KEY_MAP["presentHistory"]: 798 | self.show_playing_song() 799 | 800 | # 播放模式切换 801 | elif C.keyname(key).decode("utf-8") == KEY_MAP["playingMode"]: 802 | self.player.change_mode() 803 | 804 | # 进入专辑 805 | elif C.keyname(key).decode("utf-8") == KEY_MAP["enterAlbum"]: 806 | if datatype == "album": 807 | continue 808 | if datatype in ["songs", "fmsongs"]: 809 | song_id = datalist[idx]["song_id"] 810 | album_id = datalist[idx]["album_id"] 811 | album_name = datalist[idx]["album_name"] 812 | elif self.player.playing_flag: 813 | song_id = self.player.playing_id 814 | song_info = self.player.songs.get(str(song_id), {}) 815 | album_id = song_info.get("album_id", "") 816 | album_name = song_info.get("album_name", "") 817 | else: 818 | album_id = 0 819 | if album_id: 820 | self.stack.append([datatype, title, datalist, offset, self.index]) 821 | songs = self.api.album(album_id) 822 | self.datatype = "songs" 823 | self.datalist = self.api.dig_info(songs, "songs") 824 | self.title = "网易云音乐 > 专辑 > %s" % album_name 825 | for i in range(len(self.datalist)): 826 | if self.datalist[i]["song_id"] == song_id: 827 | self.offset = i - i % step 828 | self.index = i 829 | break 830 | 831 | # 添加到打碟歌单 832 | elif C.keyname(key).decode("utf-8") == KEY_MAP["add"]: 833 | if (self.datatype == "songs" or self.datatype == "djprograms") and len( 834 | self.datalist 835 | ) != 0: 836 | self.djstack.append(datalist[idx]) 837 | elif datatype == "artists": 838 | pass 839 | 840 | # 加载打碟歌单 841 | elif C.keyname(key).decode("utf-8") == KEY_MAP["djList"]: 842 | self.stack.append( 843 | [self.datatype, self.title, self.datalist, self.offset, self.index] 844 | ) 845 | self.datatype = "songs" 846 | self.title = "网易云音乐 > 打碟" 847 | self.datalist = self.djstack 848 | self.offset = 0 849 | self.index = 0 850 | 851 | # 添加到本地收藏 852 | elif C.keyname(key).decode("utf-8") == KEY_MAP["star"]: 853 | if (self.datatype == "songs" or self.datatype == "djprograms") and len( 854 | self.datalist 855 | ) != 0: 856 | self.collection.append(self.datalist[self.index]) 857 | notify("Added successfully", 0) 858 | 859 | # 加载本地收藏 860 | elif C.keyname(key).decode("utf-8") == KEY_MAP["collection"]: 861 | self.stack.append( 862 | [self.datatype, self.title, self.datalist, self.offset, self.index] 863 | ) 864 | self.datatype = "songs" 865 | self.title = "网易云音乐 > 本地收藏" 866 | self.datalist = self.collection 867 | self.offset = 0 868 | self.index = 0 869 | 870 | # 从当前列表移除 871 | elif C.keyname(key).decode("utf-8") == KEY_MAP["remove"]: 872 | if ( 873 | self.datatype in ("songs", "djprograms", "fmsongs") 874 | and len(self.datalist) != 0 875 | ): 876 | self.datalist.pop(self.index) 877 | log.warn(self.index) 878 | log.warn(len(self.datalist)) 879 | if self.index == len(self.datalist): 880 | self.up_key_event() 881 | self.index = carousel( 882 | self.offset, 883 | min(len(self.datalist), self.offset + self.step) - 1, 884 | self.index, 885 | ) 886 | 887 | # 倒计时 888 | elif C.keyname(key).decode("utf-8") == KEY_MAP["countDown"]: 889 | self.time_key_event() 890 | 891 | # 当前项目下移 892 | elif C.keyname(key).decode("utf-8") == KEY_MAP["moveDown"]: 893 | if ( 894 | self.datatype != "main" 895 | and len(self.datalist) != 0 896 | and self.index + 1 != len(self.datalist) 897 | ): 898 | self.menu_starts = time.time() 899 | song = self.datalist.pop(self.index) 900 | self.datalist.insert(self.index + 1, song) 901 | self.index = self.index + 1 902 | # 翻页 903 | if self.index >= self.offset + self.step: 904 | self.offset = self.offset + self.step 905 | 906 | # 当前项目上移 907 | elif C.keyname(key).decode("utf-8") == KEY_MAP["moveUp"]: 908 | if ( 909 | self.datatype != "main" 910 | and len(self.datalist) != 0 911 | and self.index != 0 912 | ): 913 | self.menu_starts = time.time() 914 | song = self.datalist.pop(self.index) 915 | self.datalist.insert(self.index - 1, song) 916 | self.index = self.index - 1 917 | # 翻页 918 | if self.index < self.offset: 919 | self.offset = self.offset - self.step 920 | 921 | # 菜单 922 | elif C.keyname(key).decode("utf-8") == KEY_MAP["menu"]: 923 | if self.datatype != "main": 924 | self.stack.append( 925 | [ 926 | self.datatype, 927 | self.title, 928 | self.datalist, 929 | self.offset, 930 | self.index, 931 | ] 932 | ) 933 | self.datatype, self.title, self.datalist, *_ = self.stack[0] 934 | self.offset = 0 935 | self.index = 0 936 | # 跳到开头 g键 937 | elif C.keyname(key).decode("utf-8") == KEY_MAP["top"]: 938 | if self.datatype == "help": 939 | webbrowser.open_new_tab("https://github.com/darknessomi/musicbox") 940 | else: 941 | self.index = 0 942 | self.offset = 0 943 | 944 | # 跳到末尾ord('G') 键 945 | elif C.keyname(key).decode("utf-8") == KEY_MAP["bottom"]: 946 | self.index = len(self.datalist) - 1 947 | self.offset = self.index - self.index % self.step 948 | 949 | # 开始下载 950 | elif C.keyname(key).decode("utf-8") == KEY_MAP["cache"]: 951 | s = self.datalist[self.index] 952 | cache_thread = threading.Thread( 953 | target=self.player.cache_song, 954 | args=(s["song_id"], s["song_name"], s["artist"], s["mp3_url"]), 955 | ) 956 | cache_thread.start() 957 | # 在网页打开 ord(i) 958 | elif C.keyname(key).decode("utf-8") == KEY_MAP["musicInfo"]: 959 | if self.player.playing_id != -1: 960 | webbrowser.open_new_tab( 961 | "http://music.163.com/song?id={}".format(self.player.playing_id) 962 | ) 963 | # term resize 964 | # 刷新屏幕 按下某个键或者默认5秒刷新空白区 965 | # erase_coro.send(key) 966 | # if erase_cmd_list: 967 | # self.screen.erase() 968 | self.player.update_size() 969 | 970 | pre_key = key 971 | self.ui.screen.refresh() 972 | self.ui.update_size() 973 | current_step = max(int(self.ui.y * 4 / 5) - 10, 1) 974 | if self.step != current_step and self.config.get("page_length") == 0: 975 | self.step = current_step 976 | self.index = 0 977 | self.build_menu_processbar() 978 | self.stop() 979 | 980 | def dispatch_enter(self, idx): 981 | # The end of stack 982 | netease = self.api 983 | datatype = self.datatype 984 | title = self.title 985 | datalist = self.datalist 986 | offset = self.offset 987 | index = self.index 988 | self.stack.append([datatype, title, datalist, offset, index]) 989 | 990 | if idx >= len(self.datalist): 991 | return False 992 | 993 | if datatype == "main": 994 | self.choice_channel(idx) 995 | 996 | # 该艺术家的热门歌曲 997 | elif datatype == "artists": 998 | artist_name = datalist[idx]["artists_name"] 999 | artist_id = datalist[idx]["artist_id"] 1000 | 1001 | self.datatype = "artist_info" 1002 | self.title += " > " + artist_name 1003 | self.datalist = [ 1004 | {"item": "{}的热门歌曲".format(artist_name), "id": artist_id}, 1005 | {"item": "{}的所有专辑".format(artist_name), "id": artist_id}, 1006 | ] 1007 | 1008 | elif datatype == "artist_info": 1009 | self.title += " > " + datalist[idx]["item"] 1010 | artist_id = datalist[0]["id"] 1011 | if idx == 0: 1012 | self.datatype = "songs" 1013 | songs = netease.artists(artist_id) 1014 | self.datalist = netease.dig_info(songs, "songs") 1015 | 1016 | elif idx == 1: 1017 | albums = netease.get_artist_album(artist_id) 1018 | self.datatype = "albums" 1019 | self.datalist = netease.dig_info(albums, "albums") 1020 | 1021 | elif datatype == "djRadios": 1022 | radio_id = datalist[idx]["id"] 1023 | programs = netease.alldjprograms(radio_id) 1024 | self.title += " > " + datalist[idx]["name"] 1025 | self.datatype = "djprograms" 1026 | self.datalist = netease.dig_info(programs, "djprograms") 1027 | 1028 | # 该专辑包含的歌曲 1029 | elif datatype == "albums": 1030 | album_id = datalist[idx]["album_id"] 1031 | songs = netease.album(album_id) 1032 | self.datatype = "songs" 1033 | self.datalist = netease.dig_info(songs, "songs") 1034 | self.title += " > " + datalist[idx]["albums_name"] 1035 | 1036 | # 精选歌单选项 1037 | elif datatype == "recommend_lists": 1038 | data = self.datalist[idx] 1039 | self.datatype = data["datatype"] 1040 | self.datalist = netease.dig_info(data["callback"](), self.datatype) 1041 | self.title += " > " + data["title"] 1042 | 1043 | # 全站置顶歌单包含的歌曲 1044 | elif datatype in ["top_playlists", "playlists"]: 1045 | playlist_id = datalist[idx]["playlist_id"] 1046 | songs = netease.playlist_songlist(playlist_id) 1047 | self.datatype = "songs" 1048 | self.datalist = netease.dig_info(songs, "songs") 1049 | self.title += " > " + datalist[idx]["playlist_name"] 1050 | 1051 | # 分类精选 1052 | elif datatype == "playlist_classes": 1053 | # 分类名称 1054 | data = self.datalist[idx] 1055 | self.datatype = "playlist_class_detail" 1056 | self.datalist = netease.dig_info(data, self.datatype) 1057 | self.title += " > " + data 1058 | 1059 | # 某一分类的详情 1060 | elif datatype == "playlist_class_detail": 1061 | # 子类别 1062 | data = self.datalist[idx] 1063 | self.datatype = "top_playlists" 1064 | self.datalist = netease.dig_info(netease.top_playlists(data), self.datatype) 1065 | self.title += " > " + data 1066 | 1067 | # 歌曲评论 1068 | elif datatype in ["songs", "fmsongs"]: 1069 | song_id = datalist[idx]["song_id"] 1070 | comments = self.api.song_comments(song_id, limit=100) 1071 | try: 1072 | hotcomments = comments["hotComments"] 1073 | comcomments = comments["comments"] 1074 | except KeyError: 1075 | hotcomments = comcomments = [] 1076 | self.datalist = [] 1077 | for one_comment in hotcomments: 1078 | self.datalist.append( 1079 | { 1080 | "comment_content": "(热评 %s❤️ ️) %s: %s" 1081 | % ( 1082 | one_comment["likedCount"], 1083 | one_comment["user"]["nickname"], 1084 | one_comment["content"], 1085 | ) 1086 | } 1087 | ) 1088 | for one_comment in comcomments: 1089 | # self.datalist.append(one_comment["content"]) 1090 | self.datalist.append( 1091 | { 1092 | "comment_content": "(%s❤️ ️) %s: %s" 1093 | % ( 1094 | one_comment["likedCount"], 1095 | one_comment["user"]["nickname"], 1096 | one_comment["content"], 1097 | ) 1098 | } 1099 | ) 1100 | self.datatype = "comments" 1101 | self.title = "网易云音乐 > 评论: %s" % datalist[idx]["song_name"] 1102 | self.offset = 0 1103 | self.index = 0 1104 | 1105 | # 歌曲榜单 1106 | elif datatype == "toplists": 1107 | songs = netease.top_songlist(idx) 1108 | self.title += " > " + self.datalist[idx] 1109 | self.datalist = netease.dig_info(songs, "songs") 1110 | self.datatype = "songs" 1111 | 1112 | # 搜索菜单 1113 | elif datatype == "search": 1114 | self.index = 0 1115 | self.offset = 0 1116 | SearchCategory = namedtuple("SearchCategory", ["type", "title"]) 1117 | idx_map = { 1118 | 0: SearchCategory("playlists", "精选歌单搜索列表"), 1119 | 1: SearchCategory("songs", "歌曲搜索列表"), 1120 | 2: SearchCategory("artists", "艺术家搜索列表"), 1121 | 3: SearchCategory("albums", "专辑搜索列表"), 1122 | 4: SearchCategory("djRadios", "主播电台搜索列表"), 1123 | } 1124 | self.datatype, self.title = idx_map[idx] 1125 | self.datalist = self.search(self.datatype) 1126 | else: 1127 | self.enter_flag = False 1128 | 1129 | # self.parser.send(-1) 1130 | 1131 | def show_playing_song(self): 1132 | if self.player.is_empty: 1133 | return 1134 | 1135 | if (not self.at_playing_list) and (not self.at_search_result): 1136 | self.stack.append( 1137 | [self.datatype, self.title, self.datalist, self.offset, self.index] 1138 | ) 1139 | self.at_playing_list = True 1140 | 1141 | if self.at_search_result: 1142 | self.back_page_event() 1143 | 1144 | self.datatype = self.player.info["player_list_type"] 1145 | self.title = self.player.info["player_list_title"] 1146 | self.datalist = [self.player.songs[i] for i in self.player.info["player_list"]] 1147 | self.index = self.player.info["idx"] 1148 | self.offset = self.index // self.step * self.step 1149 | 1150 | def song_changed_callback(self): 1151 | if self.at_playing_list: 1152 | self.show_playing_song() 1153 | 1154 | def fm_callback(self): 1155 | # log.debug('FM CallBack.') 1156 | data = self.get_new_fm() 1157 | self.player.append_songs(data) 1158 | if self.datatype == "fmsongs": 1159 | if self.player.is_empty: 1160 | return 1161 | self.datatype = self.player.info["player_list_type"] 1162 | self.title = self.player.info["player_list_title"] 1163 | self.datalist = [] 1164 | for i in self.player.info["player_list"]: 1165 | self.datalist.append(self.player.songs[i]) 1166 | self.index = self.player.info["idx"] 1167 | self.offset = self.index // self.step * self.step 1168 | if not self.player.playing_flag: 1169 | switch_flag = False 1170 | self.player.play_or_pause(self.index, switch_flag) 1171 | 1172 | def request_api(self, func, *args): 1173 | result = func(*args) 1174 | if result: 1175 | return result 1176 | if not self.login(): 1177 | notify("You need to log in") 1178 | return False 1179 | return func(*args) 1180 | 1181 | def get_new_fm(self): 1182 | data = self.request_api(self.api.personal_fm) 1183 | if not data: 1184 | return [] 1185 | return self.api.dig_info(data, "fmsongs") 1186 | 1187 | def choice_channel(self, idx): 1188 | self.offset = 0 1189 | self.index = 0 1190 | 1191 | if idx == 0: 1192 | self.datalist = self.api.toplists 1193 | self.title += " > 排行榜" 1194 | self.datatype = "toplists" 1195 | elif idx == 1: 1196 | artists = self.api.top_artists() 1197 | self.datalist = self.api.dig_info(artists, "artists") 1198 | self.title += " > 艺术家" 1199 | self.datatype = "artists" 1200 | elif idx == 2: 1201 | albums = self.api.new_albums() 1202 | self.datalist = self.api.dig_info(albums, "albums") 1203 | self.title += " > 新碟上架" 1204 | self.datatype = "albums" 1205 | elif idx == 3: 1206 | self.datalist = [ 1207 | { 1208 | "title": "全站置顶", 1209 | "datatype": "top_playlists", 1210 | "callback": self.api.top_playlists, 1211 | }, 1212 | { 1213 | "title": "分类精选", 1214 | "datatype": "playlist_classes", 1215 | "callback": lambda: [], 1216 | }, 1217 | ] 1218 | self.title += " > 精选歌单" 1219 | self.datatype = "recommend_lists" 1220 | elif idx == 4: 1221 | if not self.account: 1222 | self.login() 1223 | myplaylist = self.request_api(self.api.user_playlist, self.userid) 1224 | self.datatype = "top_playlists" 1225 | self.datalist = self.api.dig_info(myplaylist, self.datatype) 1226 | self.title += " > " + self.username + " 的歌单" 1227 | elif idx == 5: 1228 | self.datatype = "djRadios" 1229 | self.title += " > 主播电台" 1230 | self.datalist = self.api.djRadios() 1231 | elif idx == 6: 1232 | self.datatype = "songs" 1233 | self.title += " > 每日推荐歌曲" 1234 | myplaylist = self.request_api(self.api.recommend_playlist) 1235 | if myplaylist == -1: 1236 | return 1237 | self.datalist = self.api.dig_info(myplaylist, self.datatype) 1238 | elif idx == 7: 1239 | myplaylist = self.request_api(self.api.recommend_resource) 1240 | self.datatype = "top_playlists" 1241 | self.title += " > 每日推荐歌单" 1242 | self.datalist = self.api.dig_info(myplaylist, self.datatype) 1243 | elif idx == 8: 1244 | self.datatype = "fmsongs" 1245 | self.title += " > 私人FM" 1246 | self.datalist = self.get_new_fm() 1247 | elif idx == 9: 1248 | self.datatype = "search" 1249 | self.title += " > 搜索" 1250 | self.datalist = ["歌曲", "艺术家", "专辑", "主播电台", "网易精选集"] 1251 | elif idx == 10: 1252 | self.datatype = "help" 1253 | self.title += " > 帮助" 1254 | self.datalist = shortcut 1255 | -------------------------------------------------------------------------------- /NEMbox/osdlyrics.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # osdlyrics.py --- desktop lyrics for musicbox 4 | # Copyright (c) 2015-2016 omi & Contributors 5 | import sys 6 | from multiprocessing import Process 7 | from multiprocessing import set_start_method 8 | 9 | from . import logger 10 | from .config import Config 11 | 12 | log = logger.getLogger(__name__) 13 | 14 | config = Config() 15 | 16 | try: 17 | from qtpy import QtGui, QtCore, QtWidgets 18 | import dbus 19 | import dbus.service 20 | import dbus.mainloop.glib 21 | 22 | pyqt_activity = True 23 | except ImportError: 24 | pyqt_activity = False 25 | log.warn("qtpy module not installed.") 26 | log.warn("Osdlyrics Not Available.") 27 | 28 | if pyqt_activity: 29 | QWidget = QtWidgets.QWidget 30 | QApplication = QtWidgets.QApplication 31 | 32 | class Lyrics(QWidget): 33 | def __init__(self): 34 | super(Lyrics, self).__init__() 35 | self.text = "" 36 | self.initUI() 37 | 38 | def initUI(self): 39 | self.setStyleSheet("background:" + config.get("osdlyrics_background")) 40 | if config.get("osdlyrics_transparent"): 41 | self.setAttribute(QtCore.Qt.WA_TranslucentBackground) 42 | self.setAttribute(QtCore.Qt.WA_ShowWithoutActivating) 43 | self.setAttribute(QtCore.Qt.WA_X11DoNotAcceptFocus) 44 | self.setFocusPolicy(QtCore.Qt.NoFocus) 45 | if config.get("osdlyrics_on_top"): 46 | self.setWindowFlags( 47 | QtCore.Qt.FramelessWindowHint 48 | | QtCore.Qt.WindowStaysOnTopHint 49 | | QtCore.Qt.X11BypassWindowManagerHint 50 | ) 51 | else: 52 | self.setWindowFlags(QtCore.Qt.FramelessWindowHint) 53 | self.setMinimumSize(600, 50) 54 | osdlyrics_size = config.get("osdlyrics_size") 55 | self.resize(osdlyrics_size[0], osdlyrics_size[1]) 56 | scn = QApplication.desktop().screenNumber( 57 | QApplication.desktop().cursor().pos() 58 | ) 59 | bl = QApplication.desktop().screenGeometry(scn).bottomLeft() 60 | br = QApplication.desktop().screenGeometry(scn).bottomRight() 61 | bc = (bl + br) / 2 62 | frameGeo = self.frameGeometry() 63 | frameGeo.moveCenter(bc) 64 | frameGeo.moveBottom(bc.y()) 65 | self.move(frameGeo.topLeft()) 66 | self.text = "OSD Lyrics for Musicbox" 67 | self.setWindowTitle("Lyrics") 68 | self.show() 69 | 70 | def mousePressEvent(self, event): 71 | self.mpos = event.pos() 72 | 73 | def mouseMoveEvent(self, event): 74 | if event.buttons() and QtCore.Qt.LeftButton: 75 | diff = event.pos() - self.mpos 76 | newpos = self.pos() + diff 77 | self.move(newpos) 78 | 79 | def wheelEvent(self, event): 80 | self.resize(self.width() + event.delta(), self.height()) 81 | 82 | def paintEvent(self, event): 83 | qp = QtGui.QPainter() 84 | qp.begin(self) 85 | self.drawText(event, qp) 86 | qp.end() 87 | 88 | def drawText(self, event, qp): 89 | osdlyrics_color = config.get("osdlyrics_color") 90 | osdlyrics_font = config.get("osdlyrics_font") 91 | font = QtGui.QFont(osdlyrics_font[0], osdlyrics_font[1]) 92 | pen = QtGui.QColor( 93 | osdlyrics_color[0], osdlyrics_color[1], osdlyrics_color[2] 94 | ) 95 | qp.setFont(font) 96 | qp.setPen(pen) 97 | qp.drawText( 98 | event.rect(), QtCore.Qt.AlignCenter | QtCore.Qt.TextWordWrap, self.text 99 | ) 100 | 101 | def setText(self, text): 102 | self.text = text 103 | self.repaint() 104 | 105 | class LyricsAdapter(dbus.service.Object): 106 | def __init__(self, name, session): 107 | dbus.service.Object.__init__(self, name, session) 108 | self.widget = Lyrics() 109 | 110 | @dbus.service.method( 111 | "local.musicbox.Lyrics", in_signature="s", out_signature="" 112 | ) 113 | def refresh_lyrics(self, text): 114 | self.widget.setText(text.replace("||", "\n")) 115 | 116 | @dbus.service.method("local.musicbox.Lyrics", in_signature="", out_signature="") 117 | def exit(self): 118 | QApplication.quit() 119 | 120 | def show_lyrics(): 121 | app = QApplication(sys.argv) 122 | dbus.mainloop.glib.DBusGMainLoop(set_as_default=True) 123 | session_bus = dbus.SessionBus() 124 | name = dbus.service.BusName("org.musicbox.Bus", session_bus) 125 | lyrics = LyricsAdapter(session_bus, "/") 126 | app.exec_() 127 | 128 | 129 | def stop_lyrics_process(): 130 | if pyqt_activity: 131 | bus = dbus.SessionBus().get_object("org.musicbox.Bus", "/") 132 | bus.exit(dbus_interface="local.musicbox.Lyrics") 133 | 134 | 135 | def show_lyrics_new_process(): 136 | if pyqt_activity and config.get("osdlyrics"): 137 | set_start_method("spawn") 138 | p = Process(target=show_lyrics) 139 | p.daemon = True 140 | p.start() 141 | -------------------------------------------------------------------------------- /NEMbox/player.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # @Author: omi 4 | # @Date: 2014-07-15 15:48:27 5 | # @Last Modified by: AlanAlbert 6 | # @Last Modified time: 2018-11-21 14:00:00 7 | """ 8 | 网易云音乐 Player 9 | """ 10 | # Let's make some noise 11 | import os 12 | import random 13 | import subprocess 14 | import threading 15 | import time 16 | 17 | from . import logger 18 | from .api import NetEase 19 | from .cache import Cache 20 | from .config import Config 21 | from .kill_thread import stop_thread 22 | from .storage import Storage 23 | from .ui import Ui 24 | from .utils import notify 25 | 26 | 27 | log = logger.getLogger(__name__) 28 | 29 | 30 | class Player(object): 31 | MODE_ORDERED = 0 32 | MODE_ORDERED_LOOP = 1 33 | MODE_SINGLE_LOOP = 2 34 | MODE_RANDOM = 3 35 | MODE_RANDOM_LOOP = 4 36 | SUBPROCESS_LIST = [] 37 | MUSIC_THREADS = [] 38 | 39 | def __init__(self): 40 | self.config = Config() 41 | self.ui = Ui() 42 | self.popen_handler = None 43 | # flag stop, prevent thread start 44 | self.playing_flag = False 45 | self.refresh_url_flag = False 46 | self.process_length = 0 47 | self.process_location = 0 48 | self.storage = Storage() 49 | self.cache = Cache() 50 | self.end_callback = None 51 | self.playing_song_changed_callback = None 52 | self.api = NetEase() 53 | self.playinfo_starts = time.time() 54 | 55 | @property 56 | def info(self): 57 | return self.storage.database["player_info"] 58 | 59 | @property 60 | def songs(self): 61 | return self.storage.database["songs"] 62 | 63 | @property 64 | def index(self): 65 | return self.info["idx"] 66 | 67 | @property 68 | def list(self): 69 | return self.info["player_list"] 70 | 71 | @property 72 | def order(self): 73 | return self.info["playing_order"] 74 | 75 | @property 76 | def mode(self): 77 | return self.info["playing_mode"] 78 | 79 | @property 80 | def is_ordered_mode(self): 81 | return self.mode == Player.MODE_ORDERED 82 | 83 | @property 84 | def is_ordered_loop_mode(self): 85 | return self.mode == Player.MODE_ORDERED_LOOP 86 | 87 | @property 88 | def is_single_loop_mode(self): 89 | return self.mode == Player.MODE_SINGLE_LOOP 90 | 91 | @property 92 | def is_random_mode(self): 93 | return self.mode == Player.MODE_RANDOM 94 | 95 | @property 96 | def is_random_loop_mode(self): 97 | return self.mode == Player.MODE_RANDOM_LOOP 98 | 99 | @property 100 | def config_notifier(self): 101 | return self.config.get("notifier") 102 | 103 | @property 104 | def config_mpg123(self): 105 | return self.config.get("mpg123_parameters") 106 | 107 | @property 108 | def current_song(self): 109 | if not self.songs: 110 | return {} 111 | 112 | if not self.is_index_valid: 113 | return {} 114 | song_id = self.list[self.index] 115 | return self.songs.get(song_id, {}) 116 | 117 | @property 118 | def playing_id(self): 119 | return self.current_song.get("song_id") 120 | 121 | @property 122 | def playing_name(self): 123 | return self.current_song.get("song_name") 124 | 125 | @property 126 | def is_empty(self): 127 | return len(self.list) == 0 128 | 129 | @property 130 | def is_index_valid(self): 131 | return 0 <= self.index < len(self.list) 132 | 133 | def notify_playing(self): 134 | if not self.current_song: 135 | return 136 | 137 | if not self.config_notifier: 138 | return 139 | 140 | song = self.current_song 141 | notify( 142 | "正在播放: {}\n{}-{}".format( 143 | song["song_name"], song["artist"], song["album_name"] 144 | ) 145 | ) 146 | 147 | def notify_copyright_issue(self): 148 | log.warning( 149 | "Song {} is unavailable due to copyright issue.".format(self.playing_id) 150 | ) 151 | notify("版权限制,无法播放此歌曲") 152 | 153 | def change_mode(self, step=1): 154 | self.info["playing_mode"] = (self.info["playing_mode"] + step) % 5 155 | 156 | def build_playinfo(self): 157 | if not self.current_song: 158 | return 159 | 160 | self.ui.build_playinfo( 161 | self.current_song["song_name"], 162 | self.current_song["artist"], 163 | self.current_song["album_name"], 164 | self.current_song["quality"], 165 | self.playinfo_starts, 166 | pause=not self.playing_flag, 167 | ) 168 | 169 | def add_songs(self, songs): 170 | for song in songs: 171 | song_id = str(song["song_id"]) 172 | self.info["player_list"].append(song_id) 173 | if song_id in self.songs: 174 | self.songs[song_id].update(song) 175 | else: 176 | self.songs[song_id] = song 177 | 178 | def refresh_urls(self): 179 | songs = self.api.dig_info(self.list, "refresh_urls") 180 | if songs: 181 | for song in songs: 182 | song_id = str(song["song_id"]) 183 | if song_id in self.songs: 184 | self.songs[song_id]["mp3_url"] = song["mp3_url"] 185 | self.songs[song_id]["expires"] = song["expires"] 186 | self.songs[song_id]["get_time"] = song["get_time"] 187 | else: 188 | self.songs[song_id] = song 189 | self.refresh_url_flag = True 190 | 191 | def stop(self): 192 | if not hasattr(self.popen_handler, "poll") or self.popen_handler.poll(): 193 | return 194 | 195 | self.playing_flag = False 196 | try: 197 | if not self.popen_handler.poll() and not self.popen_handler.stdin.closed: 198 | self.popen_handler.stdin.write(b"Q\n") 199 | self.popen_handler.stdin.flush() 200 | self.popen_handler.communicate() 201 | self.popen_handler.kill() 202 | except Exception as e: 203 | log.warn(e) 204 | finally: 205 | for thread_i in range(0, len(self.MUSIC_THREADS) - 1): 206 | if self.MUSIC_THREADS[thread_i].is_alive(): 207 | try: 208 | stop_thread(self.MUSIC_THREADS[thread_i]) 209 | except Exception as e: 210 | log.warn(e) 211 | pass 212 | 213 | def tune_volume(self, up=0): 214 | try: 215 | if self.popen_handler.poll(): 216 | return 217 | except Exception as e: 218 | log.warn("Unable to tune volume: " + str(e)) 219 | return 220 | 221 | new_volume = self.info["playing_volume"] + up 222 | # if new_volume > 100: 223 | # new_volume = 100 224 | if new_volume < 0: 225 | new_volume = 0 226 | 227 | self.info["playing_volume"] = new_volume 228 | try: 229 | self.popen_handler.stdin.write( 230 | "V {}\n".format(self.info["playing_volume"]).encode() 231 | ) 232 | self.popen_handler.stdin.flush() 233 | except Exception as e: 234 | log.warn(e) 235 | 236 | def switch(self): 237 | if not self.popen_handler: 238 | return 239 | if self.popen_handler.poll(): 240 | return 241 | self.playing_flag = not self.playing_flag 242 | if not self.popen_handler.stdin.closed: 243 | self.popen_handler.stdin.write(b"P\n") 244 | self.popen_handler.stdin.flush() 245 | 246 | self.playinfo_starts = time.time() 247 | self.build_playinfo() 248 | 249 | def run_mpg123(self, on_exit, url, expires=-1, get_time=-1): 250 | para = ["mpg123", "-R"] + self.config_mpg123 251 | self.popen_handler = subprocess.Popen( 252 | para, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE 253 | ) 254 | 255 | if not url: 256 | self.notify_copyright_issue() 257 | if not self.is_single_loop_mode: 258 | self.next() 259 | else: 260 | self.stop() 261 | return 262 | 263 | self.tune_volume() 264 | try: 265 | self.popen_handler.stdin.write(b"L " + url.encode("utf-8") + b"\n") 266 | self.popen_handler.stdin.flush() 267 | except: 268 | pass 269 | 270 | strout = " " 271 | copyright_issue_flag = False 272 | frame_cnt = 0 273 | while True: 274 | # Check the handler/stdin/stdout 275 | if not hasattr(self.popen_handler, "poll") or self.popen_handler.poll(): 276 | break 277 | if self.popen_handler.stdout.closed: 278 | break 279 | 280 | # try to read the stdout of mpg123 281 | try: 282 | stroutlines = self.popen_handler.stdout.readline() 283 | except Exception as e: 284 | log.warn(e) 285 | break 286 | if not stroutlines: 287 | strout = " " 288 | break 289 | else: 290 | strout_new = stroutlines.decode().strip() 291 | if strout_new[:2] != strout[:2]: 292 | # if status of mpg123 changed 293 | for thread_i in range(0, len(self.MUSIC_THREADS) - 1): 294 | if self.MUSIC_THREADS[thread_i].is_alive(): 295 | try: 296 | stop_thread(self.MUSIC_THREADS[thread_i]) 297 | except Exception as e: 298 | log.warn(e) 299 | 300 | strout = strout_new 301 | 302 | # Update application status according to mpg123 output 303 | if strout[:2] == "@F": 304 | # playing, update progress 305 | out = strout.split(" ") 306 | frame_cnt += 1 307 | self.process_location = float(out[3]) 308 | self.process_length = int(float(out[3]) + float(out[4])) 309 | elif strout[:2] == "@E": 310 | self.playing_flag = True 311 | if ( 312 | expires >= 0 313 | and get_time >= 0 314 | and time.time() - expires - get_time >= 0 315 | ): 316 | # 刷新URL,设 self.refresh_url_flag = True 317 | self.refresh_urls() 318 | else: 319 | # copyright issue raised, next if not single loop 320 | copyright_issue_flag = True 321 | self.notify_copyright_issue() 322 | break 323 | elif strout == "@P 0" and frame_cnt: 324 | # normally end, moving to next 325 | self.playing_flag = True 326 | copyright_issue_flag = False 327 | break 328 | elif strout == "@P 0": 329 | # copyright issue raised, next if not single loop 330 | self.playing_flag = True 331 | copyright_issue_flag = True 332 | self.notify_copyright_issue() 333 | break 334 | 335 | # Ideal behavior: 336 | # if refresh_url_flag are set, then replay. 337 | # if not, do action like following: 338 | # [self.playing_flag, copyright_issue_flag, self.is_single_loop_mode]: function() 339 | # [0, 0, 0]: self.stop() 340 | # [0, 0, 1]: self.stop() 341 | # [0, 1, 0]: self.stop() 342 | # [0, 1, 1]: self.stop() 343 | # [1, 0, 0]: self.next() 344 | # [1, 0, 1]: self.next() 345 | # [1, 1, 0]: self.next() 346 | # [1, 1, 1]: self.stop() 347 | 348 | # Do corresponding action according to status 349 | if self.playing_flag and self.refresh_url_flag: 350 | self.stop() # Will set self.playing_flag = False 351 | # So set the playing_flag here to be True is necessary 352 | # to keep the play/pause status right 353 | self.playing_flag = True 354 | self.start_playing(lambda: 0, self.current_song) 355 | self.refresh_url_flag = False 356 | else: 357 | # When no replay are needed 358 | if not self.playing_flag: 359 | self.stop() 360 | elif copyright_issue_flag and self.is_single_loop_mode: 361 | self.stop() 362 | else: 363 | self.next() 364 | 365 | def download_lyric(self, is_transalted=False): 366 | key = "lyric" if not is_transalted else "tlyric" 367 | 368 | if key not in self.songs[str(self.playing_id)]: 369 | self.songs[str(self.playing_id)][key] = [] 370 | 371 | if len(self.songs[str(self.playing_id)][key]) > 0: 372 | return 373 | 374 | if not is_transalted: 375 | lyric = self.api.song_lyric(self.playing_id) 376 | else: 377 | lyric = self.api.song_tlyric(self.playing_id) 378 | 379 | self.songs[str(self.playing_id)][key] = lyric 380 | 381 | def download_song(self, song_id, song_name, artist, url): 382 | def write_path(song_id, path): 383 | self.songs[str(song_id)]["cache"] = path 384 | 385 | self.cache.add(song_id, song_name, artist, url, write_path) 386 | self.cache.start_download() 387 | 388 | def start_playing(self, on_exit, args): 389 | """ 390 | Runs the given args in subprocess.Popen, and then calls the function 391 | on_exit when the subprocess completes. 392 | on_exit is a callable object, and args is a lists/tuple of args 393 | that would give to subprocess.Popen. 394 | """ 395 | # print(args.get('cache')) 396 | if "cache" in args.keys() and os.path.isfile(args["cache"]): 397 | thread = threading.Thread( 398 | target=self.run_mpg123, args=(on_exit, args["cache"]) 399 | ) 400 | else: 401 | thread = threading.Thread( 402 | target=self.run_mpg123, 403 | args=(on_exit, args["mp3_url"], args["expires"], args["get_time"]), 404 | ) 405 | cache_thread = threading.Thread( 406 | target=self.download_song, 407 | args=( 408 | args["song_id"], 409 | args["song_name"], 410 | args["artist"], 411 | args["mp3_url"], 412 | ), 413 | ) 414 | cache_thread.start() 415 | thread.start() 416 | self.MUSIC_THREADS.append(thread) 417 | self.MUSIC_THREADS = [i for i in self.MUSIC_THREADS if i.is_alive()] 418 | lyric_download_thread = threading.Thread(target=self.download_lyric) 419 | lyric_download_thread.start() 420 | tlyric_download_thread = threading.Thread( 421 | target=self.download_lyric, args=(True,) 422 | ) 423 | tlyric_download_thread.start() 424 | # returns immediately after the thread starts 425 | return thread 426 | 427 | def replay(self): 428 | if not self.is_index_valid: 429 | self.stop() 430 | if self.end_callback: 431 | log.debug("Callback") 432 | self.end_callback() 433 | return 434 | 435 | if not self.current_song: 436 | return 437 | 438 | self.playing_flag = True 439 | self.playinfo_starts = time.time() 440 | self.build_playinfo() 441 | self.notify_playing() 442 | self.start_playing(lambda: 0, self.current_song) 443 | 444 | def shuffle_order(self): 445 | del self.order[:] 446 | self.order.extend(list(range(0, len(self.list)))) 447 | random.shuffle(self.order) 448 | self.info["random_index"] = 0 449 | 450 | def new_player_list(self, type, title, datalist, offset): 451 | self.info["player_list_type"] = type 452 | self.info["player_list_title"] = title 453 | # self.info['idx'] = offset 454 | self.info["player_list"] = [] 455 | self.info["playing_order"] = [] 456 | self.info["random_index"] = 0 457 | self.add_songs(datalist) 458 | 459 | def append_songs(self, datalist): 460 | self.add_songs(datalist) 461 | 462 | # switch_flag为true表示: 463 | # 在播放列表中 || 当前所在列表类型不在"songs"、"djprograms"、"fmsongs"中 464 | def play_or_pause(self, idx, switch_flag): 465 | if self.is_empty: 466 | return 467 | 468 | # if same "list index" and "playing index" --> same song :: pause/resume it 469 | if self.index == idx and switch_flag: 470 | if not self.popen_handler: 471 | self.replay() 472 | else: 473 | self.switch() 474 | else: 475 | self.info["idx"] = idx 476 | self.stop() 477 | self.replay() 478 | 479 | def _swap_song(self): 480 | now_songs = self.order.index(self.index) 481 | self.order[0], self.order[now_songs] = self.order[now_songs], self.order[0] 482 | 483 | def _need_to_shuffle(self): 484 | playing_order = self.order 485 | random_index = self.info["random_index"] 486 | if ( 487 | random_index >= len(playing_order) 488 | or playing_order[random_index] != self.index 489 | ): 490 | return True 491 | else: 492 | return False 493 | 494 | def next_idx(self): 495 | if not self.is_index_valid: 496 | return self.stop() 497 | playlist_len = len(self.list) 498 | 499 | if self.mode == Player.MODE_ORDERED: 500 | # make sure self.index will not over 501 | if self.info["idx"] < playlist_len: 502 | self.info["idx"] += 1 503 | 504 | elif self.mode == Player.MODE_ORDERED_LOOP: 505 | self.info["idx"] = (self.index + 1) % playlist_len 506 | 507 | elif self.mode == Player.MODE_SINGLE_LOOP: 508 | self.info["idx"] = self.info["idx"] 509 | 510 | else: 511 | playing_order_len = len(self.order) 512 | if self._need_to_shuffle(): 513 | self.shuffle_order() 514 | # When you regenerate playing list 515 | # you should keep previous song same. 516 | self._swap_song() 517 | playing_order_len = len(self.order) 518 | 519 | self.info["random_index"] += 1 520 | 521 | # Out of border 522 | if self.mode == Player.MODE_RANDOM_LOOP: 523 | self.info["random_index"] %= playing_order_len 524 | 525 | # Random but not loop, out of border, stop playing. 526 | if self.info["random_index"] >= playing_order_len: 527 | self.info["idx"] = playlist_len 528 | else: 529 | self.info["idx"] = self.order[self.info["random_index"]] 530 | 531 | if self.playing_song_changed_callback is not None: 532 | self.playing_song_changed_callback() 533 | 534 | def next(self): 535 | self.stop() 536 | self.next_idx() 537 | self.replay() 538 | 539 | def prev_idx(self): 540 | if not self.is_index_valid: 541 | self.stop() 542 | return 543 | playlist_len = len(self.list) 544 | 545 | if self.mode == Player.MODE_ORDERED: 546 | if self.info["idx"] > 0: 547 | self.info["idx"] -= 1 548 | 549 | elif self.mode == Player.MODE_ORDERED_LOOP: 550 | self.info["idx"] = (self.info["idx"] - 1) % playlist_len 551 | 552 | elif self.mode == Player.MODE_SINGLE_LOOP: 553 | self.info["idx"] = self.info["idx"] 554 | 555 | else: 556 | playing_order_len = len(self.order) 557 | if self._need_to_shuffle(): 558 | self.shuffle_order() 559 | playing_order_len = len(self.order) 560 | 561 | self.info["random_index"] -= 1 562 | if self.info["random_index"] < 0: 563 | if self.mode == Player.MODE_RANDOM: 564 | self.info["random_index"] = 0 565 | else: 566 | self.info["random_index"] %= playing_order_len 567 | self.info["idx"] = self.order[self.info["random_index"]] 568 | 569 | if self.playing_song_changed_callback is not None: 570 | self.playing_song_changed_callback() 571 | 572 | def prev(self): 573 | self.stop() 574 | self.prev_idx() 575 | self.replay() 576 | 577 | def shuffle(self): 578 | self.stop() 579 | self.info["playing_mode"] = Player.MODE_RANDOM 580 | self.shuffle_order() 581 | self.info["idx"] = self.info["playing_order"][self.info["random_index"]] 582 | self.replay() 583 | 584 | def volume_up(self): 585 | self.tune_volume(5) 586 | 587 | def volume_down(self): 588 | self.tune_volume(-5) 589 | 590 | def update_size(self): 591 | self.ui.update_size() 592 | self.build_playinfo() 593 | 594 | def cache_song(self, song_id, song_name, artist, song_url): 595 | def on_exit(song_id, path): 596 | self.songs[str(song_id)]["cache"] = path 597 | self.cache.enable = False 598 | 599 | self.cache.enable = True 600 | self.cache.add(song_id, song_name, artist, song_url, on_exit) 601 | self.cache.start_download() 602 | -------------------------------------------------------------------------------- /NEMbox/scrollstring.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | from time import time 4 | 5 | 6 | class scrollstring(object): 7 | def __init__(self, content, START): 8 | self.content = content # the true content of the string 9 | self.display = content # the displayed string 10 | self.START = START // 1 # when this instance is created 11 | self.update() 12 | 13 | def update(self): 14 | self.display = self.content 15 | curTime = time() // 1 16 | offset = max(int((curTime - self.START) % len(self.content)) - 1, 0) 17 | while offset > 0: 18 | if self.display[0] > chr(127): 19 | offset -= 1 20 | self.display = self.display[1:] + self.display[:1] 21 | else: 22 | offset -= 1 23 | self.display = self.display[2:] + self.display[:2] 24 | 25 | # self.display = self.content[offset:] + self.content[:offset] 26 | 27 | def __repr__(self): 28 | return self.display 29 | 30 | 31 | # determine the display length of a string 32 | 33 | 34 | def truelen(string): 35 | """ 36 | It appears one Asian character takes two spots, but __len__ 37 | counts it as three, so this function counts the dispalyed 38 | length of the string. 39 | 40 | >>> truelen('abc') 41 | 3 42 | >>> truelen('你好') 43 | 4 44 | >>> truelen('1二3') 45 | 4 46 | >>> truelen('') 47 | 0 48 | """ 49 | return len(string) + sum(1 for c in string if c > chr(127)) 50 | 51 | 52 | def truelen_cut(string, length): 53 | current_length = 0 54 | current_pos = 0 55 | for c in string: 56 | current_length += 2 if c > chr(127) else 1 57 | if current_length > length: 58 | return string[:current_pos] 59 | current_pos += 1 60 | return string 61 | -------------------------------------------------------------------------------- /NEMbox/singleton.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | 4 | class Singleton(object): 5 | """Singleton Class 6 | This is a class to make some class being a Singleton class. 7 | Such as database class or config class. 8 | 9 | usage: 10 | class xxx(Singleton): 11 | def __init__(self): 12 | if hasattr(self, '_init'): 13 | return 14 | self._init = True 15 | other init method 16 | """ 17 | 18 | def __new__(cls, *args, **kwargs): 19 | if not hasattr(cls, "_instance"): 20 | cls._instance = super().__new__(cls, *args, **kwargs) 21 | return cls._instance 22 | -------------------------------------------------------------------------------- /NEMbox/storage.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # @Author: Catofes 3 | # @Date: 2015-08-15 4 | """ 5 | Class to stores everything into a json file. 6 | """ 7 | import json 8 | 9 | from .const import Constant 10 | from .singleton import Singleton 11 | from .utils import utf8_data_to_file 12 | 13 | 14 | class Storage(Singleton): 15 | def __init__(self): 16 | """ 17 | Database stores every info. 18 | 19 | version int 20 | # if value in file is unequal to value defined in this class. 21 | # An database update will be applied. 22 | user dict: 23 | username str 24 | key str 25 | collections list: 26 | collection_info(dict): 27 | collection_name str 28 | collection_type str 29 | collection_describe str 30 | collection_songs list: 31 | song_id(int) 32 | songs dict: 33 | song_id(int) dict: 34 | song_id int 35 | artist str 36 | song_name str 37 | mp3_url str 38 | album_name str 39 | album_id str 40 | quality str 41 | lyric str 42 | tlyric str 43 | player_info dict: 44 | player_list list[dict] 45 | playing_order list[int] 46 | playing_mode int 47 | playing_offset int 48 | 49 | 50 | :return: 51 | """ 52 | if hasattr(self, "_init"): 53 | return 54 | self._init = True 55 | 56 | self.database = { 57 | "user": {"username": "", "password": "", "user_id": "", "nickname": ""}, 58 | "collections": [], 59 | "songs": {}, 60 | "player_info": { 61 | "player_list": [], 62 | "player_list_type": "", 63 | "player_list_title": "", 64 | "playing_order": [], 65 | "playing_mode": 0, 66 | "idx": 0, 67 | "ridx": 0, 68 | "playing_volume": 60, 69 | }, 70 | } 71 | self.storage_path = Constant.storage_path 72 | self.cookie_path = Constant.cookie_path 73 | 74 | def login(self, username, password, userid, nickname): 75 | self.database["user"] = dict( 76 | username=username, 77 | password=password, 78 | user_id=userid, 79 | nickname=nickname, 80 | ) 81 | 82 | def logout(self): 83 | self.database["user"] = { 84 | "username": "", 85 | "password": "", 86 | "user_id": "", 87 | "nickname": "", 88 | } 89 | 90 | def load(self): 91 | try: 92 | with open(self.storage_path, "r") as f: 93 | for k, v in json.load(f).items(): 94 | if isinstance(self.database[k], dict): 95 | self.database[k].update(v) 96 | else: 97 | self.database[k] = v 98 | except (OSError, KeyError, ValueError): 99 | pass 100 | self.save() 101 | 102 | def save(self): 103 | with open(self.storage_path, "w") as f: 104 | data = json.dumps(self.database) 105 | utf8_data_to_file(f, data) 106 | -------------------------------------------------------------------------------- /NEMbox/ui.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # @Author: omi 4 | # @Date: 2014-08-24 21:51:57 5 | # KenHuang: 6 | # 1.增加显示颜色自定义; 7 | # 2.调整显示格式; 8 | """ 9 | 网易云音乐 Ui 10 | """ 11 | import curses 12 | import datetime 13 | import os 14 | import re 15 | from shutil import get_terminal_size 16 | 17 | from . import logger 18 | from .config import Config 19 | from .scrollstring import scrollstring 20 | from .scrollstring import truelen 21 | from .scrollstring import truelen_cut 22 | from .storage import Storage 23 | from .utils import md5 24 | 25 | log = logger.getLogger(__name__) 26 | 27 | try: 28 | import dbus 29 | 30 | dbus_activity = True 31 | except ImportError: 32 | dbus_activity = False 33 | log.warn("Qt dbus module is not installed.") 34 | log.warn("Osdlyrics is not available.") 35 | 36 | 37 | def break_substr(s, start, max_len=80): 38 | if truelen(s) <= max_len: 39 | return s 40 | res = [] 41 | current_truelen = 0 42 | start_pos = 0 43 | end_pos = 0 44 | for c in s: 45 | current_truelen += 2 if c > chr(127) else 1 46 | if current_truelen > max_len: 47 | res.append(s[start_pos:end_pos]) 48 | current_truelen = 0 49 | start_pos = end_pos + 1 50 | end_pos += 1 51 | else: 52 | end_pos += 1 53 | try: 54 | res.append(s[start_pos:end_pos]) 55 | except Exception: 56 | pass 57 | return "\n{}".format(" " * start).join(res) 58 | 59 | 60 | def break_str(s, start, max_len=80): 61 | res = [] 62 | for substr in s.splitlines(): 63 | res.append(break_substr(substr, start, max_len)) 64 | return "\n{}".format(" " * start).join(res) 65 | 66 | 67 | class Ui(object): 68 | def __init__(self): 69 | self.screen = curses.initscr() 70 | curses.start_color() 71 | if Config().get("curses_transparency"): 72 | curses.use_default_colors() 73 | curses.init_pair(1, curses.COLOR_GREEN, -1) 74 | curses.init_pair(2, curses.COLOR_CYAN, -1) 75 | curses.init_pair(3, curses.COLOR_RED, -1) 76 | curses.init_pair(4, curses.COLOR_YELLOW, -1) 77 | else: 78 | colors = Config().get("colors") 79 | if ( 80 | "TERM" in os.environ 81 | and os.environ["TERM"] == "xterm-256color" 82 | and colors 83 | ): 84 | curses.use_default_colors() 85 | for i in range(1, 6): 86 | color = colors["pair" + str(i)] 87 | curses.init_pair(i, color[0], color[1]) 88 | self.screen.bkgd(32, curses.color_pair(5)) 89 | else: 90 | curses.init_pair(1, curses.COLOR_GREEN, curses.COLOR_BLACK) 91 | curses.init_pair(2, curses.COLOR_CYAN, curses.COLOR_BLACK) 92 | curses.init_pair(3, curses.COLOR_RED, curses.COLOR_BLACK) 93 | curses.init_pair(4, curses.COLOR_YELLOW, curses.COLOR_BLACK) 94 | # term resize handling 95 | self.config = Config() 96 | size = get_terminal_size() 97 | self.x = size[0] 98 | self.y = size[1] 99 | self.playerX = 1 # terminalsize.get_terminal_size()[1] - 10 100 | self.playerY = 0 101 | self.update_margin() 102 | self.update_space() 103 | self.lyric = "" 104 | self.now_lyric = "" 105 | self.post_lyric = "" 106 | self.now_lyric_index = 0 107 | self.now_tlyric_index = 0 108 | self.tlyric = "" 109 | self.storage = Storage() 110 | self.newversion = False 111 | 112 | def addstr(self, *args): 113 | if len(args) == 1: 114 | self.screen.addstr(args[0]) 115 | else: 116 | try: 117 | self.screen.addstr(args[0], args[1], args[2].encode("utf-8"), *args[3:]) 118 | except Exception as e: 119 | log.error(e) 120 | 121 | def update_margin(self): 122 | # Left margin 123 | self.left_margin_ratio = self.config.get("left_margin_ratio") 124 | if self.left_margin_ratio == 0: 125 | self.startcol = 0 126 | else: 127 | self.startcol = max(int(float(self.x) / self.left_margin_ratio), 0) 128 | self.indented_startcol = max(self.startcol - 3, 0) 129 | # Right margin 130 | self.right_margin_ratio = self.config.get("right_margin_ratio") 131 | if self.right_margin_ratio == 0: 132 | self.endcol = 0 133 | else: 134 | self.endcol = max( 135 | int(float(self.x) - float(self.x) / self.right_margin_ratio), 136 | self.startcol + 1, 137 | ) 138 | 139 | self.indented_endcol = max(self.endcol - 3, 0) 140 | self.content_width = self.endcol - self.startcol - 1 141 | 142 | def build_playinfo( 143 | self, song_name, artist, album_name, quality, start, pause=False 144 | ): 145 | curses.noecho() 146 | curses.curs_set(0) 147 | # refresh top 2 line 148 | self.screen.move(1, 1) 149 | self.screen.clrtoeol() 150 | self.screen.move(2, 1) 151 | self.screen.clrtoeol() 152 | if pause: 153 | self.addstr( 154 | 1, self.indented_startcol, "_ _ z Z Z " + quality, curses.color_pair(3) 155 | ) 156 | else: 157 | self.addstr( 158 | 1, self.indented_startcol, "♫ ♪ ♫ ♪ " + quality, curses.color_pair(3) 159 | ) 160 | 161 | if artist or album_name: 162 | song_info = "{}{}{} < {} >".format( 163 | song_name, 164 | self.space, 165 | artist, 166 | album_name, 167 | ) 168 | else: 169 | song_info = song_name 170 | 171 | if truelen(song_info) <= self.endcol - self.indented_startcol - 19: 172 | self.addstr( 173 | 1, 174 | min(self.indented_startcol + 18, self.indented_endcol - 1), 175 | song_info, 176 | curses.color_pair(4), 177 | ) 178 | else: 179 | song_info = scrollstring(song_info + " ", start) 180 | self.addstr( 181 | 1, 182 | min(self.indented_startcol + 18, self.indented_endcol - 1), 183 | truelen_cut(str(song_info), self.endcol - self.indented_startcol - 19), 184 | curses.color_pair(4), 185 | ) 186 | 187 | self.screen.refresh() 188 | 189 | def update_lyrics(self, now_playing, lyrics, tlyrics): 190 | 191 | timestap_regex = r"[0-5][0-9]:[0-5][0-9]\.[0-9]*" 192 | 193 | def get_timestap(lyric_line): 194 | match_ret = re.match(r"\[(" + timestap_regex + r")\]", lyric_line) 195 | if match_ret: 196 | return match_ret.group(1) 197 | else: 198 | return "" 199 | 200 | def get_lyric_time(lyric_line): 201 | lyric_timestap = get_timestap(lyric_line) 202 | if lyric_timestap == "": 203 | return datetime.timedelta(seconds=now_playing) 204 | else: 205 | return ( 206 | datetime.datetime.strptime(get_timestap(lyric_line), "%M:%S.%f") 207 | - datetime.datetime.strptime("00:00", "%M:%S") 208 | - lyric_time_offset 209 | ) 210 | 211 | def strip_timestap(lyric_line): 212 | return re.sub(r"\[" + timestap_regex + r"\]", r"", lyric_line) 213 | 214 | def append_translation(translated_lyric, origin_lyric): 215 | translated_lyric = strip_timestap(translated_lyric) 216 | origin_lyric = strip_timestap(origin_lyric) 217 | if translated_lyric == "" or origin_lyric == "": 218 | return translated_lyric + origin_lyric 219 | return translated_lyric + " || " + origin_lyric 220 | 221 | if ( 222 | tlyrics and self.now_tlyric_index >= len(tlyrics) - 1 223 | ) or self.now_lyric_index >= len(lyrics) - 1: 224 | self.post_lyric = "" 225 | return 226 | 227 | lyric_time_offset = datetime.timedelta(seconds=0.5) 228 | next_lyric_time = get_lyric_time(lyrics[self.now_lyric_index + 1]) 229 | # now_lyric_time = get_lyric_time(lyrics[self.now_lyric_index]) 230 | now_time = datetime.timedelta(seconds=now_playing) 231 | while now_time >= next_lyric_time and self.now_lyric_index < len(lyrics) - 2: 232 | self.now_lyric_index = self.now_lyric_index + 1 233 | next_lyric_time = get_lyric_time(lyrics[self.now_lyric_index + 1]) 234 | 235 | if tlyrics: 236 | next_tlyric_time = get_lyric_time(tlyrics[self.now_tlyric_index + 1]) 237 | while ( 238 | now_time >= next_tlyric_time 239 | and self.now_tlyric_index < len(tlyrics) - 2 240 | ): 241 | self.now_tlyric_index = self.now_tlyric_index + 1 242 | next_tlyric_time = get_lyric_time(tlyrics[self.now_tlyric_index + 1]) 243 | 244 | if tlyrics: 245 | self.now_lyric = append_translation( 246 | tlyrics[self.now_tlyric_index], lyrics[self.now_lyric_index] 247 | ) 248 | if ( 249 | self.now_tlyric_index < len(tlyrics) - 1 250 | and self.now_lyric_index < len(lyrics) - 1 251 | ): 252 | self.post_lyric = append_translation( 253 | tlyrics[self.now_tlyric_index + 1], lyrics[self.now_lyric_index + 1] 254 | ) 255 | else: 256 | self.post_lyric = "" 257 | else: 258 | self.now_lyric = strip_timestap(lyrics[self.now_lyric_index]) 259 | if self.now_lyric_index < len(lyrics) - 1: 260 | self.post_lyric = strip_timestap(lyrics[self.now_lyric_index + 1]) 261 | else: 262 | self.post_lyric = "" 263 | 264 | def build_process_bar( 265 | self, song, now_playing, total_length, playing_flag, playing_mode 266 | ): 267 | 268 | if not song or not playing_flag: 269 | return 270 | name, artist = song["song_name"], song["artist"] 271 | lyrics, tlyrics = song.get("lyric", []), song.get("tlyric", []) 272 | 273 | curses.noecho() 274 | curses.curs_set(0) 275 | self.screen.move(3, 1) 276 | self.screen.clrtoeol() 277 | self.screen.move(4, 1) 278 | self.screen.clrtoeol() 279 | self.screen.move(5, 1) 280 | self.screen.clrtoeol() 281 | self.screen.move(6, 1) 282 | self.screen.clrtoeol() 283 | if total_length <= 0: 284 | total_length = 1 285 | if now_playing > total_length or now_playing <= 0: 286 | now_playing = 0 287 | if int(now_playing) == 0: 288 | self.now_lyric_index = 0 289 | if tlyrics: 290 | self.now_tlyric_index = 0 291 | self.now_lyric = "" 292 | self.post_lyric = "" 293 | process = "[" 294 | process_bar_width = self.content_width - 24 295 | for i in range(0, process_bar_width): 296 | if i < now_playing / total_length * process_bar_width: 297 | if (i + 1) > now_playing / total_length * process_bar_width: 298 | if playing_flag: 299 | process += ">" 300 | continue 301 | process += "=" 302 | else: 303 | process += " " 304 | process += "] " 305 | 306 | now = str(datetime.timedelta(seconds=int(now_playing))).lstrip("0").lstrip(":") 307 | total = str(datetime.timedelta(seconds=total_length)).lstrip("0").lstrip(":") 308 | process += "({}/{})".format(now, total) 309 | 310 | if playing_mode == 0: 311 | process = "顺序播放 " + process 312 | elif playing_mode == 1: 313 | process = "顺序循环 " + process 314 | elif playing_mode == 2: 315 | process = "单曲循环 " + process 316 | elif playing_mode == 3: 317 | process = "随机播放 " + process 318 | elif playing_mode == 4: 319 | process = "随机循环 " + process 320 | else: 321 | pass 322 | self.addstr(3, self.startcol - 2, process, curses.color_pair(1)) 323 | if not lyrics: 324 | self.now_lyric = "暂无歌词 ~>_<~ \n" 325 | self.post_lyric = "" 326 | if dbus_activity and self.config.get("osdlyrics"): 327 | self.now_playing = "{} - {}\n".format(name, artist) 328 | else: 329 | self.update_lyrics(now_playing, lyrics, tlyrics) 330 | 331 | if dbus_activity and self.config.get("osdlyrics"): 332 | try: 333 | bus = dbus.SessionBus().get_object("org.musicbox.Bus", "/") 334 | # TODO 环境问题,没有试过桌面歌词,此处需要了解的人加个刷界面操作 335 | if self.now_lyric == "暂无歌词 ~>_<~ \n": 336 | bus.refresh_lyrics( 337 | self.now_playing, dbus_interface="local.musicbox.Lyrics" 338 | ) 339 | else: 340 | bus.refresh_lyrics( 341 | self.now_lyric, dbus_interface="local.musicbox.Lyrics" 342 | ) 343 | except Exception as e: 344 | log.error(e) 345 | pass 346 | # 根据索引计算双行歌词的显示,其中当前歌词颜色为红色,下一句歌词颜色为白色; 347 | # 当前歌词从下一句歌词刷新颜色变换,所以当前歌词和下一句歌词位置会交替 348 | if self.now_lyric_index % 2 == 0: 349 | self.addstr( 350 | 4, max(self.startcol - 2, 0), str(self.now_lyric), curses.color_pair(3) 351 | ) 352 | self.addstr( 353 | 5, max(self.startcol + 1, 0), str(self.post_lyric), curses.A_DIM 354 | ) 355 | else: 356 | self.addstr( 357 | 4, max(self.startcol - 2, 0), str(self.post_lyric), curses.A_DIM 358 | ) 359 | self.addstr( 360 | 5, max(self.startcol + 1, 0), str(self.now_lyric), curses.color_pair(3) 361 | ) 362 | self.screen.refresh() 363 | 364 | def build_loading(self): 365 | curses.curs_set(0) 366 | self.addstr(7, self.startcol, "享受高品质音乐,loading...", curses.color_pair(1)) 367 | self.screen.refresh() 368 | 369 | def build_submenu(self, data): 370 | pass 371 | 372 | # start is the called timestamp of this function 373 | def build_menu(self, datatype, title, datalist, offset, index, step, start): 374 | # keep playing info in line 1 375 | curses.noecho() 376 | curses.curs_set(0) 377 | self.screen.move(7, 1) 378 | self.screen.clrtobot() 379 | self.addstr(7, self.startcol, title, curses.color_pair(1)) 380 | 381 | if len(datalist) == 0: 382 | self.addstr(8, self.startcol, "这里什么都没有 -,-") 383 | return self.screen.refresh() 384 | 385 | if datatype == "main": 386 | for i in range(offset, min(len(datalist), offset + step)): 387 | if i == index: 388 | self.addstr( 389 | i - offset + 9, 390 | self.indented_startcol, 391 | "-> " + str(i) + ". " + datalist[i]["entry_name"], 392 | curses.color_pair(2), 393 | ) 394 | else: 395 | self.addstr( 396 | i - offset + 9, 397 | self.startcol, 398 | str(i) + ". " + datalist[i]["entry_name"], 399 | ) 400 | 401 | elif datatype == "songs" or datatype == "djprograms" or datatype == "fmsongs": 402 | iter_range = min(len(datalist), offset + step) 403 | for i in range(offset, iter_range): 404 | if isinstance(datalist[i], str): 405 | raise ValueError(datalist) 406 | # this item is focus 407 | if i == index: 408 | self.addstr(i - offset + 9, 0, " " * self.startcol) 409 | lead = "-> " + str(i) + ". " 410 | self.addstr( 411 | i - offset + 9, 412 | self.indented_startcol, 413 | lead, 414 | curses.color_pair(2), 415 | ) 416 | name = "{}{}{} < {} >".format( 417 | datalist[i]["song_name"], 418 | self.space, 419 | datalist[i]["artist"], 420 | datalist[i]["album_name"], 421 | ) 422 | 423 | # the length decides whether to scroll 424 | if truelen(name) < self.content_width: 425 | self.addstr( 426 | i - offset + 9, 427 | self.indented_startcol + len(lead), 428 | name, 429 | curses.color_pair(2), 430 | ) 431 | else: 432 | name = scrollstring(name + " ", start) 433 | self.addstr( 434 | i - offset + 9, 435 | self.indented_startcol + len(lead), 436 | truelen_cut( 437 | str(name), self.content_width - len(str(i)) - 2 438 | ), 439 | curses.color_pair(2), 440 | ) 441 | else: 442 | self.addstr(i - offset + 9, 0, " " * self.startcol) 443 | self.addstr( 444 | i - offset + 9, 445 | self.startcol, 446 | truelen_cut( 447 | "{}. {}{}{} < {} >".format( 448 | i, 449 | datalist[i]["song_name"], 450 | self.space, 451 | datalist[i]["artist"], 452 | datalist[i]["album_name"], 453 | ), 454 | self.content_width, 455 | ), 456 | ) 457 | 458 | self.addstr(iter_range - offset + 9, 0, " " * self.x) 459 | 460 | elif datatype == "comments": 461 | # 被选中的评论在最下方显示全部字符,其余评论仅显示一行 462 | for i in range(offset, min(len(datalist), offset + step)): 463 | maxlength = min( 464 | self.content_width, truelen(datalist[i]["comment_content"]) 465 | ) 466 | if i == index: 467 | self.addstr( 468 | i - offset + 9, 469 | self.indented_startcol, 470 | truelen_cut( 471 | "-> " 472 | + str(i) 473 | + ". " 474 | + datalist[i]["comment_content"].splitlines()[0], 475 | self.content_width + len("-> " + str(i)), 476 | ), 477 | curses.color_pair(2), 478 | ) 479 | self.addstr( 480 | step + 10, 481 | self.indented_startcol, 482 | "-> " 483 | + str(i) 484 | + ". " 485 | + datalist[i]["comment_content"].split(":", 1)[0] 486 | + ":", 487 | curses.color_pair(2), 488 | ) 489 | self.addstr( 490 | step + 12, 491 | self.startcol + (len(str(i)) + 2), 492 | break_str( 493 | datalist[i]["comment_content"].split(":", 1)[1][1:], 494 | self.startcol + (len(str(i)) + 2), 495 | maxlength, 496 | ), 497 | curses.color_pair(2), 498 | ) 499 | else: 500 | self.addstr( 501 | i - offset + 9, 502 | self.startcol, 503 | truelen_cut( 504 | str(i) 505 | + ". " 506 | + datalist[i]["comment_content"].splitlines()[0], 507 | self.content_width, 508 | ), 509 | ) 510 | 511 | elif datatype == "artists": 512 | for i in range(offset, min(len(datalist), offset + step)): 513 | if i == index: 514 | self.addstr( 515 | i - offset + 9, 516 | self.indented_startcol, 517 | "-> " 518 | + str(i) 519 | + ". " 520 | + datalist[i]["artists_name"] 521 | + self.space 522 | + str(datalist[i]["alias"]), 523 | curses.color_pair(2), 524 | ) 525 | else: 526 | self.addstr( 527 | i - offset + 9, 528 | self.startcol, 529 | str(i) 530 | + ". " 531 | + datalist[i]["artists_name"] 532 | + self.space 533 | + datalist[i]["alias"], 534 | ) 535 | 536 | elif datatype == "artist_info": 537 | for i in range(offset, min(len(datalist), offset + step)): 538 | if i == index: 539 | self.addstr( 540 | i - offset + 9, 541 | self.indented_startcol, 542 | "-> " + str(i) + ". " + datalist[i]["item"], 543 | curses.color_pair(2), 544 | ) 545 | else: 546 | self.addstr( 547 | i - offset + 9, 548 | self.startcol, 549 | str(i) + ". " + datalist[i]["item"], 550 | ) 551 | 552 | elif datatype == "albums": 553 | for i in range(offset, min(len(datalist), offset + step)): 554 | if i == index: 555 | self.addstr( 556 | i - offset + 9, 557 | self.indented_startcol, 558 | "-> " 559 | + str(i) 560 | + ". " 561 | + datalist[i]["albums_name"] 562 | + self.space 563 | + datalist[i]["artists_name"], 564 | curses.color_pair(2), 565 | ) 566 | else: 567 | self.addstr( 568 | i - offset + 9, 569 | self.startcol, 570 | str(i) 571 | + ". " 572 | + datalist[i]["albums_name"] 573 | + self.space 574 | + datalist[i]["artists_name"], 575 | ) 576 | 577 | elif datatype == "recommend_lists": 578 | for i in range(offset, min(len(datalist), offset + step)): 579 | if i == index: 580 | self.addstr( 581 | i - offset + 9, 582 | self.indented_startcol, 583 | "-> " + str(i) + ". " + datalist[i]["title"], 584 | curses.color_pair(2), 585 | ) 586 | else: 587 | self.addstr( 588 | i - offset + 9, 589 | self.startcol, 590 | str(i) + ". " + datalist[i]["title"], 591 | ) 592 | 593 | elif datatype in ("top_playlists", "playlists"): 594 | for i in range(offset, min(len(datalist), offset + step)): 595 | if i == index: 596 | self.addstr( 597 | i - offset + 9, 598 | self.indented_startcol, 599 | "-> " 600 | + str(i) 601 | + ". " 602 | + datalist[i]["playlist_name"] 603 | + self.space 604 | + datalist[i]["creator_name"], 605 | curses.color_pair(2), 606 | ) 607 | else: 608 | self.addstr( 609 | i - offset + 9, 610 | self.startcol, 611 | str(i) 612 | + ". " 613 | + datalist[i]["playlist_name"] 614 | + self.space 615 | + datalist[i]["creator_name"], 616 | ) 617 | 618 | elif datatype in ("toplists", "playlist_classes", "playlist_class_detail"): 619 | for i in range(offset, min(len(datalist), offset + step)): 620 | if i == index: 621 | self.addstr( 622 | i - offset + 9, 623 | self.indented_startcol, 624 | "-> " + str(i) + ". " + datalist[i], 625 | curses.color_pair(2), 626 | ) 627 | else: 628 | self.addstr( 629 | i - offset + 9, self.startcol, str(i) + ". " + datalist[i] 630 | ) 631 | 632 | elif datatype == "djRadios": 633 | for i in range(offset, min(len(datalist), offset + step)): 634 | if i == index: 635 | self.addstr( 636 | i - offset + 8, 637 | self.indented_startcol, 638 | "-> " + str(i) + ". " + datalist[i]["name"], 639 | curses.color_pair(2), 640 | ) 641 | else: 642 | self.addstr( 643 | i - offset + 8, 644 | self.startcol, 645 | str(i) + ". " + datalist[i]["name"], 646 | ) 647 | 648 | elif datatype == "search": 649 | self.screen.move(6, 1) 650 | self.screen.clrtobot() 651 | self.screen.timeout(-1) 652 | self.addstr(8, self.startcol, "选择搜索类型:", curses.color_pair(1)) 653 | for i in range(offset, min(len(datalist), offset + step)): 654 | if i == index: 655 | self.addstr( 656 | i - offset + 10, 657 | self.indented_startcol, 658 | "-> " + str(i) + "." + datalist[i - 1], 659 | curses.color_pair(2), 660 | ) 661 | else: 662 | self.addstr( 663 | i - offset + 10, self.startcol, str(i) + "." + datalist[i - 1] 664 | ) 665 | self.screen.timeout(100) 666 | 667 | elif datatype == "help": 668 | for i in range(offset, min(len(datalist), offset + step)): 669 | if i == index: 670 | self.addstr( 671 | i - offset + 9, 672 | self.indented_startcol, 673 | "-> {}. '{}{} {}".format( 674 | i, 675 | (datalist[i][0] + "'").ljust(11), 676 | datalist[i][1].ljust(16), 677 | datalist[i][2], 678 | ), 679 | curses.color_pair(2), 680 | ) 681 | else: 682 | self.addstr( 683 | i - offset + 9, 684 | self.startcol, 685 | "{}. '{}{} {}".format( 686 | i, 687 | (datalist[i][0] + "'").ljust(11), 688 | datalist[i][1].ljust(16), 689 | datalist[i][2], 690 | ), 691 | ) 692 | 693 | self.addstr( 694 | 20, self.startcol, "NetEase-MusicBox 基于Python,所有版权音乐来源于网易,本地不做任何保存" 695 | ) 696 | self.addstr(21, self.startcol, "按 [G] 到 Github 了解更多信息,帮助改进,或者Star表示支持~~") 697 | self.addstr(22, self.startcol, "Build with love to music by omi") 698 | 699 | self.screen.refresh() 700 | 701 | def build_login(self): 702 | curses.curs_set(0) 703 | self.build_login_bar() 704 | account = self.get_account() 705 | password = md5(self.get_password()) 706 | return account, password 707 | 708 | def build_login_bar(self): 709 | curses.curs_set(0) 710 | curses.noecho() 711 | self.screen.move(4, 1) 712 | self.screen.clrtobot() 713 | self.addstr(5, self.startcol, "请输入登录信息(支持手机登录)", curses.color_pair(1)) 714 | self.addstr(8, self.startcol, "账号:", curses.color_pair(1)) 715 | self.addstr(9, self.startcol, "密码:", curses.color_pair(1)) 716 | self.screen.move(8, 24) 717 | self.screen.refresh() 718 | 719 | def build_login_error(self): 720 | curses.curs_set(0) 721 | self.screen.move(4, 1) 722 | self.screen.timeout(-1) # disable the screen timeout 723 | self.screen.clrtobot() 724 | self.addstr(8, self.startcol, "艾玛,登录信息好像不对呢 (O_O)#", curses.color_pair(1)) 725 | self.addstr(10, self.startcol, "[1] 再试一次") 726 | self.addstr(11, self.startcol, "[2] 稍后再试") 727 | self.addstr(14, self.startcol, "请键入对应数字:", curses.color_pair(2)) 728 | self.screen.refresh() 729 | x = self.screen.getch() 730 | self.screen.timeout(100) # restore the screen timeout 731 | return x 732 | 733 | def build_search_error(self): 734 | curses.curs_set(0) 735 | self.screen.move(4, 1) 736 | self.screen.timeout(-1) 737 | self.screen.clrtobot() 738 | self.addstr(8, self.startcol, "是不支持的搜索类型呢...", curses.color_pair(3)) 739 | self.addstr(9, self.startcol, "(在做了,在做了,按任意键关掉这个提示)", curses.color_pair(3)) 740 | self.screen.refresh() 741 | x = self.screen.getch() 742 | self.screen.timeout(100) 743 | return x 744 | 745 | def build_timing(self): 746 | curses.curs_set(0) 747 | self.screen.move(6, 1) 748 | self.screen.clrtobot() 749 | self.screen.timeout(-1) 750 | self.addstr(8, self.startcol, "输入定时时间(min):", curses.color_pair(1)) 751 | self.addstr(11, self.startcol, "ps:定时时间为整数,输入0代表取消定时退出", curses.color_pair(1)) 752 | self.screen.timeout(-1) # disable the screen timeout 753 | curses.echo() 754 | timing_time = self.screen.getstr(8, self.startcol + 19, 60) 755 | self.screen.timeout(100) # restore the screen timeout 756 | return timing_time 757 | 758 | def get_account(self): 759 | self.screen.timeout(-1) # disable the screen timeout 760 | curses.echo() 761 | account = self.screen.getstr(8, self.startcol + 6, 60) 762 | self.screen.timeout(100) # restore the screen timeout 763 | return account.decode("utf-8") 764 | 765 | def get_password(self): 766 | self.screen.timeout(-1) # disable the screen timeout 767 | curses.noecho() 768 | password = self.screen.getstr(9, self.startcol + 6, 60) 769 | self.screen.timeout(100) # restore the screen timeout 770 | return password.decode("utf-8") 771 | 772 | def get_param(self, prompt_string): 773 | # keep playing info in line 1 774 | curses.echo() 775 | self.screen.move(4, 1) 776 | self.screen.clrtobot() 777 | self.addstr(5, self.startcol, prompt_string, curses.color_pair(1)) 778 | self.screen.refresh() 779 | keyword = self.screen.getstr(10, self.startcol, 60) 780 | return keyword.decode("utf-8").strip() 781 | 782 | def update_size(self): 783 | curses.curs_set(0) 784 | # get terminal size 785 | size = get_terminal_size() 786 | x = size[0] 787 | y = size[1] 788 | if (x, y) == (self.x, self.y): # no need to resize 789 | return 790 | self.x, self.y = x, y 791 | 792 | # update intendations 793 | curses.resizeterm(self.y, self.x) 794 | self.update_margin() 795 | self.update_space() 796 | self.screen.clear() 797 | self.screen.refresh() 798 | 799 | def update_space(self): 800 | if self.x > 140: 801 | self.space = " - " 802 | elif self.x > 80: 803 | self.space = " - " 804 | else: 805 | self.space = " - " 806 | self.screen.refresh() 807 | -------------------------------------------------------------------------------- /NEMbox/utils.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # utils.py --- utils for musicbox 4 | # Copyright (c) 2015-2016 omi & Contributors 5 | """ 6 | 定义几个函数 写文件 通知 返回键 创建目录 创建文件 7 | """ 8 | import os 9 | import platform 10 | import subprocess 11 | import hashlib 12 | 13 | from collections import OrderedDict 14 | 15 | 16 | __all__ = [ 17 | "utf8_data_to_file", 18 | "notify", 19 | "uniq", 20 | "create_dir", 21 | "create_file", 22 | "md5", 23 | ] 24 | 25 | 26 | def md5(s): 27 | return hashlib.md5(s.encode("utf-8")).hexdigest() 28 | 29 | 30 | def mkdir(path): 31 | try: 32 | os.mkdir(path) 33 | return True 34 | except OSError: 35 | return False 36 | 37 | 38 | def create_dir(path): 39 | if not os.path.exists(path): 40 | return mkdir(path) 41 | elif os.path.isdir(path): 42 | return True 43 | else: 44 | os.remove(path) 45 | return mkdir(path) 46 | 47 | 48 | def create_file(path, default="\n"): 49 | if not os.path.exists(path): 50 | with open(path, "w") as f: 51 | f.write(default) 52 | 53 | 54 | def uniq(arr): 55 | return list(OrderedDict.fromkeys(arr).keys()) 56 | 57 | 58 | def utf8_data_to_file(f, data): 59 | if hasattr(data, "decode"): 60 | f.write(data.decode("utf-8")) 61 | else: 62 | f.write(data) 63 | 64 | 65 | def notify_command_osx(msg, msg_type, duration_time=None): 66 | command = ["/usr/bin/osascript", "-e"] 67 | tpl = 'display notification "{}" {} with title "musicbox"' 68 | sound = 'sound name "/System/Library/Sounds/Ping.aiff"' if msg_type else "" 69 | command.append(tpl.format(msg, sound).encode("utf-8")) 70 | return command 71 | 72 | 73 | def notify_command_linux(msg, duration_time=None): 74 | command = ["/usr/bin/notify-send"] 75 | command.append(msg.encode("utf-8")) 76 | if duration_time: 77 | command.extend(["-t", str(duration_time)]) 78 | command.extend(["-h", "int:transient:1"]) 79 | return command 80 | 81 | 82 | def notify(msg, msg_type=0, duration_time=None): 83 | """Show system notification with duration t (ms)""" 84 | msg = msg.replace('"', '\\"') 85 | if platform.system() == "Darwin": 86 | command = notify_command_osx(msg, msg_type, duration_time) 87 | else: 88 | command = notify_command_linux(msg, duration_time) 89 | 90 | try: 91 | subprocess.call(command) 92 | return True 93 | except OSError: 94 | return False 95 | 96 | 97 | if __name__ == "__main__": 98 | notify('I\'m test ""quote', msg_type=1, duration_time=1000) 99 | notify("I'm test 1", msg_type=1, duration_time=1000) 100 | notify("I'm test 2", msg_type=0, duration_time=1000) 101 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NetEase-MusicBox 2 | 3 | **感谢为 MusicBox 的开发付出过努力的[每一个人](https://github.com/darknessomi/musicbox/graphs/contributors)!** 4 | 5 | 高品质网易云音乐命令行版本,简洁优雅,丝般顺滑,基于 Python 编写。 6 | 7 | [![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg)](LICENSE) 8 | [![versions](https://img.shields.io/pypi/v/NetEase-MusicBox.svg)](https://pypi.org/project/NetEase-MusicBox/) 9 | [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/NetEase-MusicBox.svg)](https://pypi.org/project/NetEase-MusicBox/) 10 | 11 | ## Demo 12 | 13 | [![NetEase-MusicBox-GIF](https://qfile.aobeef.cn/3abba3b8a3994ee3d5cd.gif)](https://pypi.org/project/NetEase-MusicBox/) 14 | 15 | ## 功能特性 16 | 17 | 1. 320kbps 的高品质音乐 18 | 2. 歌曲,艺术家,专辑检索 19 | 3. 网易 22 个歌曲排行榜 20 | 4. 网易新碟推荐 21 | 5. 网易精选歌单 22 | 6. 网易主播电台 23 | 7. 私人歌单,每日推荐 24 | 8. 随心打碟 25 | 9. 本地收藏,随时加 ❤ 26 | 10. 播放进度及播放模式显示 27 | 11. 现在播放及桌面歌词显示 28 | 12. 歌曲评论显示 29 | 13. 一键进入歌曲专辑 30 | 14. 定时退出 31 | 15. Vimer 式快捷键让操作丝般顺滑 32 | 16. 可使用数字快捷键 33 | 17. 可使用自定义全局快捷键 34 | 18. 对当前歌单列表进行本地模糊搜索 35 | 36 | ### 键盘快捷键 37 | 38 | 有 num + 字样的快捷键可以用数字修饰,按键顺序为先输入数字再键入被修饰的键,即 num + 后的快捷键。 39 | 40 | | Key | Effect | | 41 | | ------------------------------------- | --------------- | ------------------ | 42 | | j | Down | 下移 | 43 | | k | Up | 上移 | 44 | | num + j | Quick Jump | 快速向后跳转 n 首 | 45 | | num + k | Quick Up | 快速向前跳转 n 首 | 46 | | h | Back | 后退 | 47 | | l | Forword | 前进 | 48 | | u | Prev Page | 上一页 | 49 | | d | Next Page | 下一页 | 50 | | f | Search | 当前列表模糊搜索 | 51 | | \[ | Prev Song | 上一曲 | 52 | | ] | Next Song | 下一曲 | 53 | | num + \[ | Quick Prev Song | 快速前 n 首 | 54 | | num + ] | Quick Next Song | 快速后 n 首 | 55 | | num + Shift + g | Index for Song | 跳到第 n 首 | 56 | | = | Volume + | 音量增加 | 57 | | - | Volume - | 音量减少 | 58 | | Space | Play/Pause | 播放/暂停 | 59 | | ? | Shuffle | 手气不错 | 60 | | m | Menu | 主菜单 | 61 | | p | Present/History | 当前/历史播放列表 | 62 | | i | Music Info | 当前音乐信息 | 63 | | Shift + p | Playing Mode | 播放模式切换 | 64 | | a | Add | 添加曲目到打碟 | 65 | | Shift + a | Enter Album | 进入专辑 | 66 | | g | To the First | 跳至首项 | 67 | | Shift + g | To the End | 跳至尾项 | 68 | | z | DJ List | 打碟列表 | 69 | | s | Star | 添加到收藏 | 70 | | c | Collection | 收藏列表 | 71 | | r | Remove | 删除当前条目 | 72 | | Shift + j | Move Down | 向下移动当前项目 | 73 | | Shift + k | Move Up | 向上移动当前项目 | 74 | | Shift + c | Cache | 缓存歌曲到本地 | 75 | | , | Like | 喜爱 | 76 | | . | Trash FM | 删除 FM | 77 | | / | Next FM | 下一 FM | 78 | | q | Quit | 退出 | 79 | | t | Timing Exit | 定时退出 | 80 | | w | Quit & Clear | 退出并清除用户信息 | 81 | 82 | ## 安装 83 | 84 | ### 必选依赖 85 | 86 | 1. `mpg123` 用于播放歌曲,安装方法参见下面的说明 87 | 2. `python-fuzzywuzzy` 用于模糊搜索 88 | 89 | ### 可选依赖 90 | 91 | 1. `aria2` 用于缓存歌曲 92 | 2. `libnotify-bin` 用于支持消息提示(Linux 平台) 93 | 3. `qtpy python-dbus dbus qt` 用于支持桌面歌词 94 | (根据系统 qt 的版本还需要安装 pyqt4 pyqt4 pyside pyside2 中的任意一个) 95 | 4. `python-levenshtein` 用于模糊搜索 96 | 97 | ### PyPi 安装(\*nix 系统) 98 | 99 | ```bash 100 | pip3 install NetEase-MusicBox 101 | ``` 102 | 103 | ### Git clone 安装 master 分支(\*nix 系统) 104 | 105 | ```bash 106 | git clone https://github.com/darknessomi/musicbox.git && cd musicbox 107 | poetry build && poetry install 108 | ``` 109 | 110 | ### macOS 安装 111 | 112 | ```bash 113 | pip3 install NetEase-MusicBox 114 | brew install mpg123 115 | ``` 116 | 117 | ### Linux 安装 118 | 119 | **注意:通过以下方法安装可能仍然需要`pip3 install -U NetEase-MusicBox`更新到最新版**。 120 | 121 | #### Fedora 122 | 123 | 首先添加[FZUG](https://github.com/FZUG/repo/wiki)源,然后`sudo dnf install musicbox`。 124 | 125 | #### Ubuntu/Debian 126 | 127 | ```bash 128 | pip3 install NetEase-MusicBox 129 | sudo apt-get install mpg123 130 | ``` 131 | 132 | #### Arch Linux 133 | 134 | ```bash 135 | pacaur -S netease-musicbox-git # or $ yaourt musicbox 136 | ``` 137 | 138 | #### Centos/Red Hat 139 | 140 | ```bash 141 | sudo yum install -y python3-devel 142 | pip3 install NetEase-MusicBox 143 | wget http://mirror.centos.org/centos/7/os/x86_64/Packages/mpg123-1.25.6-1.el7.x86_64.rpm 144 | sudo yum install -y mpg123-1.25.6-1.el7.x86_64.rpm 145 | ``` 146 | 147 | ## 配置和错误处理 148 | 149 | 配置文件地址: `~/.config/netease-musicbox/config.json` 150 | 可配置缓存,快捷键,消息,桌面歌词。 151 | 由于歌曲 API 只接受中国大陆地区访问,非中国大陆地区用户请自行设置代理(可用 polipo 将 socks5 代理转换成 http 代理): 152 | 153 | ```bash 154 | export http_proxy=http://IP:PORT 155 | export https_proxy=http://IP:PORT 156 | curl -L ip.cn 157 | ``` 158 | 159 | 显示 IP 属于中国大陆地区即可。 160 | 161 | ### 已测试的系统兼容列表 162 | 163 | | OS | Version | 164 | | ----- | ------- | 165 | | Arch | Rolling | 166 | | macOS | 10.15.7 | 167 | 168 | ### 错误处理 169 | 170 | 当某些歌曲不能播放时,总时长为 00:01 时,请检查是否为版权问题导致。 171 | 172 | 如遇到在特定终端下不能播放问题,首先检查**此终端**下 mpg123 能否正常使用,其次检查**其他终端**下 musicbox 能否正常使用,报告 issue 的时候请告知以上使用情况以及出问题终端的报错信息。 173 | 174 | 同时,您可以通过`tail -f ~/.local/share/netease-musicbox/musicbox.log`自行查看日志。 175 | mpg123 最新的版本可能会报找不到声音硬件的错误,测试了 1.25.6 版本可以正常使用。 176 | 177 | ### 已知问题及解决方案 178 | 179 | - [#374](https://github.com/darknessomi/musicbox/issues/374) i3wm 下播放杂音或快进问题,此问题常见于 Arch Linux。尝试更改 mpg123 配置。 180 | - [#405](https://github.com/darknessomi/musicbox/issues/405) 32 位 Python 下 cookie 时间戳超出了 32 位整数最大值。尝试使用 64 位版本的 Python 或者拷贝 cookie 文件到对应位置。 181 | - [#347](https://github.com/darknessomi/musicbox/issues/347) 暂停时间超过一定长度(数分钟)之后 mpg123 停止输出,导致切换到下一首歌。此问题是 mpg123 的 bug,暂时无解决方案。 182 | - [#791](https://github.com/darknessomi/musicbox/issues/791) 版权问题,master 分支已经修复 183 | 184 | ## 使用 185 | 186 | ```bash 187 | musicbox 188 | ``` 189 | 190 | Enjoy it ! 191 | 192 | ## 更新日志 193 | 194 | 2021-01-18 版本 0.3.1 错误修复 195 | 196 | 2020-10-23 版本 0.3.0 接口更新,错误修复 197 | 198 | 2018-11-28 版本 0.2.5.4 修复多处错误 199 | 200 | 2018-06-21 版本 0.2.5.3 修复多处播放错误 201 | 202 | 2018-06-07 版本 0.2.5.1 修复配置文件错误 203 | 204 | 2018-06-05 版本 0.2.5.0 全部迁移到新版 api,大量错误修复 205 | 206 | [更多>>](https://github.com/darknessomi/musicbox/blob/master/CHANGELOG.md) 207 | 208 | ## LICENSE 209 | 210 | [MIT](LICENSE) 211 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | [[package]] 2 | name = "appdirs" 3 | version = "1.4.4" 4 | description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." 5 | category = "dev" 6 | optional = false 7 | python-versions = "*" 8 | 9 | [[package]] 10 | name = "atomicwrites" 11 | version = "1.4.0" 12 | description = "Atomic file writes." 13 | category = "dev" 14 | optional = false 15 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 16 | 17 | [[package]] 18 | name = "attrs" 19 | version = "20.3.0" 20 | description = "Classes Without Boilerplate" 21 | category = "dev" 22 | optional = false 23 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 24 | 25 | [package.extras] 26 | dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "furo", "sphinx", "pre-commit"] 27 | docs = ["furo", "sphinx", "zope.interface"] 28 | tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface"] 29 | tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six"] 30 | 31 | [[package]] 32 | name = "black" 33 | version = "20.8b1" 34 | description = "The uncompromising code formatter." 35 | category = "dev" 36 | optional = false 37 | python-versions = ">=3.6" 38 | 39 | [package.dependencies] 40 | appdirs = "*" 41 | click = ">=7.1.2" 42 | mypy-extensions = ">=0.4.3" 43 | pathspec = ">=0.6,<1" 44 | regex = ">=2020.1.8" 45 | toml = ">=0.10.1" 46 | typed-ast = ">=1.4.0" 47 | typing-extensions = ">=3.7.4" 48 | 49 | [package.extras] 50 | colorama = ["colorama (>=0.4.3)"] 51 | d = ["aiohttp (>=3.3.2)", "aiohttp-cors"] 52 | 53 | [[package]] 54 | name = "certifi" 55 | version = "2020.12.5" 56 | description = "Python package for providing Mozilla's CA Bundle." 57 | category = "main" 58 | optional = false 59 | python-versions = "*" 60 | 61 | [[package]] 62 | name = "chardet" 63 | version = "4.0.0" 64 | description = "Universal encoding detector for Python 2 and 3" 65 | category = "main" 66 | optional = false 67 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 68 | 69 | [[package]] 70 | name = "click" 71 | version = "7.1.2" 72 | description = "Composable command line interface toolkit" 73 | category = "dev" 74 | optional = false 75 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 76 | 77 | [[package]] 78 | name = "colorama" 79 | version = "0.4.4" 80 | description = "Cross-platform colored terminal text." 81 | category = "dev" 82 | optional = false 83 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 84 | 85 | [[package]] 86 | name = "flake8" 87 | version = "3.8.4" 88 | description = "the modular source code checker: pep8 pyflakes and co" 89 | category = "dev" 90 | optional = false 91 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" 92 | 93 | [package.dependencies] 94 | importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} 95 | mccabe = ">=0.6.0,<0.7.0" 96 | pycodestyle = ">=2.6.0a1,<2.7.0" 97 | pyflakes = ">=2.2.0,<2.3.0" 98 | 99 | [[package]] 100 | name = "fuzzywuzzy" 101 | version = "0.18.0" 102 | description = "Fuzzy string matching in python" 103 | category = "main" 104 | optional = false 105 | python-versions = "*" 106 | 107 | [package.extras] 108 | speedup = ["python-levenshtein (>=0.12)"] 109 | 110 | [[package]] 111 | name = "idna" 112 | version = "2.10" 113 | description = "Internationalized Domain Names in Applications (IDNA)" 114 | category = "main" 115 | optional = false 116 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 117 | 118 | [[package]] 119 | name = "importlib-metadata" 120 | version = "2.1.1" 121 | description = "Read metadata from Python packages" 122 | category = "main" 123 | optional = false 124 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" 125 | 126 | [package.dependencies] 127 | zipp = ">=0.5" 128 | 129 | [package.extras] 130 | docs = ["sphinx", "rst.linker"] 131 | testing = ["packaging", "pep517", "unittest2", "importlib-resources (>=1.3)"] 132 | 133 | [[package]] 134 | name = "iniconfig" 135 | version = "1.1.1" 136 | description = "iniconfig: brain-dead simple config-ini parsing" 137 | category = "dev" 138 | optional = false 139 | python-versions = "*" 140 | 141 | [[package]] 142 | name = "mccabe" 143 | version = "0.6.1" 144 | description = "McCabe checker, plugin for flake8" 145 | category = "dev" 146 | optional = false 147 | python-versions = "*" 148 | 149 | [[package]] 150 | name = "mypy" 151 | version = "0.782" 152 | description = "Optional static typing for Python" 153 | category = "dev" 154 | optional = false 155 | python-versions = ">=3.5" 156 | 157 | [package.dependencies] 158 | mypy-extensions = ">=0.4.3,<0.5.0" 159 | typed-ast = ">=1.4.0,<1.5.0" 160 | typing-extensions = ">=3.7.4" 161 | 162 | [package.extras] 163 | dmypy = ["psutil (>=4.0)"] 164 | 165 | [[package]] 166 | name = "mypy-extensions" 167 | version = "0.4.3" 168 | description = "Experimental type system extensions for programs checked with the mypy typechecker." 169 | category = "dev" 170 | optional = false 171 | python-versions = "*" 172 | 173 | [[package]] 174 | name = "packaging" 175 | version = "20.8" 176 | description = "Core utilities for Python packages" 177 | category = "dev" 178 | optional = false 179 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 180 | 181 | [package.dependencies] 182 | pyparsing = ">=2.0.2" 183 | 184 | [[package]] 185 | name = "pathspec" 186 | version = "0.8.1" 187 | description = "Utility library for gitignore style pattern matching of file paths." 188 | category = "dev" 189 | optional = false 190 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 191 | 192 | [[package]] 193 | name = "pluggy" 194 | version = "0.13.1" 195 | description = "plugin and hook calling mechanisms for python" 196 | category = "dev" 197 | optional = false 198 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 199 | 200 | [package.dependencies] 201 | importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} 202 | 203 | [package.extras] 204 | dev = ["pre-commit", "tox"] 205 | 206 | [[package]] 207 | name = "py" 208 | version = "1.10.0" 209 | description = "library with cross-python path, ini-parsing, io, code, log facilities" 210 | category = "dev" 211 | optional = false 212 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 213 | 214 | [[package]] 215 | name = "pycodestyle" 216 | version = "2.6.0" 217 | description = "Python style guide checker" 218 | category = "dev" 219 | optional = false 220 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 221 | 222 | [[package]] 223 | name = "pycryptodomex" 224 | version = "3.9.9" 225 | description = "Cryptographic library for Python" 226 | category = "main" 227 | optional = false 228 | python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 229 | 230 | [[package]] 231 | name = "pyflakes" 232 | version = "2.2.0" 233 | description = "passive checker of Python programs" 234 | category = "dev" 235 | optional = false 236 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 237 | 238 | [[package]] 239 | name = "pyparsing" 240 | version = "2.4.7" 241 | description = "Python parsing module" 242 | category = "dev" 243 | optional = false 244 | python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" 245 | 246 | [[package]] 247 | name = "pytest" 248 | version = "6.2.1" 249 | description = "pytest: simple powerful testing with Python" 250 | category = "dev" 251 | optional = false 252 | python-versions = ">=3.6" 253 | 254 | [package.dependencies] 255 | atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} 256 | attrs = ">=19.2.0" 257 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 258 | importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} 259 | iniconfig = "*" 260 | packaging = "*" 261 | pluggy = ">=0.12,<1.0.0a1" 262 | py = ">=1.8.2" 263 | toml = "*" 264 | 265 | [package.extras] 266 | testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] 267 | 268 | [[package]] 269 | name = "python-levenshtein" 270 | version = "0.12.0" 271 | description = "Python extension for computing string edit distances and similarities." 272 | category = "main" 273 | optional = false 274 | python-versions = "*" 275 | 276 | [[package]] 277 | name = "regex" 278 | version = "2020.11.13" 279 | description = "Alternative regular expression module, to replace re." 280 | category = "dev" 281 | optional = false 282 | python-versions = "*" 283 | 284 | [[package]] 285 | name = "requests" 286 | version = "2.25.1" 287 | description = "Python HTTP for Humans." 288 | category = "main" 289 | optional = false 290 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 291 | 292 | [package.dependencies] 293 | certifi = ">=2017.4.17" 294 | chardet = ">=3.0.2,<5" 295 | idna = ">=2.5,<3" 296 | urllib3 = ">=1.21.1,<1.27" 297 | 298 | [package.extras] 299 | security = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)"] 300 | socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"] 301 | 302 | [[package]] 303 | name = "requests-cache" 304 | version = "0.5.2" 305 | description = "Persistent cache for requests library" 306 | category = "main" 307 | optional = false 308 | python-versions = "*" 309 | 310 | [package.dependencies] 311 | requests = ">=1.1.0" 312 | 313 | [[package]] 314 | name = "toml" 315 | version = "0.10.2" 316 | description = "Python Library for Tom's Obvious, Minimal Language" 317 | category = "dev" 318 | optional = false 319 | python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" 320 | 321 | [[package]] 322 | name = "typed-ast" 323 | version = "1.4.2" 324 | description = "a fork of Python 2 and 3 ast modules with type comment support" 325 | category = "dev" 326 | optional = false 327 | python-versions = "*" 328 | 329 | [[package]] 330 | name = "typing-extensions" 331 | version = "3.7.4.3" 332 | description = "Backported and Experimental Type Hints for Python 3.5+" 333 | category = "dev" 334 | optional = false 335 | python-versions = "*" 336 | 337 | [[package]] 338 | name = "urllib3" 339 | version = "1.26.2" 340 | description = "HTTP library with thread-safe connection pooling, file post, and more." 341 | category = "main" 342 | optional = false 343 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" 344 | 345 | [package.extras] 346 | brotli = ["brotlipy (>=0.6.0)"] 347 | secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] 348 | socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] 349 | 350 | [[package]] 351 | name = "zipp" 352 | version = "3.4.0" 353 | description = "Backport of pathlib-compatible object wrapper for zip files" 354 | category = "main" 355 | optional = false 356 | python-versions = ">=3.6" 357 | 358 | [package.extras] 359 | docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"] 360 | testing = ["pytest (>=3.5,!=3.7.3)", "pytest-checkdocs (>=1.2.3)", "pytest-flake8", "pytest-cov", "jaraco.test (>=3.2.0)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy"] 361 | 362 | [metadata] 363 | lock-version = "1.1" 364 | python-versions = "^3.6" 365 | content-hash = "2980e70261abdeb4d15a8204ccc6b215fd6c7fb27d880312d6f91ce5d16cf7a5" 366 | 367 | [metadata.files] 368 | appdirs = [ 369 | {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"}, 370 | {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"}, 371 | ] 372 | atomicwrites = [ 373 | {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, 374 | {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, 375 | ] 376 | attrs = [ 377 | {file = "attrs-20.3.0-py2.py3-none-any.whl", hash = "sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6"}, 378 | {file = "attrs-20.3.0.tar.gz", hash = "sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700"}, 379 | ] 380 | black = [ 381 | {file = "black-20.8b1.tar.gz", hash = "sha256:1c02557aa099101b9d21496f8a914e9ed2222ef70336404eeeac8edba836fbea"}, 382 | ] 383 | certifi = [ 384 | {file = "certifi-2020.12.5-py2.py3-none-any.whl", hash = "sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830"}, 385 | {file = "certifi-2020.12.5.tar.gz", hash = "sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c"}, 386 | ] 387 | chardet = [ 388 | {file = "chardet-4.0.0-py2.py3-none-any.whl", hash = "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5"}, 389 | {file = "chardet-4.0.0.tar.gz", hash = "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa"}, 390 | ] 391 | click = [ 392 | {file = "click-7.1.2-py2.py3-none-any.whl", hash = "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"}, 393 | {file = "click-7.1.2.tar.gz", hash = "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a"}, 394 | ] 395 | colorama = [ 396 | {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, 397 | {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, 398 | ] 399 | flake8 = [ 400 | {file = "flake8-3.8.4-py2.py3-none-any.whl", hash = "sha256:749dbbd6bfd0cf1318af27bf97a14e28e5ff548ef8e5b1566ccfb25a11e7c839"}, 401 | {file = "flake8-3.8.4.tar.gz", hash = "sha256:aadae8761ec651813c24be05c6f7b4680857ef6afaae4651a4eccaef97ce6c3b"}, 402 | ] 403 | fuzzywuzzy = [ 404 | {file = "fuzzywuzzy-0.18.0-py2.py3-none-any.whl", hash = "sha256:928244b28db720d1e0ee7587acf660ea49d7e4c632569cad4f1cd7e68a5f0993"}, 405 | {file = "fuzzywuzzy-0.18.0.tar.gz", hash = "sha256:45016e92264780e58972dca1b3d939ac864b78437422beecebb3095f8efd00e8"}, 406 | ] 407 | idna = [ 408 | {file = "idna-2.10-py2.py3-none-any.whl", hash = "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"}, 409 | {file = "idna-2.10.tar.gz", hash = "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6"}, 410 | ] 411 | importlib-metadata = [ 412 | {file = "importlib_metadata-2.1.1-py2.py3-none-any.whl", hash = "sha256:c2d6341ff566f609e89a2acb2db190e5e1d23d5409d6cc8d2fe34d72443876d4"}, 413 | {file = "importlib_metadata-2.1.1.tar.gz", hash = "sha256:b8de9eff2b35fb037368f28a7df1df4e6436f578fa74423505b6c6a778d5b5dd"}, 414 | ] 415 | iniconfig = [ 416 | {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, 417 | {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, 418 | ] 419 | mccabe = [ 420 | {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, 421 | {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, 422 | ] 423 | mypy = [ 424 | {file = "mypy-0.782-cp35-cp35m-macosx_10_6_x86_64.whl", hash = "sha256:2c6cde8aa3426c1682d35190b59b71f661237d74b053822ea3d748e2c9578a7c"}, 425 | {file = "mypy-0.782-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:9c7a9a7ceb2871ba4bac1cf7217a7dd9ccd44c27c2950edbc6dc08530f32ad4e"}, 426 | {file = "mypy-0.782-cp35-cp35m-win_amd64.whl", hash = "sha256:c05b9e4fb1d8a41d41dec8786c94f3b95d3c5f528298d769eb8e73d293abc48d"}, 427 | {file = "mypy-0.782-cp36-cp36m-macosx_10_6_x86_64.whl", hash = "sha256:6731603dfe0ce4352c555c6284c6db0dc935b685e9ce2e4cf220abe1e14386fd"}, 428 | {file = "mypy-0.782-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:f05644db6779387ccdb468cc47a44b4356fc2ffa9287135d05b70a98dc83b89a"}, 429 | {file = "mypy-0.782-cp36-cp36m-win_amd64.whl", hash = "sha256:b7fbfabdbcc78c4f6fc4712544b9b0d6bf171069c6e0e3cb82440dd10ced3406"}, 430 | {file = "mypy-0.782-cp37-cp37m-macosx_10_6_x86_64.whl", hash = "sha256:3fdda71c067d3ddfb21da4b80e2686b71e9e5c72cca65fa216d207a358827f86"}, 431 | {file = "mypy-0.782-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:d7df6eddb6054d21ca4d3c6249cae5578cb4602951fd2b6ee2f5510ffb098707"}, 432 | {file = "mypy-0.782-cp37-cp37m-win_amd64.whl", hash = "sha256:a4a2cbcfc4cbf45cd126f531dedda8485671545b43107ded25ce952aac6fb308"}, 433 | {file = "mypy-0.782-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6bb93479caa6619d21d6e7160c552c1193f6952f0668cdda2f851156e85186fc"}, 434 | {file = "mypy-0.782-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:81c7908b94239c4010e16642c9102bfc958ab14e36048fa77d0be3289dda76ea"}, 435 | {file = "mypy-0.782-cp38-cp38-win_amd64.whl", hash = "sha256:5dd13ff1f2a97f94540fd37a49e5d255950ebcdf446fb597463a40d0df3fac8b"}, 436 | {file = "mypy-0.782-py3-none-any.whl", hash = "sha256:e0b61738ab504e656d1fe4ff0c0601387a5489ca122d55390ade31f9ca0e252d"}, 437 | {file = "mypy-0.782.tar.gz", hash = "sha256:eff7d4a85e9eea55afa34888dfeaccde99e7520b51f867ac28a48492c0b1130c"}, 438 | ] 439 | mypy-extensions = [ 440 | {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, 441 | {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, 442 | ] 443 | packaging = [ 444 | {file = "packaging-20.8-py2.py3-none-any.whl", hash = "sha256:24e0da08660a87484d1602c30bb4902d74816b6985b93de36926f5bc95741858"}, 445 | {file = "packaging-20.8.tar.gz", hash = "sha256:78598185a7008a470d64526a8059de9aaa449238f280fc9eb6b13ba6c4109093"}, 446 | ] 447 | pathspec = [ 448 | {file = "pathspec-0.8.1-py2.py3-none-any.whl", hash = "sha256:aa0cb481c4041bf52ffa7b0d8fa6cd3e88a2ca4879c533c9153882ee2556790d"}, 449 | {file = "pathspec-0.8.1.tar.gz", hash = "sha256:86379d6b86d75816baba717e64b1a3a3469deb93bb76d613c9ce79edc5cb68fd"}, 450 | ] 451 | pluggy = [ 452 | {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, 453 | {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, 454 | ] 455 | py = [ 456 | {file = "py-1.10.0-py2.py3-none-any.whl", hash = "sha256:3b80836aa6d1feeaa108e046da6423ab8f6ceda6468545ae8d02d9d58d18818a"}, 457 | {file = "py-1.10.0.tar.gz", hash = "sha256:21b81bda15b66ef5e1a777a21c4dcd9c20ad3efd0b3f817e7a809035269e1bd3"}, 458 | ] 459 | pycodestyle = [ 460 | {file = "pycodestyle-2.6.0-py2.py3-none-any.whl", hash = "sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367"}, 461 | {file = "pycodestyle-2.6.0.tar.gz", hash = "sha256:c58a7d2815e0e8d7972bf1803331fb0152f867bd89adf8a01dfd55085434192e"}, 462 | ] 463 | pycryptodomex = [ 464 | {file = "pycryptodomex-3.9.9-cp27-cp27m-macosx_10_6_intel.whl", hash = "sha256:5e486cab2dfcfaec934dd4f5d5837f4a9428b690f4d92a3b020fd31d1497ca64"}, 465 | {file = "pycryptodomex-3.9.9-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:42669638e4f7937b7141044a2fbd1019caca62bd2cdd8b535f731426ab07bde1"}, 466 | {file = "pycryptodomex-3.9.9-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:4ce1fc1e6d2fd2d6dc197607153327989a128c093e0e94dca63408f506622c3e"}, 467 | {file = "pycryptodomex-3.9.9-cp27-cp27m-win32.whl", hash = "sha256:d2d1388595cb5d27d9220d5cbaff4f37c6ec696a25882eb06d224d241e6e93fb"}, 468 | {file = "pycryptodomex-3.9.9-cp27-cp27m-win_amd64.whl", hash = "sha256:a1d38a96da57e6103423a446079ead600b450cf0f8ebf56a231895abf77e7ffc"}, 469 | {file = "pycryptodomex-3.9.9-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:934e460c5058346c6f1d62fdf3db5680fbdfbfd212722d24d8277bf47cd9ebdc"}, 470 | {file = "pycryptodomex-3.9.9-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:3642252d7bfc4403a42050e18ba748bedebd5a998a8cba89665a4f42aea4c380"}, 471 | {file = "pycryptodomex-3.9.9-cp35-cp35m-macosx_10_6_intel.whl", hash = "sha256:a385fceaa0cdb97f0098f1c1e9ec0b46cc09186ddf60ec23538e871b1dddb6dc"}, 472 | {file = "pycryptodomex-3.9.9-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:73240335f4a1baf12880ebac6df66ab4d3a9212db9f3efe809c36a27280d16f8"}, 473 | {file = "pycryptodomex-3.9.9-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:305e3c46f20d019cd57543c255e7ba49e432e275d7c0de8913b6dbe57a851bc8"}, 474 | {file = "pycryptodomex-3.9.9-cp35-cp35m-manylinux2014_aarch64.whl", hash = "sha256:871852044f55295449fbf225538c2c4118525093c32f0a6c43c91bed0452d7e3"}, 475 | {file = "pycryptodomex-3.9.9-cp36-cp36m-macosx_10_6_intel.whl", hash = "sha256:4632d55a140b28e20be3cd7a3057af52fb747298ff0fd3290d4e9f245b5004ba"}, 476 | {file = "pycryptodomex-3.9.9-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:a9aac1a30b00b5038d3d8e48248f3b58ea15c827b67325c0d18a447552e30fc8"}, 477 | {file = "pycryptodomex-3.9.9-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:a7cf1c14e47027d9fb9d26aa62e5d603994227bd635e58a8df4b1d2d1b6a8ed7"}, 478 | {file = "pycryptodomex-3.9.9-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:20fb7f4efc494016eab1bc2f555bc0a12dd5ca61f35c95df8061818ffb2c20a3"}, 479 | {file = "pycryptodomex-3.9.9-cp36-cp36m-win32.whl", hash = "sha256:892e93f3e7e10c751d6c17fa0dc422f7984cfd5eb6690011f9264dc73e2775fc"}, 480 | {file = "pycryptodomex-3.9.9-cp36-cp36m-win_amd64.whl", hash = "sha256:28ee3bcb4d609aea3040cad995a8e2c9c6dc57c12183dadd69e53880c35333b9"}, 481 | {file = "pycryptodomex-3.9.9-cp37-cp37m-macosx_10_6_intel.whl", hash = "sha256:d62fbab185a6b01c5469eda9f0795f3d1a5bba24f5a5813f362e4b73a3c4dc70"}, 482 | {file = "pycryptodomex-3.9.9-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:bef9e9d39393dc7baec39ba4bac6c73826a4db02114cdeade2552a9d6afa16e2"}, 483 | {file = "pycryptodomex-3.9.9-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:f20a62397e09704049ce9007bea4f6bad965ba9336a760c6f4ef1b4192e12d6d"}, 484 | {file = "pycryptodomex-3.9.9-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:c885fe4d5f26ce8ca20c97d02e88f5fdd92c01e1cc771ad0951b21e1641faf6d"}, 485 | {file = "pycryptodomex-3.9.9-cp37-cp37m-win32.whl", hash = "sha256:f81f7311250d9480e36dec819127897ae772e7e8de07abfabe931b8566770b8e"}, 486 | {file = "pycryptodomex-3.9.9-cp37-cp37m-win_amd64.whl", hash = "sha256:55cf4e99b3ba0122dee570dc7661b97bf35c16aab3e2ccb5070709d282a1c7ab"}, 487 | {file = "pycryptodomex-3.9.9-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:15c03ffdac17731b126880622823d30d0a3cc7203cd219e6b9814140a44e7fab"}, 488 | {file = "pycryptodomex-3.9.9-cp38-cp38-manylinux1_i686.whl", hash = "sha256:3547b87b16aad6afb28c9b3a9cd870e11b5e7b5ac649b74265258d96d8de1130"}, 489 | {file = "pycryptodomex-3.9.9-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:65ec88c8271448d2ea109d35c1f297b09b872c57214ab7e832e413090d3469a9"}, 490 | {file = "pycryptodomex-3.9.9-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:404faa3e518f8bea516aae2aac47d4d960397199a15b4bd6f66cad97825469a0"}, 491 | {file = "pycryptodomex-3.9.9-cp38-cp38-win32.whl", hash = "sha256:d2e853e0f9535e693fade97768cf7293f3febabecc5feb1e9b2ffdfe1044ab96"}, 492 | {file = "pycryptodomex-3.9.9-cp38-cp38-win_amd64.whl", hash = "sha256:836fe39282e75311ce4c38468be148f7fac0df3d461c5de58c5ff1ddb8966bac"}, 493 | {file = "pycryptodomex-3.9.9-cp39-cp39-manylinux1_i686.whl", hash = "sha256:4a88c9383d273bdce3afc216020282c9c5c39ec0bd9462b1a206af6afa377cf0"}, 494 | {file = "pycryptodomex-3.9.9-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:9736f3f3e1761024200637a080a4f922f5298ad5d780e10dbb5634fe8c65b34c"}, 495 | {file = "pycryptodomex-3.9.9-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:6c95a3361ce70068cf69526a58751f73ddac5ba27a3c2379b057efa2f5338c8c"}, 496 | {file = "pycryptodomex-3.9.9-cp39-cp39-win32.whl", hash = "sha256:b696876ee583d15310be57311e90e153a84b7913ac93e6b99675c0c9867926d0"}, 497 | {file = "pycryptodomex-3.9.9-cp39-cp39-win_amd64.whl", hash = "sha256:7651211e15109ac0058a49159265d9f6e6423c8a81c65434d3c56d708417a05b"}, 498 | {file = "pycryptodomex-3.9.9.tar.gz", hash = "sha256:7b5b7c5896f8172ea0beb283f7f9428e0ab88ec248ce0a5b8c98d73e26267d51"}, 499 | ] 500 | pyflakes = [ 501 | {file = "pyflakes-2.2.0-py2.py3-none-any.whl", hash = "sha256:0d94e0e05a19e57a99444b6ddcf9a6eb2e5c68d3ca1e98e90707af8152c90a92"}, 502 | {file = "pyflakes-2.2.0.tar.gz", hash = "sha256:35b2d75ee967ea93b55750aa9edbbf72813e06a66ba54438df2cfac9e3c27fc8"}, 503 | ] 504 | pyparsing = [ 505 | {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"}, 506 | {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"}, 507 | ] 508 | pytest = [ 509 | {file = "pytest-6.2.1-py3-none-any.whl", hash = "sha256:1969f797a1a0dbd8ccf0fecc80262312729afea9c17f1d70ebf85c5e76c6f7c8"}, 510 | {file = "pytest-6.2.1.tar.gz", hash = "sha256:66e419b1899bc27346cb2c993e12c5e5e8daba9073c1fbce33b9807abc95c306"}, 511 | ] 512 | python-levenshtein = [ 513 | {file = "python-Levenshtein-0.12.0.tar.gz", hash = "sha256:033a11de5e3d19ea25c9302d11224e1a1898fe5abd23c61c7c360c25195e3eb1"}, 514 | ] 515 | regex = [ 516 | {file = "regex-2020.11.13-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:8b882a78c320478b12ff024e81dc7d43c1462aa4a3341c754ee65d857a521f85"}, 517 | {file = "regex-2020.11.13-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:a63f1a07932c9686d2d416fb295ec2c01ab246e89b4d58e5fa468089cab44b70"}, 518 | {file = "regex-2020.11.13-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:6e4b08c6f8daca7d8f07c8d24e4331ae7953333dbd09c648ed6ebd24db5a10ee"}, 519 | {file = "regex-2020.11.13-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:bba349276b126947b014e50ab3316c027cac1495992f10e5682dc677b3dfa0c5"}, 520 | {file = "regex-2020.11.13-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:56e01daca75eae420bce184edd8bb341c8eebb19dd3bce7266332258f9fb9dd7"}, 521 | {file = "regex-2020.11.13-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:6a8ce43923c518c24a2579fda49f093f1397dad5d18346211e46f134fc624e31"}, 522 | {file = "regex-2020.11.13-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:1ab79fcb02b930de09c76d024d279686ec5d532eb814fd0ed1e0051eb8bd2daa"}, 523 | {file = "regex-2020.11.13-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:9801c4c1d9ae6a70aeb2128e5b4b68c45d4f0af0d1535500884d644fa9b768c6"}, 524 | {file = "regex-2020.11.13-cp36-cp36m-win32.whl", hash = "sha256:49cae022fa13f09be91b2c880e58e14b6da5d10639ed45ca69b85faf039f7a4e"}, 525 | {file = "regex-2020.11.13-cp36-cp36m-win_amd64.whl", hash = "sha256:749078d1eb89484db5f34b4012092ad14b327944ee7f1c4f74d6279a6e4d1884"}, 526 | {file = "regex-2020.11.13-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b2f4007bff007c96a173e24dcda236e5e83bde4358a557f9ccf5e014439eae4b"}, 527 | {file = "regex-2020.11.13-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:38c8fd190db64f513fe4e1baa59fed086ae71fa45083b6936b52d34df8f86a88"}, 528 | {file = "regex-2020.11.13-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:5862975b45d451b6db51c2e654990c1820523a5b07100fc6903e9c86575202a0"}, 529 | {file = "regex-2020.11.13-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:262c6825b309e6485ec2493ffc7e62a13cf13fb2a8b6d212f72bd53ad34118f1"}, 530 | {file = "regex-2020.11.13-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:bafb01b4688833e099d79e7efd23f99172f501a15c44f21ea2118681473fdba0"}, 531 | {file = "regex-2020.11.13-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:e32f5f3d1b1c663af7f9c4c1e72e6ffe9a78c03a31e149259f531e0fed826512"}, 532 | {file = "regex-2020.11.13-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:3bddc701bdd1efa0d5264d2649588cbfda549b2899dc8d50417e47a82e1387ba"}, 533 | {file = "regex-2020.11.13-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:02951b7dacb123d8ea6da44fe45ddd084aa6777d4b2454fa0da61d569c6fa538"}, 534 | {file = "regex-2020.11.13-cp37-cp37m-win32.whl", hash = "sha256:0d08e71e70c0237883d0bef12cad5145b84c3705e9c6a588b2a9c7080e5af2a4"}, 535 | {file = "regex-2020.11.13-cp37-cp37m-win_amd64.whl", hash = "sha256:1fa7ee9c2a0e30405e21031d07d7ba8617bc590d391adfc2b7f1e8b99f46f444"}, 536 | {file = "regex-2020.11.13-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:baf378ba6151f6e272824b86a774326f692bc2ef4cc5ce8d5bc76e38c813a55f"}, 537 | {file = "regex-2020.11.13-cp38-cp38-manylinux1_i686.whl", hash = "sha256:e3faaf10a0d1e8e23a9b51d1900b72e1635c2d5b0e1bea1c18022486a8e2e52d"}, 538 | {file = "regex-2020.11.13-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:2a11a3e90bd9901d70a5b31d7dd85114755a581a5da3fc996abfefa48aee78af"}, 539 | {file = "regex-2020.11.13-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:d1ebb090a426db66dd80df8ca85adc4abfcbad8a7c2e9a5ec7513ede522e0a8f"}, 540 | {file = "regex-2020.11.13-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:b2b1a5ddae3677d89b686e5c625fc5547c6e492bd755b520de5332773a8af06b"}, 541 | {file = "regex-2020.11.13-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:2c99e97d388cd0a8d30f7c514d67887d8021541b875baf09791a3baad48bb4f8"}, 542 | {file = "regex-2020.11.13-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:c084582d4215593f2f1d28b65d2a2f3aceff8342aa85afd7be23a9cad74a0de5"}, 543 | {file = "regex-2020.11.13-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:a3d748383762e56337c39ab35c6ed4deb88df5326f97a38946ddd19028ecce6b"}, 544 | {file = "regex-2020.11.13-cp38-cp38-win32.whl", hash = "sha256:7913bd25f4ab274ba37bc97ad0e21c31004224ccb02765ad984eef43e04acc6c"}, 545 | {file = "regex-2020.11.13-cp38-cp38-win_amd64.whl", hash = "sha256:6c54ce4b5d61a7129bad5c5dc279e222afd00e721bf92f9ef09e4fae28755683"}, 546 | {file = "regex-2020.11.13-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1862a9d9194fae76a7aaf0150d5f2a8ec1da89e8b55890b1786b8f88a0f619dc"}, 547 | {file = "regex-2020.11.13-cp39-cp39-manylinux1_i686.whl", hash = "sha256:4902e6aa086cbb224241adbc2f06235927d5cdacffb2425c73e6570e8d862364"}, 548 | {file = "regex-2020.11.13-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:7a25fcbeae08f96a754b45bdc050e1fb94b95cab046bf56b016c25e9ab127b3e"}, 549 | {file = "regex-2020.11.13-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:d2d8ce12b7c12c87e41123997ebaf1a5767a5be3ec545f64675388970f415e2e"}, 550 | {file = "regex-2020.11.13-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:f7d29a6fc4760300f86ae329e3b6ca28ea9c20823df123a2ea8693e967b29917"}, 551 | {file = "regex-2020.11.13-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:717881211f46de3ab130b58ec0908267961fadc06e44f974466d1887f865bd5b"}, 552 | {file = "regex-2020.11.13-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:3128e30d83f2e70b0bed9b2a34e92707d0877e460b402faca908c6667092ada9"}, 553 | {file = "regex-2020.11.13-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:8f6a2229e8ad946e36815f2a03386bb8353d4bde368fdf8ca5f0cb97264d3b5c"}, 554 | {file = "regex-2020.11.13-cp39-cp39-win32.whl", hash = "sha256:f8f295db00ef5f8bae530fc39af0b40486ca6068733fb860b42115052206466f"}, 555 | {file = "regex-2020.11.13-cp39-cp39-win_amd64.whl", hash = "sha256:a15f64ae3a027b64496a71ab1f722355e570c3fac5ba2801cafce846bf5af01d"}, 556 | {file = "regex-2020.11.13.tar.gz", hash = "sha256:83d6b356e116ca119db8e7c6fc2983289d87b27b3fac238cfe5dca529d884562"}, 557 | ] 558 | requests = [ 559 | {file = "requests-2.25.1-py2.py3-none-any.whl", hash = "sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e"}, 560 | {file = "requests-2.25.1.tar.gz", hash = "sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804"}, 561 | ] 562 | requests-cache = [ 563 | {file = "requests-cache-0.5.2.tar.gz", hash = "sha256:813023269686045f8e01e2289cc1e7e9ae5ab22ddd1e2849a9093ab3ab7270eb"}, 564 | {file = "requests_cache-0.5.2-py2.py3-none-any.whl", hash = "sha256:81e13559baee64677a7d73b85498a5a8f0639e204517b5d05ff378e44a57831a"}, 565 | ] 566 | toml = [ 567 | {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, 568 | {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, 569 | ] 570 | typed-ast = [ 571 | {file = "typed_ast-1.4.2-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:7703620125e4fb79b64aa52427ec192822e9f45d37d4b6625ab37ef403e1df70"}, 572 | {file = "typed_ast-1.4.2-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:c9aadc4924d4b5799112837b226160428524a9a45f830e0d0f184b19e4090487"}, 573 | {file = "typed_ast-1.4.2-cp35-cp35m-manylinux2014_aarch64.whl", hash = "sha256:9ec45db0c766f196ae629e509f059ff05fc3148f9ffd28f3cfe75d4afb485412"}, 574 | {file = "typed_ast-1.4.2-cp35-cp35m-win32.whl", hash = "sha256:85f95aa97a35bdb2f2f7d10ec5bbdac0aeb9dafdaf88e17492da0504de2e6400"}, 575 | {file = "typed_ast-1.4.2-cp35-cp35m-win_amd64.whl", hash = "sha256:9044ef2df88d7f33692ae3f18d3be63dec69c4fb1b5a4a9ac950f9b4ba571606"}, 576 | {file = "typed_ast-1.4.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:c1c876fd795b36126f773db9cbb393f19808edd2637e00fd6caba0e25f2c7b64"}, 577 | {file = "typed_ast-1.4.2-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:5dcfc2e264bd8a1db8b11a892bd1647154ce03eeba94b461effe68790d8b8e07"}, 578 | {file = "typed_ast-1.4.2-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:8db0e856712f79c45956da0c9a40ca4246abc3485ae0d7ecc86a20f5e4c09abc"}, 579 | {file = "typed_ast-1.4.2-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:d003156bb6a59cda9050e983441b7fa2487f7800d76bdc065566b7d728b4581a"}, 580 | {file = "typed_ast-1.4.2-cp36-cp36m-win32.whl", hash = "sha256:4c790331247081ea7c632a76d5b2a265e6d325ecd3179d06e9cf8d46d90dd151"}, 581 | {file = "typed_ast-1.4.2-cp36-cp36m-win_amd64.whl", hash = "sha256:d175297e9533d8d37437abc14e8a83cbc68af93cc9c1c59c2c292ec59a0697a3"}, 582 | {file = "typed_ast-1.4.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:cf54cfa843f297991b7388c281cb3855d911137223c6b6d2dd82a47ae5125a41"}, 583 | {file = "typed_ast-1.4.2-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:b4fcdcfa302538f70929eb7b392f536a237cbe2ed9cba88e3bf5027b39f5f77f"}, 584 | {file = "typed_ast-1.4.2-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:987f15737aba2ab5f3928c617ccf1ce412e2e321c77ab16ca5a293e7bbffd581"}, 585 | {file = "typed_ast-1.4.2-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:37f48d46d733d57cc70fd5f30572d11ab8ed92da6e6b28e024e4a3edfb456e37"}, 586 | {file = "typed_ast-1.4.2-cp37-cp37m-win32.whl", hash = "sha256:36d829b31ab67d6fcb30e185ec996e1f72b892255a745d3a82138c97d21ed1cd"}, 587 | {file = "typed_ast-1.4.2-cp37-cp37m-win_amd64.whl", hash = "sha256:8368f83e93c7156ccd40e49a783a6a6850ca25b556c0fa0240ed0f659d2fe496"}, 588 | {file = "typed_ast-1.4.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:963c80b583b0661918718b095e02303d8078950b26cc00b5e5ea9ababe0de1fc"}, 589 | {file = "typed_ast-1.4.2-cp38-cp38-manylinux1_i686.whl", hash = "sha256:e683e409e5c45d5c9082dc1daf13f6374300806240719f95dc783d1fc942af10"}, 590 | {file = "typed_ast-1.4.2-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:84aa6223d71012c68d577c83f4e7db50d11d6b1399a9c779046d75e24bed74ea"}, 591 | {file = "typed_ast-1.4.2-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:a38878a223bdd37c9709d07cd357bb79f4c760b29210e14ad0fb395294583787"}, 592 | {file = "typed_ast-1.4.2-cp38-cp38-win32.whl", hash = "sha256:a2c927c49f2029291fbabd673d51a2180038f8cd5a5b2f290f78c4516be48be2"}, 593 | {file = "typed_ast-1.4.2-cp38-cp38-win_amd64.whl", hash = "sha256:c0c74e5579af4b977c8b932f40a5464764b2f86681327410aa028a22d2f54937"}, 594 | {file = "typed_ast-1.4.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:07d49388d5bf7e863f7fa2f124b1b1d89d8aa0e2f7812faff0a5658c01c59aa1"}, 595 | {file = "typed_ast-1.4.2-cp39-cp39-manylinux1_i686.whl", hash = "sha256:240296b27397e4e37874abb1df2a608a92df85cf3e2a04d0d4d61055c8305ba6"}, 596 | {file = "typed_ast-1.4.2-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:d746a437cdbca200622385305aedd9aef68e8a645e385cc483bdc5e488f07166"}, 597 | {file = "typed_ast-1.4.2-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:14bf1522cdee369e8f5581238edac09150c765ec1cb33615855889cf33dcb92d"}, 598 | {file = "typed_ast-1.4.2-cp39-cp39-win32.whl", hash = "sha256:cc7b98bf58167b7f2db91a4327da24fb93368838eb84a44c472283778fc2446b"}, 599 | {file = "typed_ast-1.4.2-cp39-cp39-win_amd64.whl", hash = "sha256:7147e2a76c75f0f64c4319886e7639e490fee87c9d25cb1d4faef1d8cf83a440"}, 600 | {file = "typed_ast-1.4.2.tar.gz", hash = "sha256:9fc0b3cb5d1720e7141d103cf4819aea239f7d136acf9ee4a69b047b7986175a"}, 601 | ] 602 | typing-extensions = [ 603 | {file = "typing_extensions-3.7.4.3-py2-none-any.whl", hash = "sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f"}, 604 | {file = "typing_extensions-3.7.4.3-py3-none-any.whl", hash = "sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918"}, 605 | {file = "typing_extensions-3.7.4.3.tar.gz", hash = "sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c"}, 606 | ] 607 | urllib3 = [ 608 | {file = "urllib3-1.26.2-py2.py3-none-any.whl", hash = "sha256:d8ff90d979214d7b4f8ce956e80f4028fc6860e4431f731ea4a8c08f23f99473"}, 609 | {file = "urllib3-1.26.2.tar.gz", hash = "sha256:19188f96923873c92ccb987120ec4acaa12f0461fa9ce5d3d0772bc965a39e08"}, 610 | ] 611 | zipp = [ 612 | {file = "zipp-3.4.0-py3-none-any.whl", hash = "sha256:102c24ef8f171fd729d46599845e95c7ab894a4cf45f5de11a44cc7444fb1108"}, 613 | {file = "zipp-3.4.0.tar.gz", hash = "sha256:ed5eee1974372595f9e416cc7bbeeb12335201d8081ca8a0743c954d4446e5cb"}, 614 | ] 615 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "NetEase-MusicBox" 3 | packages = [ 4 | {include = "NEMbox"}, 5 | ] 6 | version = "0.3.1" 7 | # docs 8 | authors = [ 9 | "omi <4399.omi@gmail.com>", 10 | "Weiliang Li ", 11 | ] 12 | classifiers = [ 13 | "Development Status :: 4 - Beta", 14 | "Intended Audience :: Developers", 15 | "Natural Language :: Chinese (Simplified)", 16 | "Operating System :: OS Independent", 17 | "Programming Language :: Python :: Implementation :: CPython", 18 | "Topic :: Multimedia :: Sound/Audio", 19 | ] 20 | description = "A sexy command line interface musicbox" 21 | keywords = ["music", "netease", "cli", "player"] 22 | license = "MIT" 23 | maintainers = [ 24 | "omi <4399.omi@gmail.com>", 25 | "Weiliang Li ", 26 | ] 27 | readme = "README.md" 28 | repository = "https://github.com/darknessomi/musicbox" 29 | 30 | [tool.poetry.dependencies] 31 | fuzzywuzzy = "^0.18.0" 32 | importlib-metadata = "^2.0.0" 33 | pycryptodomex = "^3.9.8" 34 | python = "^3.6" 35 | python-Levenshtein = "^0.12.0" 36 | requests = "^2.24.0" 37 | requests-cache = "^0.5.2" 38 | 39 | [tool.poetry.dev-dependencies] 40 | black = {version = "^20.8b1", python = "^3.7"} 41 | flake8 = "^3.8.4" 42 | mypy = "^0.782" 43 | pytest = "^6.1.1" 44 | 45 | [tool.poetry.scripts] 46 | musicbox = "NEMbox.__main__:start" 47 | 48 | [build-system] 49 | build-backend = "poetry.core.masonry.api" 50 | requires = ["poetry-core>=1.0.0"] 51 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = E501,E402,W503,E203 3 | exclude = .git,__pycache__,docs/source/conf.py,old,build,dist,venv 4 | max-complexity = 15 5 | max-line-length = 88 6 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darknessomi/musicbox/592e13d1b948201447d008f4f3e249d347f47f21/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_api.py: -------------------------------------------------------------------------------- 1 | from NEMbox.api import NetEase 2 | from NEMbox.api import Parse 3 | 4 | 5 | def test_api(): 6 | api = NetEase() 7 | ids = [347230, 496619464, 405998841, 28012031] 8 | print(api.songs_url(ids)) 9 | print(api.songs_detail(ids)) 10 | print(Parse.song_url(api.songs_detail(ids)[0])) 11 | # user = api.login('example@163.com', md5(b'').hexdigest()) 12 | # user_id = user['account']['id'] 13 | # print(user) 14 | # api.logout() 15 | # print(api.user_playlist(3765346)) 16 | # print(api.song_comments(347230)) 17 | # print(api.search('海阔天空')['result']['songs']) 18 | # print(api.top_songlist()[0]) 19 | # print(Parse.song_url(api.top_songlist()[0])) 20 | # print(api.djchannels()) 21 | # print(api.search('测', 1000)) 22 | # print(api.album(38721188)) 23 | # print(api.djchannels()[:5]) 24 | # print(api.channel_detail([348289113])) 25 | # print(api.djprograms(243, True, limit=5)) 26 | # print(api.request('POST', '/weapi/djradio/hot/v1', params=dict( 27 | # category='旅途|城市', 28 | # limit=5, 29 | # offset=0 30 | # ))) 31 | # print(api.recommend_resource()[0]) 32 | print(api.songs_url([561307346])) 33 | --------------------------------------------------------------------------------