├── requirements.txt ├── mellplayer ├── __init__.py ├── utils │ ├── __init__.py │ ├── encrypt_utils.py │ ├── getch.py │ └── mpv.py ├── event │ ├── __init__.py │ └── ui_event.py ├── start.py ├── directory.py ├── mell_logger.py ├── deco.py ├── controller.py ├── watcher.py ├── api.py ├── player.py └── ui.py ├── document └── mellplayer_tutorial.gif ├── mell_start.py ├── setup.py ├── LICENSE ├── .gitignore └── README.md /requirements.txt: -------------------------------------------------------------------------------- 1 | requests 2 | pycrypto 3 | -------------------------------------------------------------------------------- /mellplayer/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | -------------------------------------------------------------------------------- /mellplayer/utils/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | -------------------------------------------------------------------------------- /mellplayer/event/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | -------------------------------------------------------------------------------- /document/mellplayer_tutorial.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0oVicero0/MellPlayer/master/document/mellplayer_tutorial.gif -------------------------------------------------------------------------------- /mell_start.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | ''' 5 | MellPlayer Starter 6 | 7 | Created on 2017-03-05 8 | @author: Mellcap 9 | ''' 10 | 11 | from mellplayer.controller import mell_ui, initial_player 12 | from mellplayer.watcher import time_watcher, key_watcher 13 | 14 | def main(): 15 | print('Initial Player...') 16 | initial_player() 17 | time_watcher() 18 | key_watcher() 19 | mell_ui.display() 20 | 21 | if __name__ == '__main__': 22 | main() 23 | -------------------------------------------------------------------------------- /mellplayer/start.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | ''' 5 | MellPlayer Starter 6 | 7 | Created on 2017-03-05 8 | @author: Mellcap 9 | ''' 10 | 11 | from mellplayer.controller import mell_ui, initial_player 12 | from mellplayer.watcher import time_watcher, key_watcher 13 | 14 | def main(): 15 | print('Initial Player...') 16 | initial_player() 17 | time_watcher() 18 | key_watcher() 19 | mell_ui.display() 20 | 21 | if __name__ == '__main__': 22 | main() 23 | -------------------------------------------------------------------------------- /mellplayer/directory.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | ''' 5 | MellPlayer Directory 6 | 7 | Created on 2017-02-23 8 | @author: Mellcap 9 | ''' 10 | 11 | import os 12 | 13 | BASE_DIRECTORY = os.path.join(os.path.expanduser('~'), '.MellPlayer') 14 | 15 | class Directory(object): 16 | 17 | def create_directory(self, directory=None): 18 | if not directory: 19 | directory = BASE_DIRECTORY 20 | if not os.path.exists(directory): 21 | os.makedirs(directory) 22 | 23 | # =========================== 24 | # Instance 25 | # =========================== 26 | 27 | mell_directory = Directory() 28 | mell_directory.create_directory() 29 | -------------------------------------------------------------------------------- /mellplayer/mell_logger.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | ''' 5 | MellPlayer logger 6 | 7 | Created on 2017-03-06 8 | @author: Mellcap 9 | ''' 10 | 11 | import os 12 | import logging 13 | 14 | from mellplayer.directory import BASE_DIRECTORY 15 | 16 | LOG_FILE = os.path.join(BASE_DIRECTORY, 'mell_logger.log') 17 | 18 | 19 | # create logger 20 | mell_logger = logging.getLogger('mell_logger') 21 | mell_logger.setLevel(logging.DEBUG) 22 | 23 | # define handler write in file 24 | fh = logging.FileHandler(LOG_FILE) 25 | fh.setLevel(logging.DEBUG) 26 | 27 | # define formatter 28 | formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s \n- %(message)s') 29 | fh.setFormatter(formatter) 30 | 31 | # add handler 32 | mell_logger.addHandler(fh) 33 | 34 | 35 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | ''' 5 | MellPlayer Setup 6 | 7 | Created on 2017-03-09 8 | @author: Mellcap 9 | ''' 10 | 11 | from setuptools import setup, find_packages 12 | VERSION = '0.1.1.1' 13 | 14 | setup( 15 | name='MellPlayer', 16 | version=VERSION, 17 | packages=find_packages(), 18 | install_requires=[ 19 | 'requests', 20 | 'pycrypto' 21 | ], 22 | 23 | entry_points={ 24 | 'console_scripts': [ 25 | 'mellplayer = mellplayer.start:main' 26 | ], 27 | }, 28 | 29 | license='MIT', 30 | author='Mellcap', 31 | author_email='imellcap@gmail.com', 32 | url='https://github.com/Mellcap/MellPlayer', 33 | description='A tiny terminal player based on Python', 34 | keywords=['mellplayer', 'terminal', 'playlist', 'music', 'cli', 'player'], 35 | ) 36 | -------------------------------------------------------------------------------- /mellplayer/event/ui_event.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | ''' 5 | MellPlayer UIEvent 6 | 7 | Created on 2017-03-05 8 | @author: Mellcap 9 | ''' 10 | 11 | from mellplayer.ui import mell_ui, mell_lyric_ui 12 | 13 | class UIEvent(object): 14 | 15 | # =========================== 16 | # Main UI 17 | # =========================== 18 | 19 | def handler_update_playInfo(self, play_info): 20 | mell_ui.update_play_info(play_info) 21 | 22 | def handler_update_title(self, items): 23 | mell_ui.update_title(items) 24 | 25 | # =========================== 26 | # Lyric UI 27 | # =========================== 28 | 29 | def handler_initial_lyric(self): 30 | mell_lyric_ui.initial_lyric() 31 | 32 | def handler_parse_lyric(self, origin_lyric): 33 | self.handler_initial_lyric() 34 | mell_lyric_ui.parse_lyric(origin_lyric=origin_lyric) 35 | 36 | def handler_roll_lyric(self, timestamp): 37 | mell_lyric_ui.roll(timestamp=timestamp) 38 | 39 | -------------------------------------------------------------------------------- /mellplayer/deco.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | ''' 5 | MellPlayer Player Deco 6 | 7 | Created on 2017-03-08 8 | @author: Mellcap 9 | ''' 10 | 11 | # =========================== 12 | # Deco 13 | # =========================== 14 | 15 | def show_changing_text(func): 16 | ''' 17 | 加载歌曲显示 18 | ''' 19 | def wrapper(*args, **kw): 20 | # args[0] == player 21 | p = args[0] 22 | p.show_song_changing() 23 | return func(*args, **kw) 24 | return wrapper 25 | 26 | def show_song_info_text(func): 27 | ''' 28 | 歌曲详情显示 29 | ''' 30 | def wrapper(*args, **kw): 31 | func(*args, **kw) 32 | p = args[0] 33 | while 1: 34 | if p.time_pos: 35 | p.show_song_info() 36 | break 37 | time.sleep(1) 38 | return wrapper 39 | 40 | def update_title_text(func): 41 | ''' 42 | 更新title 43 | ''' 44 | def wrapper(*args, **kw): 45 | # args[0] == player 46 | func(*args, **kw) 47 | p = args[0] 48 | p.update_title() 49 | return wrapper 50 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Mellcap 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 | -------------------------------------------------------------------------------- /mellplayer/utils/encrypt_utils.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import json 3 | import os 4 | import binascii 5 | 6 | from Crypto.Cipher import AES 7 | 8 | modulus = '00e0b509f6259df8642dbc35662901477df22677ec152b5ff68ace615bb7b725' \ 9 | '152b3ab17a876aea8a5aa76d2e417629ec4ee341f56135fccf695280104e0312ecbda' \ 10 | '92557c93870114af6c9d05c4f7f0c3685b7a46bee255932575cce10b424d813cfe4' \ 11 | '875d3e82047b97ddef52741d546b8e289dc6935b3ece0462db0a22b8e7' 12 | nonce = '0CoJUm6Qyw8W8jud' 13 | pubKey = '010001' 14 | 15 | 16 | def encrypted_request(text): 17 | text = json.dumps(text) 18 | secKey = createSecretKey(16) 19 | encText = aesEncrypt(aesEncrypt(text, nonce), secKey) 20 | encSecKey = rsaEncrypt(secKey, pubKey, modulus) 21 | data = {'params': encText, 'encSecKey': encSecKey} 22 | return data 23 | 24 | 25 | def aesEncrypt(text, secKey): 26 | pad = 16 - len(text) % 16 27 | text = text + chr(pad) * pad 28 | encryptor = AES.new(secKey, 2, '0102030405060708') 29 | ciphertext = encryptor.encrypt(text) 30 | ciphertext = base64.b64encode(ciphertext).decode('utf-8') 31 | return ciphertext 32 | 33 | 34 | def rsaEncrypt(text, pubKey, modulus): 35 | text = text[::-1] 36 | rs = pow(int(binascii.hexlify(text), 16), int(pubKey, 16), int(modulus, 16)) 37 | return format(rs, 'x').zfill(256) 38 | 39 | 40 | def createSecretKey(size): 41 | return binascii.hexlify(os.urandom(size))[:16] 42 | -------------------------------------------------------------------------------- /.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 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | local_settings.py 55 | 56 | # Flask stuff: 57 | instance/ 58 | .webassets-cache 59 | 60 | # Scrapy stuff: 61 | .scrapy 62 | 63 | # Sphinx documentation 64 | docs/_build/ 65 | 66 | # PyBuilder 67 | target/ 68 | 69 | # IPython Notebook 70 | .ipynb_checkpoints 71 | 72 | # pyenv 73 | .python-version 74 | 75 | # celery beat schedule file 76 | celerybeat-schedule 77 | 78 | # dotenv 79 | .env 80 | 81 | # virtualenv 82 | venv/ 83 | ENV/ 84 | 85 | # Spyder project settings 86 | .spyderproject 87 | 88 | # Rope project settings 89 | .ropeproject 90 | 91 | # MellPlayer 92 | .idea/* 93 | -------------------------------------------------------------------------------- /mellplayer/utils/getch.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """ 4 | Copyright (c) 2006-2015 sqlmap developers (http://sqlmap.org/) 5 | See the file 'doc/COPYING' for copying permission 6 | """ 7 | 8 | 9 | class _Getch(object): 10 | """ 11 | Gets a single character from standard input. Does not echo to 12 | the screen (reference: http://code.activestate.com/recipes/134892/) 13 | """ 14 | def __init__(self): 15 | try: 16 | self.impl = _GetchWindows() 17 | except ImportError: 18 | try: 19 | self.impl = _GetchMacCarbon() 20 | except(AttributeError, ImportError): 21 | self.impl = _GetchUnix() 22 | 23 | def __call__(self): 24 | return self.impl() 25 | 26 | 27 | class _GetchUnix(object): 28 | def __init__(self): 29 | import tty 30 | 31 | def __call__(self): 32 | import sys 33 | import termios 34 | import tty 35 | 36 | fd = sys.stdin.fileno() 37 | old_settings = termios.tcgetattr(fd) 38 | try: 39 | tty.setraw(sys.stdin.fileno()) 40 | ch = sys.stdin.read(1) 41 | finally: 42 | termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) 43 | return ch 44 | 45 | 46 | class _GetchWindows(object): 47 | def __init__(self): 48 | import msvcrt 49 | 50 | def __call__(self): 51 | import msvcrt 52 | return msvcrt.getch() 53 | 54 | 55 | class _GetchMacCarbon(object): 56 | """ 57 | A function which returns the current ASCII key that is down; 58 | if no ASCII key is down, the null string is returned. The 59 | page http://www.mactech.com/macintosh-c/chap02-1.html was 60 | very helpful in figuring out how to do this. 61 | """ 62 | def __init__(self): 63 | import Carbon 64 | Carbon.Evt # see if it has this (in Unix, it doesn't) 65 | 66 | def __call__(self): 67 | import Carbon 68 | if Carbon.Evt.EventAvail(0x0008)[0] == 0: # 0x0008 is the keyDownMask 69 | return '' 70 | else: 71 | # 72 | # The event contains the following info: 73 | # (what,msg,when,where,mod)=Carbon.Evt.GetNextEvent(0x0008)[1] 74 | # 75 | # The message (msg) contains the ASCII char which is 76 | # extracted with the 0x000000FF charCodeMask; this 77 | # number is converted to an ASCII character with chr() and 78 | # returned 79 | # 80 | (what, msg, when, where, mod) = Carbon.Evt.GetNextEvent(0x0008)[1] 81 | return chr(msg & 0x000000FF) 82 | 83 | 84 | getch = _Getch() 85 | -------------------------------------------------------------------------------- /mellplayer/controller.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | ''' 5 | MellPlayer Controller 6 | 7 | Created on 2017-03-05 8 | @author: Mellcap 9 | ''' 10 | import threading 11 | 12 | from mellplayer.player import mell_player 13 | from mellplayer.ui import mell_ui, mell_help_ui, mell_lyric_ui, SONG_CATEGORIES 14 | from mellplayer.mell_logger import mell_logger 15 | 16 | 17 | # =========================== 18 | # Controller Handler 19 | # =========================== 20 | 21 | def handler_space(): 22 | current_category = SONG_CATEGORIES[mell_ui.mark_index] 23 | if mell_player.category == current_category: 24 | handler_play() 25 | else: 26 | # change UI play_index 27 | mell_ui.update_play_index() 28 | # change playlist category 29 | mell_player.switch_category(new_category=current_category) 30 | 31 | 32 | def handler_next_line(): 33 | mell_ui.next_line() 34 | 35 | def handler_prev_line(): 36 | mell_ui.prev_line() 37 | 38 | def handler_play(): 39 | mell_player.start_or_pause() 40 | 41 | def handler_next_song(): 42 | mell_player.next_song() 43 | 44 | def handler_prev_song(): 45 | mell_player.prev_song() 46 | 47 | def handler_next_playlist(): 48 | mell_player.next_playlist() 49 | 50 | def handler_prev_playlist(): 51 | mell_player.prev_playlist() 52 | 53 | def handler_reduce_volume(): 54 | mell_player.reduce_volume() 55 | 56 | def handler_increase_volume(): 57 | mell_player.increase_volume() 58 | 59 | def handler_mute_volume(): 60 | mell_player.mute_volume() 61 | 62 | def handler_lyric(): 63 | if mell_ui.ui_mode == 'home' and mell_player.time_remaining: 64 | mell_ui.ui_mode = 'lyric' 65 | handler_lyric_display() 66 | elif mell_ui.ui_mode == 'lyric': 67 | mell_ui.display() 68 | mell_ui.ui_mode = 'home' 69 | 70 | def handler_lyric_display(): 71 | song_id = mell_player.playlist_ids[mell_player.playlist_index] 72 | if mell_player.lyric_id != song_id: 73 | mell_lyric_ui.initial_lyric() 74 | mell_player.get_lyric_detail() 75 | mell_lyric_ui.display() 76 | 77 | def handler_help(): 78 | if mell_ui.ui_mode == 'home': 79 | mell_help_ui.display() 80 | mell_ui.ui_mode = 'help' 81 | elif mell_ui.ui_mode == 'help': 82 | mell_ui.display() 83 | mell_ui.ui_mode = 'home' 84 | 85 | def handler_quit(): 86 | mell_player.terminate() 87 | 88 | 89 | # =========================== 90 | # Initial Player 91 | # =========================== 92 | 93 | def i_player(): 94 | current_category = SONG_CATEGORIES[mell_ui.mark_index] 95 | mell_player.switch_category(new_category=current_category) 96 | # handler_update_playInfo() 97 | 98 | def initial_player(): 99 | initPlayer_thread = threading.Thread(target=i_player) 100 | initPlayer_thread.start() 101 | 102 | -------------------------------------------------------------------------------- /mellplayer/watcher.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | ''' 5 | MellPlayer Watcher 6 | 7 | Created on 2017-03-05 8 | @author: Mellcap 9 | ''' 10 | 11 | import sys 12 | import time 13 | import queue 14 | import threading 15 | 16 | from mellplayer.controller import * 17 | from mellplayer.utils import getch 18 | 19 | KEY_QUEUE = queue.Queue() 20 | CONFIG = { 21 | # 主页 22 | 'q': 'quit', 23 | 'j': 'next_line', 24 | 'k': 'prev_line', 25 | # 音乐 26 | ' ': 'space', 27 | 'n': 'next_song', 28 | 'p': 'prev_song', 29 | 'f': 'next_playlist', 30 | 'b': 'prev_playlist', 31 | # 音量 32 | '-': 'reduce_volume', 33 | '=': 'increase_volume', 34 | 'm': 'mute_volume', 35 | # 歌词 36 | 'l': 'lyric', 37 | # 帮助 38 | 'h': 'help' 39 | } 40 | 41 | # =========================== 42 | # Key Watcher 43 | # =========================== 44 | 45 | def k_watcher(): 46 | while 1: 47 | key = getch.getch() 48 | KEY_QUEUE.put(key) 49 | if key == 'q': 50 | mell_player.is_quit = True 51 | break 52 | 53 | def k_executor(): 54 | while 1: 55 | key = KEY_QUEUE.get() 56 | action = CONFIG.get(key, None) 57 | if action: 58 | func = 'handler_%s' % action 59 | eval(func)() 60 | if action == 'quit': 61 | break 62 | 63 | def key_watcher(): 64 | k_watcher_thread = threading.Thread(target=k_watcher) 65 | k_executor_thread = threading.Thread(target=k_executor) 66 | 67 | k_watcher_thread.start() 68 | k_executor_thread.start() 69 | k_watcher_thread.join() 70 | k_executor_thread.join() 71 | 72 | 73 | # =========================== 74 | # Time Watcher 75 | # =========================== 76 | 77 | def t_watcher(): 78 | while not mell_player.is_quit: 79 | if not mell_player.pause: 80 | time_pos = mell_player.time_pos 81 | time_remain = mell_player.time_remaining 82 | if time_pos and time_remain: 83 | if mell_ui.ui_mode == 'lyric': 84 | mell_lyric_ui.roll(int(time_pos)) 85 | if time_remain <= 2: 86 | handler_next_song() 87 | timestamp = format_timestamp(time_pos, time_remain) 88 | show_footer(timestamp=timestamp) 89 | time.sleep(1) 90 | 91 | def format_timestamp(time_pos, time_remain): 92 | total_time = time_pos + time_remain 93 | format_time_pos = '%02d:%02d' % divmod(time_pos, 60) 94 | format_time_total = '%02d:%02d' % divmod(total_time, 60) 95 | return ' / '.join((format_time_pos, format_time_total)) 96 | 97 | def show_footer(timestamp): 98 | timestamp = mell_ui.gen_color(data=timestamp, color='blue') 99 | footer = timestamp.rjust(mell_ui.screen_width + 13) + '\r' 100 | sys.stdout.write(footer) 101 | sys.stdout.flush() 102 | 103 | 104 | def time_watcher(): 105 | t_watcher_thread = threading.Thread(target=t_watcher) 106 | t_watcher_thread.start() 107 | 108 | -------------------------------------------------------------------------------- /mellplayer/api.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | ''' 5 | Netease Music API 6 | 7 | Created on 2017-02-19 8 | @author: Mellcap 9 | ''' 10 | import requests 11 | import json 12 | 13 | from mellplayer.utils.encrypt_utils import encrypted_request 14 | from mellplayer.mell_logger import mell_logger 15 | 16 | 17 | class Netease(object): 18 | 19 | def __init__(self): 20 | self.playlist_categories = [] 21 | 22 | def _request(self, url, method='GET', is_raw=True, data=None): 23 | ''' 24 | 对requests简单封装 25 | ''' 26 | headers = {'appver': '2.0.2', 'Referer': 'http://music.163.com'} 27 | if method == 'GET': 28 | result = requests.get(url=url, headers=headers) 29 | elif method == 'POST' and data: 30 | result = requests.post(url=url, data=data, headers=headers) 31 | # if request failed, return False 32 | if not result.ok: 33 | return False 34 | result.encoding = 'UTF-8' 35 | if is_raw: 36 | return json.loads(result.text) 37 | return result.text 38 | 39 | def playlist_categories(self): 40 | ''' 41 | 分类歌单 42 | http://music.163.com/discover/playlist/ 43 | ''' 44 | url = 'http://music.163.com/discover/playlist/' 45 | result = self._request(url) 46 | return result 47 | 48 | def category_playlists(self, category='流行', offset=0, limit=50, order='hot', total='false'): 49 | ''' 50 | 分类详情 51 | http://music.163.com/api/playlist/list?cat=流行&order=hot&offset=0&total=false&limit=50 52 | ''' 53 | url = 'http://music.163.com/api/playlist/list?cat=%s&order=%s&offset=%s&total=%s&limit=%s' % (category, order, offset, total, limit) 54 | result = self._request(url) 55 | return result 56 | 57 | def playlist_detail(self, playlist_id): 58 | ''' 59 | 歌单详情 60 | http://music.163.com/api/playlist/detail?id=xxx 61 | ''' 62 | url = 'http://music.163.com/api/playlist/detail?id=%s' % playlist_id 63 | result = self._request(url) 64 | return result 65 | 66 | 67 | def song_detail(self, song_ids): 68 | ''' 69 | 歌曲详情 70 | http://music.163.com/api/song/detail?ids=[xxx, xxx] 71 | ''' 72 | url = 'http://music.163.com/api/song/detail?ids=%s' % song_ids 73 | result = self._request(url) 74 | return result 75 | 76 | def song_detail_new(self, song_ids): 77 | url = 'http://music.163.com/weapi/song/enhance/player/url?csrf_token=' 78 | data = {'ids': song_ids, 'br': 320000, 'csrf_token': ''} 79 | data = encrypted_request(data) 80 | result = self._request(url, method="POST", data=data) 81 | return result 82 | 83 | 84 | def lyric_detail(self, song_id): 85 | ''' 86 | 歌词详情 87 | http://music.163.com/api/song/lyric?os=osx&id=xxx&lv=-1&kv=-1&tv=-1 88 | ''' 89 | url = 'http://music.163.com/api/song/lyric?os=osx&id=%s&lv=-1&kv=-1&tv=-1' % song_id 90 | result = self._request(url) 91 | return result 92 | 93 | 94 | def parse_info(self, data, parse_type): 95 | ''' 96 | 解析信息 97 | ''' 98 | res = None 99 | if parse_type == 'category_playlists': 100 | res = [d['id'] for d in data['playlists']] 101 | elif parse_type == 'playlist_detail': 102 | tracks = data['result']['tracks'] 103 | playlist_ids = [t['id'] for t in tracks] 104 | playlist_detail = {t['id']: { 105 | 'song_id': t['id'], 106 | 'song_name': t['name'], 107 | 'song_url': t['mp3Url'], 108 | 'song_artists': ' & '.join(map(lambda a: a['name'], t['artists'])) 109 | } for t in tracks} 110 | res = (playlist_ids, playlist_detail) 111 | elif parse_type == 'lyric_detail': 112 | if 'lrc' in data: 113 | res = { 114 | 'lyric': data['lrc']['lyric'] 115 | } 116 | else: 117 | res = { 118 | 'lyric': 'no_lyric' 119 | } 120 | elif parse_type == 'song_detail_new': 121 | res = {d['id']: { 122 | 'song_url': d['url'], 123 | 'song_br': d['br'] 124 | } for d in data['data']} 125 | return res 126 | 127 | 128 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MellPlayer 2 | ![https://pypi.python.org/pypi/MellPlayer/0.1.0](https://img.shields.io/badge/pypi-v0.1.0-orange.svg) 3 | ![https://pypi.python.org/pypi/MellPlayer/0.1.0](https://img.shields.io/badge/python-3.5,3.6-blue.svg) 4 | ![https://pypi.python.org/pypi/MellPlayer/0.1.0](https://img.shields.io/badge/license-MIT-blue.svg) 5 | 6 | A tiny terminal player based on Python3. 7 | 8 | ![](document/mellplayer_tutorial.gif) 9 | 10 | # OverView 11 | * [English Tutorial](#English Tutorial) 12 | - [Support](#Support) 13 | - [Installation](#Installation) 14 | - [Additional Mac OSX Installation notes](#Additional Mac OSX Installation notes) 15 | - [Additional Ubuntu Installation notes](#Additional Ubuntu Installation notes) 16 | - [Upgrading](#Upgrading) 17 | - [Usage](#Usage) 18 | - [Keys](#Keys) 19 | * [中文说明](#Chinese Tutorial) 20 | - [前言](#Preface_cn) 21 | - [开发理念](#Develop idea_cn) 22 | - [关于项目](#About Repo_cn) 23 | - [支持](#Support_cn) 24 | - [安装](#Installation_cn) 25 | - [Mac OSX 安装依赖](#Additional Mac OSX Installation notes_cn) 26 | - [Ubuntu 安装依赖](#Additional Ubuntu Installation notes_cn) 27 | - [更新](#Upgrading_cn) 28 | - [使用](#Usage_cn) 29 | - [快捷键](#Keys_cn) 30 | 31 | ## English Tutorial 32 | 33 | ### Support 34 | OSX & Linux (Linux still have some bugs) 35 | 36 | ### Installation 37 | Using [pip](https://pip.pypa.io/en/stable/) 38 | ```bash 39 | [sudo] pip3 install MellPlayer 40 | ``` 41 | 42 | ### Additional Mac OSX Installation notes 43 | Install mpv with [Homebrew](https://brew.sh/) 44 | ```bash 45 | brew install mpv 46 | ``` 47 | 48 | ### Additional Ubuntu Installation notes 49 | 50 | Install mpv with apt-get 51 | ```bash 52 | sudo apt-get install libmpv-dev mpv 53 | ``` 54 | 55 | ### Upgrading 56 | Upgrade pip installation: 57 | ```bash 58 | [sudo] pip3 install MellPlayer --upgrade 59 | ``` 60 | 61 | ### Usage 62 | MellPlayer is run on the command line using the command: 63 | ```bash 64 | mellplayer 65 | ``` 66 | 67 | ### Keys 68 | ``` 69 | 70 | 操作 71 | [j] [Next Line] ---> 下 72 | [k] [Prev Line] ---> 上 73 | [q] [Quit] ---> 退出 74 | 75 | 音乐 76 | [space] [Start/Pause] ---> 播放/暂停 77 | [n] [Next Song] ---> 下一曲 78 | [p] [Prev Song] ---> 上一曲 79 | [f] [Forward Playlist] ---> 下个歌单 80 | [b] [Backward Playlist] ---> 上个歌单 81 | 82 | 音量 83 | [-] [Reduce Volume] ---> 减小音量 84 | [=] [Increase Volume] ---> 增加音量 85 | [m] [Mute] ---> 静音 86 | 87 | 歌词 88 | [l] [Show/Hide Lyric] ---> 显示/关闭歌词 89 | 90 | 帮助 91 | [h] [Show/Hide Help] ---> 显示/关闭帮助 92 | 93 | ``` 94 | 95 | 96 | ## 中文说明 97 | 98 | ### 前言 99 | 我写代码时非常喜欢听音乐,最近在歌单中听到了许多入耳惊艳的歌,觉得非常不错。但是歌单的随机播放以及快速切换是个软肋,于是开发了MellPlayer,可以按照分类随机听歌,实现了歌单间的快速切换,希望大家能够喜欢。 100 | 101 | ### 开发理念 102 | MellPlayer的初版刚刚发布,还有许许多多需要改进的地方,非常希望能有志同道合的朋友Fork下来,一起打造越来越完美的播放器,下面就说下我的开发理念: 103 | 104 | >MellPlayer是一款命令行播放器,主要是为了实现根据心情随机听歌,并且能够快速进行歌单间的切换,简约流畅,我希望在此基础上谨慎添加小而美的功能。并不想引入过多繁琐的功能,添加一大堆的快捷键,将简洁的东西繁琐化是违背我的初衷的。 105 | 106 | 107 | ### 关于项目 108 | 项目地址:[MellPlayer](https://github.com/Mellcap/MellPlayer) 109 | 110 | 项目基于python3开发,依赖mpv。还有很多地方需要优化改进,大家发现什么问题可以给我提Issue,当然非常欢迎有兴趣的朋友加入,一起打造我们喜欢的播放器。 111 | 112 | 既然看到这儿了,就来 [Star](https://github.com/Mellcap/MellPlayer) 一下, 互相 [Follow](https://github.com/Mellcap) 一下吧哈哈!!! 113 | 114 | #### 支持 115 | OSX & Linux (Linux 仍有些bug待修复) 116 | 117 | ### 安装 118 | 通过 [pip3](https://pip.pypa.io/en/stable/) 安装 119 | ```bash 120 | [sudo] pip3 install MellPlayer 121 | ``` 122 | 123 | #### Mac OSX 安装依赖 124 | 通过 [Homebrew](https://brew.sh/) 安装 mpv 125 | ```bash 126 | brew install mpv 127 | ``` 128 | 129 | #### Ubuntu 安装依赖 130 | 通过 apt-get 安装 mpv 131 | ```bash 132 | sudo apt-get install libmpv-dev mpv 133 | ``` 134 | 135 | #### 更新 136 | 通过 pip3 更新 137 | ```bash 138 | [sudo] pip3 install MellPlayer --upgrade 139 | ``` 140 | 141 | ### 使用 142 | 在命令行直接输入mellplayer即可享受: 143 | ```bash 144 | mellplayer 145 | ``` 146 | 147 | #### 快捷键 148 | ``` 149 | 150 | 操作 151 | [j] [Next Line] ---> 下 152 | [k] [Prev Line] ---> 上 153 | [q] [Quit] ---> 退出 154 | 155 | 音乐 156 | [space] [Start/Pause] ---> 播放/暂停 157 | [n] [Next Song] ---> 下一曲 158 | [p] [Prev Song] ---> 上一曲 159 | [f] [Forward Playlist] ---> 下个歌单 160 | [b] [Backward Playlist] ---> 上个歌单 161 | 162 | 音量 163 | [-] [Reduce Volume] ---> 减小音量 164 | [=] [Increase Volume] ---> 增加音量 165 | [m] [Mute] ---> 静音 166 | 167 | 歌词 168 | [l] [Show/Hide Lyric] ---> 显示/关闭歌词 169 | 170 | 帮助 171 | [h] [Show/Hide Help] ---> 显示/关闭帮助 172 | 173 | ``` 174 | 175 | 176 | 177 | -------------------------------------------------------------------------------- /mellplayer/player.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | ''' 5 | MellPlayer Player 6 | 7 | Created on 2017-02-20 8 | @author: Mellcap 9 | ''' 10 | 11 | import os 12 | import time 13 | import threading 14 | import datetime 15 | 16 | from mellplayer.utils.mpv import MPV 17 | from mellplayer.api import Netease 18 | from mellplayer.directory import BASE_DIRECTORY 19 | from mellplayer.event.ui_event import UIEvent 20 | from mellplayer.deco import show_changing_text, show_song_info_text, update_title_text 21 | from mellplayer.mell_logger import mell_logger 22 | 23 | PLAYLIST_MAX = 50 24 | # PLAYLIST_FILE = os.path.join(BASE_DIRECTORY, 'playlist.m3u') 25 | NeteaseApi = Netease() 26 | UiEvent = UIEvent() 27 | 28 | 29 | class Player(MPV): 30 | 31 | def __init__(self, *extra_mpv_flags, log_handler=None, start_event_thread=True, **extra_mpv_opts): 32 | super(Player, self).__init__(*extra_mpv_flags, log_handler=log_handler, start_event_thread=start_event_thread, **extra_mpv_opts) 33 | self.category = None 34 | self.category_playlist_ids = None 35 | self.category_playlist_index = 0 36 | self.playlist_ids = None 37 | self.playlist_index = 0 38 | self.playlist_detail = None 39 | self.song_info = '' 40 | self.song_br = 0 41 | self.lyric_id = 0 42 | self.is_quit = False 43 | self.volume = 100 44 | 45 | # =========================== 46 | # Player Controller 47 | # =========================== 48 | 49 | def start_or_pause(self): 50 | self.pause = not self.pause 51 | 52 | def switch_song(self, action='next'): 53 | ''' 54 | action: next/prev 55 | ''' 56 | if action == 'next': 57 | self.playlist_next() 58 | elif action == 'prev': 59 | self.playlist_prev() 60 | 61 | @show_changing_text 62 | def next_song(self): 63 | # self.playlist_next() 64 | playlist_ids = self.playlist_ids 65 | if playlist_ids: 66 | self.playlist_index += 1 67 | if self.playlist_index >= len(playlist_ids): 68 | self.playlist_index = 0 69 | self.run_player() 70 | 71 | @show_changing_text 72 | def prev_song(self): 73 | # self.playlist_prev() 74 | playlist_ids = self.playlist_ids 75 | if playlist_ids: 76 | self.playlist_index -= 1 77 | if self.playlist_index < 0: 78 | self.playlist_index = len(self.playlist_ids) - 1 79 | self.run_player() 80 | 81 | def switch_playlist(self): 82 | ''' 83 | action: next/prev 84 | ''' 85 | if action == 'next': 86 | self.next_playlist() 87 | elif action == 'prev': 88 | self.prev_playlist() 89 | 90 | @show_changing_text 91 | def next_playlist(self): 92 | category_playlist_ids = self.category_playlist_ids 93 | if category_playlist_ids: 94 | self.category_playlist_index += 1 95 | if self.category_playlist_index >= len(self.category_playlist_ids): 96 | self.category_playlist_index = 0 97 | self.run_playlist() 98 | 99 | @show_changing_text 100 | def prev_playlist(self): 101 | category_playlist_ids = self.category_playlist_ids 102 | if category_playlist_ids: 103 | self.category_playlist_index -= 1 104 | if self.category_playlist_index < 0: 105 | self.category_playlist_index = len(self.category_playlist_ids) - 1 106 | self.run_playlist() 107 | 108 | @show_changing_text 109 | def switch_category(self, new_category): 110 | self.category = new_category 111 | self.get_category_playlist_ids() 112 | self.run_playlist() 113 | 114 | # =========================== 115 | # Play Info 116 | # =========================== 117 | 118 | def get_category_playlist_ids(self): 119 | ''' 120 | 获取该类别-歌单id列表 121 | ''' 122 | category = self.category or '全部' 123 | # initial category_playlist_index 124 | self.category_playlist_index = 0 125 | # get data 126 | data = NeteaseApi.category_playlists(category=category) 127 | category_playlist_ids = NeteaseApi.parse_info(data=data, parse_type='category_playlists') 128 | self.category_playlist_ids = tuple(category_playlist_ids) 129 | 130 | def get_playlist(self): 131 | ''' 132 | 获取该歌单-歌曲id列表 133 | ''' 134 | playlist_id = self.category_playlist_ids[self.category_playlist_index] 135 | data = NeteaseApi.playlist_detail(playlist_id) 136 | playlist_ids, playlist_detail = NeteaseApi.parse_info(data=data, parse_type='playlist_detail') 137 | self.playlist_ids = playlist_ids 138 | self.playlist_detail = playlist_detail 139 | self.update_playlist_url() 140 | 141 | def update_playlist_url(self): 142 | ''' 143 | 旧的mp3_url有很多无法播放,采用新的接口 144 | 新接口mp3_url:有时间戳,经过一段时间失效,已在logger中refresh_playlist 145 | ''' 146 | song_ids = self.playlist_ids 147 | data = NeteaseApi.song_detail_new(song_ids) 148 | song_details = NeteaseApi.parse_info(data=data, parse_type='song_detail_new') 149 | for song_id in self.playlist_detail: 150 | song_detail = song_details.get(song_id, None) 151 | if not song_detail: 152 | continue 153 | song_info = { 154 | 'song_url': song_detail.get('song_url', None), 155 | 'song_br': song_detail.get('song_br', None) 156 | } 157 | self.playlist_detail[song_id].update(song_info) 158 | 159 | def get_lyric_detail(self): 160 | ''' 161 | 获取歌词详情 162 | ''' 163 | song_id = self.playlist_ids[self.playlist_index] 164 | if self.lyric_id != song_id: 165 | self.lyric_id = song_id 166 | data = NeteaseApi.lyric_detail(song_id=song_id) 167 | lyric_detail = NeteaseApi.parse_info(data=data, parse_type='lyric_detail')['lyric'] 168 | UiEvent.handler_parse_lyric(origin_lyric=lyric_detail) 169 | 170 | def update_song_info(self): 171 | ''' 172 | 更新歌曲信息:歌名 & 歌手名 173 | ''' 174 | if self.playlist_ids and self.playlist_detail: 175 | play_detail = self.playlist_detail.get(self.playlist_ids[self.playlist_index], None) 176 | if play_detail: 177 | self.song_info = [play_detail.get('song_name', ''), play_detail.get('song_artists', '')] 178 | self.song_br = play_detail.get('song_br', 0) 179 | 180 | def get_play_info(self): 181 | ''' 182 | 待作废 183 | ''' 184 | self.update_song_info() 185 | return self.song_info 186 | 187 | def show_song_info(self): 188 | ''' 189 | 歌曲信息、音量等信息 190 | ''' 191 | self.update_song_info() 192 | UiEvent.handler_update_playInfo(self.song_info) 193 | self.update_title() 194 | 195 | def update_title(self): 196 | ''' 197 | 更新title: 码率和音量 198 | ''' 199 | song_br = '%s%s' % (int(int(self.song_br)/1000), 'Kbps') 200 | volume = 'Volume: %s%s' % (int(self.get_volume()), '%') 201 | UiEvent.handler_update_title(items=[song_br, volume]) 202 | 203 | def show_song_changing(self): 204 | ''' 205 | 加载歌曲loading 206 | ''' 207 | changing_text = '加载歌曲中...' 208 | UiEvent.handler_update_playInfo(changing_text) 209 | 210 | def run_playlist(self): 211 | ''' 212 | 启动播放列表 213 | ''' 214 | # initial playlist_index 215 | self.playlist_index = 0 216 | self.get_playlist() 217 | self.run_player() 218 | 219 | # @show_song_info_text 220 | def run_player(self): 221 | ''' 222 | 启动播放器 223 | ''' 224 | if self.playlist_detail and self.playlist_ids: 225 | song_id = self.playlist_ids[self.playlist_index] 226 | song_info = self.playlist_detail.get(song_id, {}) 227 | if not song_info: 228 | mell_logger.error('Can not get song_info, song_id: %s' % song_id) 229 | pass 230 | song_url = song_info.get('song_url', None) 231 | if not song_url: 232 | mell_logger.error('Can not get song_url, song_id: %s' % song_id) 233 | self.next_song() 234 | else: 235 | self.play(song_url) 236 | self.show_song_info() 237 | 238 | 239 | # =========================== 240 | # Volume Controller 241 | # =========================== 242 | 243 | @update_title_text 244 | def reduce_volume(self, step=10): 245 | ''' 246 | 减小音量 247 | ''' 248 | volume = max(self.volume - step, 0) 249 | self.volume = volume 250 | 251 | @update_title_text 252 | def increase_volume(self, step=10): 253 | ''' 254 | 增加音量 255 | ''' 256 | volume = min(self.volume + step, 100) 257 | self.volume = volume 258 | 259 | @update_title_text 260 | def mute_volume(self): 261 | ''' 262 | 静音 263 | ''' 264 | self.mute = not self.mute 265 | 266 | def get_volume(self): 267 | return self.volume 268 | 269 | 270 | # =========================== 271 | # Playlist 272 | # =========================== 273 | # ***Have some bugs 274 | 275 | def init_playlist(self): 276 | ''' 277 | playlist会发生闪退问题,暂时自己控制播放列表 278 | ''' 279 | if os.path.exists(PLAYLIST_FILE): 280 | self.loadlist(PLAYLIST_FILE) 281 | 282 | def loop_playlist(self): 283 | ''' 284 | 循环播放 285 | ''' 286 | self._set_property('loop', True) 287 | 288 | def save_playlist(self): 289 | ''' 290 | 保存播放列表为m3u格式 291 | ''' 292 | playlist = [] 293 | m3u_title = '#EXTM3U\n' 294 | playlist.append(m3u_title) 295 | for song in self.playlist_detail: 296 | song_detail = '#EXTINF:,%s\n%s\n' % (song['song_id'], song['song_url']) 297 | playlist.append(song_detail) 298 | with open(PLAYLIST_FILE, 'w') as f: 299 | for line in playlist: 300 | f.write(line) 301 | return True 302 | 303 | 304 | 305 | # =========================== 306 | # Instance 307 | # =========================== 308 | 309 | def player_logger(loglevel, component, message): 310 | if loglevel == 'error': 311 | # print('[{}] {}: {}\r'.format(loglevel, component, message)) 312 | refresh_playlist() 313 | # mell_logger.info('[%s] %s: %s' % (loglevel, component, message)) 314 | 315 | mell_player = Player(log_handler=player_logger, ytdl=True) 316 | 317 | def refresh_playlist(): 318 | mell_player.get_playlist() 319 | mell_player.run_player() 320 | -------------------------------------------------------------------------------- /mellplayer/ui.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | ''' 5 | MellPlayer UI 6 | 7 | Created on 2017-02-21 8 | @author: Mellcap 9 | ''' 10 | 11 | import os 12 | import re 13 | 14 | from mellplayer.mell_logger import mell_logger 15 | 16 | 17 | SONG_CATEGORIES = ( 18 | '榜单', '流行', '摇滚', '民谣', '说唱', '轻音乐', '爵士', '乡村', '古典', '电子', '舞曲', '另类/独立',\ 19 | '学习', '工作', '午休', '下午茶', '清晨', '夜晚',\ 20 | '华语', '欧美', '日语', '韩语', '粤语', '小语种',\ 21 | '怀旧', '清新', '浪漫', '性感', '伤感', '治愈', '放松', '孤独', '感动', '兴奋', '快乐', '安静', '思念', \ 22 | '民族', '英伦', '金属', '朋克', '蓝调', '雷鬼', '世界音乐', '拉丁', 'New Age', '古风', '后摇', 'Bossa Nova',\ 23 | '地铁', '驾车', '运动', '旅行', '散步', '酒吧',\ 24 | '影视原声', 'ACG', '校园', '游戏', '70后', '80后', '90后', '00后',\ 25 | '网络歌曲', 'KTV', '经典', '翻唱', '吉他', '钢琴', '器乐', '儿童', 26 | ) 27 | 28 | # 所有颜色 https://upload.wikimedia.org/wikipedia/commons/1/15/Xterm_256color_chart.svg 29 | FOREGROUND_COLOR = { # 前景色 30 | 'default' : 249, 31 | 'white' : 15, 32 | 'blue' : 39, 33 | 'green' : 118, 34 | 'gray' : 239, 35 | 'red' : 196, 36 | 'pink' : 201, 37 | 'yellow' : 220, 38 | 'light_gray' : 250, 39 | } 40 | 41 | BLANK_CONSTANT = 3 42 | TERMINAL_SIZE = os.get_terminal_size() 43 | BOLD_STR = '\033[1m' 44 | MAX_LINES = len(SONG_CATEGORIES) 45 | ALL_LINES = MAX_LINES + BLANK_CONSTANT 46 | 47 | class UI(object): 48 | 49 | def __init__(self, ui_mode='home'): 50 | self.category_lines = SONG_CATEGORIES 51 | self.mark_index = 0 52 | self.play_index= 0 53 | self.play_info = '' 54 | 55 | self.top_index = 0 56 | self.screen_height = TERMINAL_SIZE.lines 57 | self.screen_width = TERMINAL_SIZE.columns 58 | self.base_title = self._get_base_title() 59 | self.title = self.base_title 60 | self.ui_mode = ui_mode 61 | 62 | # ===================== 63 | # UI Displayer 64 | # ===================== 65 | 66 | def _get_base_title(self): 67 | player_name = '%s%s' % (BOLD_STR, self.gen_color('MellPlayer', 'blue')) 68 | # netease = self.gen_color('网易云音乐', 'red') 69 | divider = self.gen_color(data=r'\\') 70 | display_items = [player_name] 71 | return (' %s ' % divider).join(display_items) 72 | 73 | def update_title(self, items=None): 74 | if not items: 75 | self.title = self.base_title 76 | else: 77 | divider = self.gen_color(data=r'\\') 78 | divider_play = self.gen_color(data=r'>>') 79 | items = [self.gen_color(data=i, color='pink') for i in items] 80 | extend_title = (' %s ' % divider).join(items) 81 | extend_title = ' %s %s' % (divider_play, extend_title) 82 | self.title = self.base_title + extend_title 83 | self.display() 84 | 85 | def display(self): 86 | ''' 87 | UI 输出 88 | 说明:多线程终端输出有问题,在每行结尾加\r 89 | ''' 90 | display_lines = ['\r'] 91 | display_title = '\n%s%s' % (' '*5, self.title) 92 | display_lines.append(display_title) 93 | top_index = self.top_index 94 | bottom_index = (self.screen_height - BLANK_CONSTANT) + top_index 95 | 96 | for index, category in enumerate(self.category_lines[top_index: bottom_index]): 97 | # mark_index 98 | is_markline = True if (index + self.top_index) == self.mark_index else False 99 | category = self.gen_category(category, is_markline) 100 | # play_index 101 | play_info = '' 102 | is_playline = True if (index + self.top_index) == self.play_index else False 103 | if is_playline: 104 | play_info = self.gen_playline() 105 | 106 | complete_line = '%s%s%s' % (category, ' '*10, play_info) 107 | display_lines.append(complete_line) 108 | 109 | if ALL_LINES < self.screen_height: 110 | # fill_blanks 111 | display_lines = self.fill_blanks(display_lines) 112 | # add tail 113 | display_lines = self.add_tail(source_list=display_lines, tail='\r') 114 | print('\n'.join(display_lines)) 115 | 116 | def gen_category(self, category, is_markline=False): 117 | ''' 118 | 生成分类样式 119 | ''' 120 | if is_markline: 121 | category = self.gen_mark(category) 122 | category = self.gen_color(data=category, color='yellow') 123 | else: 124 | category = '%s%s' % (' '*5, category) 125 | category = self.gen_color(data=category, color='') 126 | return category 127 | 128 | def gen_mark(self, category): 129 | return ' ➣ %s' % category 130 | 131 | def gen_playline(self): 132 | ''' 133 | 生成歌曲展示行 134 | ''' 135 | complete_info = [self.gen_color(data=p, color='yellow') for p in self.play_info] 136 | divider = self.gen_color(data='|', color='') 137 | return (' %s ' % divider).join(complete_info) 138 | 139 | # ===================== 140 | # UI Controller 141 | # ===================== 142 | 143 | def next_line(self): 144 | ''' 145 | 下一行 146 | ''' 147 | if self.mark_index < (MAX_LINES - 1): 148 | self.mark_index += 1 149 | bottom_index = (self.screen_height - BLANK_CONSTANT) + self.top_index 150 | if self.mark_index > (bottom_index - 1): 151 | self.top_index += 1 152 | self.display() 153 | 154 | def prev_line(self): 155 | ''' 156 | 上一行 157 | ''' 158 | if self.mark_index > 0: 159 | self.mark_index -= 1 160 | if self.mark_index < self.top_index: 161 | self.top_index -= 1 162 | self.display() 163 | 164 | def update_play_index(self): 165 | ''' 166 | 更新歌曲信息展示在光标选定行 167 | ''' 168 | self.play_index = self.mark_index 169 | 170 | def update_play_info(self, play_info): 171 | ''' 172 | 更新歌曲信息 173 | ''' 174 | if type(play_info) is str: 175 | play_info = [play_info] 176 | self.play_info = play_info 177 | self.display() 178 | 179 | # ===================== 180 | # Utils 181 | # ===================== 182 | 183 | def gen_color(self, data, color='default'): 184 | ''' 185 | 参考地址:http://blog.csdn.net/gatieme/article/details/45439671 186 | 但是目前用不到这么多类型,目前只用前景色 187 | ''' 188 | color_code = FOREGROUND_COLOR.get(color, 246) 189 | data = "\001\033[38;5;%sm\002%s\001\033[0m\002" % (color_code, data) 190 | return data 191 | 192 | def add_tail(self, source_list, tail): 193 | return map(lambda x: '%s%s' % (str(x), tail), source_list) 194 | 195 | def fill_blanks(self, display_lines, all_lines=ALL_LINES, position='after'): 196 | ''' 197 | 补全空白行 198 | ''' 199 | delta_lines = self.screen_height - all_lines 200 | delta_blanks = [' ' for i in range(delta_lines)] 201 | if position == 'after': 202 | display_lines += delta_blanks 203 | elif position == 'before': 204 | display_lines = delta_blanks + display_lines 205 | return display_lines 206 | 207 | 208 | # ===================== 209 | # HelpUI 210 | # ===================== 211 | 212 | HELP_LINES = { 213 | 'help_space_1': '', 214 | 'control_move': '操作', 215 | 'next_line': '[j] [Next Line] ---> 下', 216 | 'prev_line': '[k] [Prev Line] ---> 上', 217 | 'quit': '[q] [Quit] ---> 退出', 218 | 'help_space_2': '', 219 | 'control_music': '音乐', 220 | 'space': '[space] [Start/Pause] ---> 播放/暂停', 221 | 'next_song': '[n] [Next Song] ---> 下一曲', 222 | 'prev_song': '[p] [Prev Song] ---> 上一曲', 223 | 'next_playlist': '[f] [Forward Playlist] ---> 下个歌单', 224 | 'prev_playlist': '[b] [Backward Playlist] ---> 上个歌单', 225 | 'help_space_3': '', 226 | 'control_volume': '音量', 227 | 'reduce_volume': '[-] [Reduce Volume] ---> 减小音量', 228 | 'increase_volume': '[=] [Increase Volume] ---> 增加音量', 229 | 'mute': '[m] [Mute] ---> 静音', 230 | 'help_space_4': '', 231 | 'control_lyric': '歌词', 232 | 'lyric': '[l] [Show/Hide Lyric] ---> 显示/关闭歌词', 233 | 'help_space_5': '', 234 | 'control_help': '帮助', 235 | 'help': '[h] [Show/Hide Help] ---> 显示/关闭帮助' 236 | } 237 | 238 | class HelpUI(UI): 239 | 240 | def __init__(self): 241 | super(HelpUI, self).__init__(ui_mode='help') 242 | 243 | def display(self): 244 | display_lines = ['\r'] 245 | display_title = '\n%s%s' % (' '*5, self.title) 246 | display_lines.append(display_title) 247 | for key, help_line in HELP_LINES.items(): 248 | colored_help_line = self.color_line(key=key, line=help_line) 249 | display_lines.append('%s%s' % (' '*5, colored_help_line)) 250 | 251 | # fill blanks 252 | all_lines = len(HELP_LINES) + BLANK_CONSTANT 253 | if all_lines < self.screen_height: 254 | display_lines = self.fill_blanks(display_lines, all_lines=all_lines) 255 | # add tail 256 | display_lines = self.add_tail(source_list=display_lines, tail='\r') 257 | print('\n'.join(display_lines)) 258 | 259 | def color_line(self, key, line): 260 | if key.startswith('control'): 261 | return self.gen_color(data='%s%s' % (BOLD_STR, line), color='yellow') 262 | else: 263 | return self.gen_color(data=line, color='light_gray') 264 | 265 | # ===================== 266 | # LyricUI 267 | # ===================== 268 | 269 | class LyricUI(UI): 270 | 271 | def __init__(self): 272 | super(LyricUI, self).__init__(ui_mode='lyric') 273 | self.has_lyric = True 274 | self.lyric_times = None 275 | self.lyric_lines = '' 276 | self.lyric_display_lines = '' 277 | 278 | def parse_lyric(self, origin_lyric): 279 | ''' 280 | 解析歌词 281 | ''' 282 | if origin_lyric == 'no_lyric': 283 | self.has_lyric = False 284 | else: 285 | compiler = re.compile('\[(.+)\](.+?)\n') 286 | format_lyric = compiler.findall(origin_lyric) 287 | if format_lyric: 288 | self.lyric_times = [format_minute2second(l[0]) for l in format_lyric] 289 | self.lyric_lines = [l[1] for l in format_lyric] 290 | else: 291 | self.has_lyric = False 292 | 293 | def display(self): 294 | display_lines = ['\r'] 295 | display_title = '\n%s%s' % (' '*5, self.title) 296 | display_lines.append(display_title) 297 | if not self.has_lyric: 298 | text = '求歌词...' 299 | self.display_center(text=text, display_lines=display_lines) 300 | elif not self.lyric_display_lines: 301 | text = '貌似Ta还没开嗓...' 302 | self.display_center(text=text, display_lines=display_lines) 303 | elif self.lyric_lines: 304 | self.display_lyric(display_lines=display_lines) 305 | 306 | def make_display_lines(self, display_lines): 307 | ''' 308 | 生成展示歌词 309 | ''' 310 | all_lines = len(self.lyric_display_lines) + BLANK_CONSTANT 311 | lyric_display_lines = self.lyric_display_lines 312 | if all_lines >= self.screen_height: 313 | display_index = all_lines - self.screen_height 314 | lyric_display_lines = lyric_display_lines[display_index:] 315 | else: 316 | lyric_display_lines = self.fill_blanks(lyric_display_lines, all_lines=all_lines, position='before') 317 | return lyric_display_lines 318 | 319 | def display_lyric(self, display_lines): 320 | lyric_display_lines = self.make_display_lines(display_lines) 321 | for line in lyric_display_lines: 322 | # 居中 323 | line = str_center(string=line, screen_width=self.screen_width) 324 | line = self.gen_color(data=line, color='light_gray') 325 | display_lines.append(line) 326 | display_lines = self.add_tail(source_list=display_lines, tail='\r') 327 | print('\n'.join(display_lines) + '\r') 328 | 329 | def display_center(self, text, display_lines): 330 | blank_length = self.screen_height - BLANK_CONSTANT 331 | blank_lines = [' ' for i in range(blank_length)] 332 | blank_lines[int(blank_length / 2)] = str_center(string=text, screen_width=self.screen_width) 333 | display_lines.extend(blank_lines) 334 | display_lines = self.add_tail(source_list=display_lines, tail='\r') 335 | print('\n'.join(display_lines) + '\r') 336 | 337 | def roll(self, timestamp): 338 | lyric_times = self.lyric_times 339 | if lyric_times: 340 | if timestamp in lyric_times: 341 | lyric_index = lyric_times.index(timestamp) + 1 342 | else: 343 | lyric_times_copy = lyric_times[:] 344 | lyric_times_copy.append(timestamp) 345 | lyric_times_copy.sort() 346 | lyric_index = lyric_times_copy.index(timestamp) 347 | self.lyric_display_lines = self.lyric_lines[:lyric_index] 348 | self.display() 349 | 350 | def initial_lyric(self): 351 | self.has_lyric = True 352 | self.lyric_times = None 353 | self.lyric_lines = '' 354 | self.lyric_display_lines = '' 355 | 356 | 357 | # ===================== 358 | # Basic Method 359 | # ===================== 360 | 361 | def format_minute2second(timestamp): 362 | stamp_list = timestamp[:5].split(':') 363 | return int(stamp_list[0]) * 60 + int(stamp_list[1]) 364 | 365 | def str_len(string): 366 | ''' 367 | 计算string实际占字符长 368 | ''' 369 | row_l = len(string) 370 | utf8_l = len(string.encode('utf-8')) 371 | return int((utf8_l - row_l) / 2 + row_l) 372 | 373 | def str_center(string, screen_width, fill_char=None): 374 | ''' 375 | 字符居中 376 | ''' 377 | delta_width = screen_width - (str_len(string) - len(string)) 378 | if fill_char: 379 | return string.center(delta_width, fill_char) 380 | return string.center(delta_width) 381 | 382 | 383 | # ===================== 384 | # Instance 385 | # ===================== 386 | 387 | mell_ui = UI() 388 | mell_help_ui = HelpUI() 389 | mell_lyric_ui = LyricUI() 390 | -------------------------------------------------------------------------------- /mellplayer/utils/mpv.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | ''' 3 | Python interface to the awesome mpv media player 4 | 5 | Created on 2017-02-19 6 | @author: jaseg 7 | forked from: https://github.com/jaseg/python-mpv 8 | ''' 9 | 10 | from ctypes import * 11 | import ctypes.util 12 | import threading 13 | import os 14 | import sys 15 | from warnings import warn 16 | from functools import partial 17 | import collections 18 | import re 19 | import traceback 20 | 21 | # vim: ts=4 sw=4 et 22 | 23 | if os.name == 'nt': 24 | backend = CDLL('mpv-1.dll') 25 | fs_enc = 'utf-8' 26 | else: 27 | import locale 28 | lc, enc = locale.getlocale(locale.LC_NUMERIC) 29 | # libmpv requires LC_NUMERIC to be set to "C". Since messing with global variables everyone else relies upon is 30 | # still better than segfaulting, we are setting LC_NUMERIC to "C". 31 | locale.setlocale(locale.LC_NUMERIC, 'C') 32 | 33 | sofile = ctypes.util.find_library('mpv') 34 | if sofile is None: 35 | raise OSError("Cannot find libmpv in the usual places. Depending on your distro, you may try installing an " 36 | "mpv-devel or mpv-libs package. If you have libmpv around but this script can't find it, maybe consult " 37 | "the documentation for ctypes.util.find_library which this script uses to look up the library " 38 | "filename.") 39 | backend = CDLL(sofile) 40 | fs_enc = sys.getfilesystemencoding() 41 | 42 | 43 | class MpvHandle(c_void_p): 44 | pass 45 | 46 | class MpvOpenGLCbContext(c_void_p): 47 | pass 48 | 49 | 50 | class PropertyUnavailableError(AttributeError): 51 | pass 52 | 53 | class ErrorCode(object): 54 | """ For documentation on these, see mpv's libmpv/client.h """ 55 | SUCCESS = 0 56 | EVENT_QUEUE_FULL = -1 57 | NOMEM = -2 58 | UNINITIALIZED = -3 59 | INVALID_PARAMETER = -4 60 | OPTION_NOT_FOUND = -5 61 | OPTION_FORMAT = -6 62 | OPTION_ERROR = -7 63 | PROPERTY_NOT_FOUND = -8 64 | PROPERTY_FORMAT = -9 65 | PROPERTY_UNAVAILABLE = -10 66 | PROPERTY_ERROR = -11 67 | COMMAND = -12 68 | 69 | EXCEPTION_DICT = { 70 | 0: None, 71 | -1: lambda *a: MemoryError('mpv event queue full', *a), 72 | -2: lambda *a: MemoryError('mpv cannot allocate memory', *a), 73 | -3: lambda *a: ValueError('Uninitialized mpv handle used', *a), 74 | -4: lambda *a: ValueError('Invalid value for mpv parameter', *a), 75 | -5: lambda *a: AttributeError('mpv option does not exist', *a), 76 | -6: lambda *a: TypeError('Tried to set mpv option using wrong format', *a), 77 | -7: lambda *a: ValueError('Invalid value for mpv option', *a), 78 | -8: lambda *a: AttributeError('mpv property does not exist', *a), 79 | # Currently (mpv 0.18.1) there is a bug causing a PROPERTY_FORMAT error to be returned instead of 80 | # INVALID_PARAMETER when setting a property-mapped option to an invalid value. 81 | -9: lambda *a: TypeError('Tried to get/set mpv property using wrong format, or passed invalid value', *a), 82 | -10: lambda *a: PropertyUnavailableError('mpv property is not available', *a), 83 | -11: lambda *a: RuntimeError('Generic error getting or setting mpv property', *a), 84 | -12: lambda *a: SystemError('Error running mpv command', *a) } 85 | 86 | @staticmethod 87 | def default_error_handler(ec, *args): 88 | return ValueError(_mpv_error_string(ec).decode('utf-8'), ec, *args) 89 | 90 | @classmethod 91 | def raise_for_ec(kls, ec, func, *args): 92 | ec = 0 if ec > 0 else ec 93 | ex = kls.EXCEPTION_DICT.get(ec , kls.default_error_handler) 94 | if ex: 95 | raise ex(ec, *args) 96 | 97 | 98 | class MpvFormat(c_int): 99 | NONE = 0 100 | STRING = 1 101 | OSD_STRING = 2 102 | FLAG = 3 103 | INT64 = 4 104 | DOUBLE = 5 105 | NODE = 6 106 | NODE_ARRAY = 7 107 | NODE_MAP = 8 108 | BYTE_ARRAY = 9 109 | 110 | def __eq__(self, other): 111 | return self is other or self.value == other or self.value == int(other) 112 | 113 | def __repr__(self): 114 | return ['NONE', 'STRING', 'OSD_STRING', 'FLAG', 'INT64', 'DOUBLE', 'NODE', 'NODE_ARRAY', 'NODE_MAP', 115 | 'BYTE_ARRAY'][self.value] 116 | 117 | 118 | 119 | class MpvEventID(c_int): 120 | NONE = 0 121 | SHUTDOWN = 1 122 | LOG_MESSAGE = 2 123 | GET_PROPERTY_REPLY = 3 124 | SET_PROPERTY_REPLY = 4 125 | COMMAND_REPLY = 5 126 | START_FILE = 6 127 | END_FILE = 7 128 | FILE_LOADED = 8 129 | TRACKS_CHANGED = 9 130 | TRACK_SWITCHED = 10 131 | IDLE = 11 132 | PAUSE = 12 133 | UNPAUSE = 13 134 | TICK = 14 135 | SCRIPT_INPUT_DISPATCH = 15 136 | CLIENT_MESSAGE = 16 137 | VIDEO_RECONFIG = 17 138 | AUDIO_RECONFIG = 18 139 | METADATA_UPDATE = 19 140 | SEEK = 20 141 | PLAYBACK_RESTART = 21 142 | PROPERTY_CHANGE = 22 143 | CHAPTER_CHANGE = 23 144 | 145 | ANY = ( SHUTDOWN, LOG_MESSAGE, GET_PROPERTY_REPLY, SET_PROPERTY_REPLY, COMMAND_REPLY, START_FILE, END_FILE, 146 | FILE_LOADED, TRACKS_CHANGED, TRACK_SWITCHED, IDLE, PAUSE, UNPAUSE, TICK, SCRIPT_INPUT_DISPATCH, 147 | CLIENT_MESSAGE, VIDEO_RECONFIG, AUDIO_RECONFIG, METADATA_UPDATE, SEEK, PLAYBACK_RESTART, PROPERTY_CHANGE, 148 | CHAPTER_CHANGE ) 149 | 150 | def __repr__(self): 151 | return ['NONE', 'SHUTDOWN', 'LOG_MESSAGE', 'GET_PROPERTY_REPLY', 'SET_PROPERTY_REPLY', 'COMMAND_REPLY', 152 | 'START_FILE', 'END_FILE', 'FILE_LOADED', 'TRACKS_CHANGED', 'TRACK_SWITCHED', 'IDLE', 'PAUSE', 'UNPAUSE', 153 | 'TICK', 'SCRIPT_INPUT_DISPATCH', 'CLIENT_MESSAGE', 'VIDEO_RECONFIG', 'AUDIO_RECONFIG', 154 | 'METADATA_UPDATE', 'SEEK', 'PLAYBACK_RESTART', 'PROPERTY_CHANGE', 'CHAPTER_CHANGE'][self.value] 155 | 156 | 157 | class MpvNodeList(Structure): 158 | def array_value(self, decode_str=False): 159 | return [ self.values[i].node_value(decode_str) for i in range(self.num) ] 160 | 161 | def dict_value(self, decode_str=False): 162 | return { self.keys[i].decode('utf-8'): self.values[i].node_value(decode_str) for i in range(self.num) } 163 | 164 | class MpvNode(Structure): 165 | _fields_ = [('val', c_longlong), 166 | ('format', MpvFormat)] 167 | 168 | def node_value(self, decode_str=False): 169 | return MpvNode.node_cast_value(byref(c_void_p(self.val)), self.format.value, decode_str) 170 | 171 | @staticmethod 172 | def node_cast_value(v, fmt, decode_str=False): 173 | dwrap = lambda s: s.decode('utf-8') if decode_str else s 174 | return { 175 | MpvFormat.NONE: lambda v: None, 176 | MpvFormat.STRING: lambda v: dwrap(cast(v, POINTER(c_char_p)).contents.value), 177 | MpvFormat.OSD_STRING: lambda v: cast(v, POINTER(c_char_p)).contents.value.decode('utf-8'), 178 | MpvFormat.FLAG: lambda v: bool(cast(v, POINTER(c_int)).contents.value), 179 | MpvFormat.INT64: lambda v: cast(v, POINTER(c_longlong)).contents.value, 180 | MpvFormat.DOUBLE: lambda v: cast(v, POINTER(c_double)).contents.value, 181 | MpvFormat.NODE: lambda v: cast(v, POINTER(MpvNode)).contents.node_value(decode_str), 182 | MpvFormat.NODE_ARRAY: lambda v: cast(v, POINTER(POINTER(MpvNodeList))).contents.contents.array_value(decode_str), 183 | MpvFormat.NODE_MAP: lambda v: cast(v, POINTER(POINTER(MpvNodeList))).contents.contents.dict_value(decode_str), 184 | MpvFormat.BYTE_ARRAY: lambda v: cast(v, POINTER(c_char_p)).contents.value, 185 | }[fmt](v) 186 | 187 | MpvNodeList._fields_ = [('num', c_int), 188 | ('values', POINTER(MpvNode)), 189 | ('keys', POINTER(c_char_p))] 190 | 191 | class MpvSubApi(c_int): 192 | MPV_SUB_API_OPENGL_CB = 1 193 | 194 | class MpvEvent(Structure): 195 | _fields_ = [('event_id', MpvEventID), 196 | ('error', c_int), 197 | ('reply_userdata', c_ulonglong), 198 | ('data', c_void_p)] 199 | 200 | def as_dict(self): 201 | dtype = {MpvEventID.END_FILE: MpvEventEndFile, 202 | MpvEventID.PROPERTY_CHANGE: MpvEventProperty, 203 | MpvEventID.GET_PROPERTY_REPLY: MpvEventProperty, 204 | MpvEventID.LOG_MESSAGE: MpvEventLogMessage, 205 | MpvEventID.SCRIPT_INPUT_DISPATCH: MpvEventScriptInputDispatch, 206 | MpvEventID.CLIENT_MESSAGE: MpvEventClientMessage 207 | }.get(self.event_id.value, None) 208 | return {'event_id': self.event_id.value, 209 | 'error': self.error, 210 | 'reply_userdata': self.reply_userdata, 211 | 'event': cast(self.data, POINTER(dtype)).contents.as_dict() if dtype else None} 212 | 213 | class MpvEventProperty(Structure): 214 | _fields_ = [('name', c_char_p), 215 | ('format', MpvFormat), 216 | ('data', c_void_p)] 217 | def as_dict(self): 218 | if self.format.value == MpvFormat.STRING: 219 | proptype, _access = ALL_PROPERTIES.get(self.name, (str, None)) 220 | return {'name': self.name.decode('utf-8'), 221 | 'format': self.format, 222 | 'data': self.data, 223 | 'value': proptype(cast(self.data, POINTER(c_char_p)).contents.value.decode('utf-8'))} 224 | else: 225 | return {'name': self.name.decode('utf-8'), 226 | 'format': self.format, 227 | 'data': self.data} 228 | 229 | class MpvEventLogMessage(Structure): 230 | _fields_ = [('prefix', c_char_p), 231 | ('level', c_char_p), 232 | ('text', c_char_p)] 233 | 234 | def as_dict(self): 235 | return { 'prefix': self.prefix.decode('utf-8'), 236 | 'level': self.level.decode('utf-8'), 237 | 'text': self.text.decode('utf-8').rstrip() } 238 | 239 | class MpvEventEndFile(c_int): 240 | EOF_OR_INIT_FAILURE = 0 241 | RESTARTED = 1 242 | ABORTED = 2 243 | QUIT = 3 244 | 245 | def as_dict(self): 246 | return {'reason': self.value} 247 | 248 | class MpvEventScriptInputDispatch(Structure): 249 | _fields_ = [('arg0', c_int), 250 | ('type', c_char_p)] 251 | 252 | def as_dict(self): 253 | pass # TODO 254 | 255 | class MpvEventClientMessage(Structure): 256 | _fields_ = [('num_args', c_int), 257 | ('args', POINTER(c_char_p))] 258 | 259 | def as_dict(self): 260 | return { 'args': [ self.args[i].decode('utf-8') for i in range(self.num_args) ] } 261 | 262 | WakeupCallback = CFUNCTYPE(None, c_void_p) 263 | 264 | OpenGlCbUpdateFn = CFUNCTYPE(None, c_void_p) 265 | OpenGlCbGetProcAddrFn = CFUNCTYPE(None, c_void_p, c_char_p) 266 | 267 | def _handle_func(name, args, restype, errcheck, ctx=MpvHandle): 268 | func = getattr(backend, name) 269 | func.argtypes = [ctx] + args if ctx else args 270 | if restype is not None: 271 | func.restype = restype 272 | if errcheck is not None: 273 | func.errcheck = errcheck 274 | globals()['_'+name] = func 275 | 276 | def bytes_free_errcheck(res, func, *args): 277 | notnull_errcheck(res, func, *args) 278 | rv = cast(res, c_void_p).value 279 | _mpv_free(res) 280 | return rv 281 | 282 | def notnull_errcheck(res, func, *args): 283 | if res is None: 284 | raise RuntimeError('Underspecified error in MPV when calling {} with args {!r}: NULL pointer returned.'\ 285 | 'Please consult your local debugger.'.format(func.__name__, args)) 286 | return res 287 | 288 | ec_errcheck = ErrorCode.raise_for_ec 289 | 290 | def _handle_gl_func(name, args=[], restype=None): 291 | _handle_func(name, args, restype, errcheck=None, ctx=MpvOpenGLCbContext) 292 | 293 | backend.mpv_client_api_version.restype = c_ulong 294 | def _mpv_client_api_version(): 295 | ver = backend.mpv_client_api_version() 296 | return ver>>16, ver&0xFFFF 297 | 298 | backend.mpv_free.argtypes = [c_void_p] 299 | _mpv_free = backend.mpv_free 300 | 301 | backend.mpv_free_node_contents.argtypes = [c_void_p] 302 | _mpv_free_node_contents = backend.mpv_free_node_contents 303 | 304 | backend.mpv_create.restype = MpvHandle 305 | _mpv_create = backend.mpv_create 306 | 307 | _handle_func('mpv_create_client', [c_char_p], MpvHandle, notnull_errcheck) 308 | _handle_func('mpv_client_name', [], c_char_p, errcheck=None) 309 | _handle_func('mpv_initialize', [], c_int, ec_errcheck) 310 | _handle_func('mpv_detach_destroy', [], None, errcheck=None) 311 | _handle_func('mpv_terminate_destroy', [], None, errcheck=None) 312 | _handle_func('mpv_load_config_file', [c_char_p], c_int, ec_errcheck) 313 | _handle_func('mpv_suspend', [], None, errcheck=None) 314 | _handle_func('mpv_resume', [], None, errcheck=None) 315 | _handle_func('mpv_get_time_us', [], c_ulonglong, errcheck=None) 316 | 317 | _handle_func('mpv_set_option', [c_char_p, MpvFormat, c_void_p], c_int, ec_errcheck) 318 | _handle_func('mpv_set_option_string', [c_char_p, c_char_p], c_int, ec_errcheck) 319 | 320 | _handle_func('mpv_command', [POINTER(c_char_p)], c_int, ec_errcheck) 321 | _handle_func('mpv_command_string', [c_char_p, c_char_p], c_int, ec_errcheck) 322 | _handle_func('mpv_command_async', [c_ulonglong, POINTER(c_char_p)], c_int, ec_errcheck) 323 | 324 | _handle_func('mpv_set_property', [c_char_p, MpvFormat, c_void_p], c_int, ec_errcheck) 325 | _handle_func('mpv_set_property_string', [c_char_p, c_char_p], c_int, ec_errcheck) 326 | _handle_func('mpv_set_property_async', [c_ulonglong, c_char_p, MpvFormat,c_void_p],c_int, ec_errcheck) 327 | _handle_func('mpv_get_property', [c_char_p, MpvFormat, c_void_p], c_int, ec_errcheck) 328 | _handle_func('mpv_get_property_string', [c_char_p], c_void_p, bytes_free_errcheck) 329 | _handle_func('mpv_get_property_osd_string', [c_char_p], c_void_p, bytes_free_errcheck) 330 | _handle_func('mpv_get_property_async', [c_ulonglong, c_char_p, MpvFormat], c_int, ec_errcheck) 331 | _handle_func('mpv_observe_property', [c_ulonglong, c_char_p, MpvFormat], c_int, ec_errcheck) 332 | _handle_func('mpv_unobserve_property', [c_ulonglong], c_int, ec_errcheck) 333 | 334 | _handle_func('mpv_event_name', [c_int], c_char_p, errcheck=None, ctx=None) 335 | _handle_func('mpv_error_string', [c_int], c_char_p, errcheck=None, ctx=None) 336 | 337 | _handle_func('mpv_request_event', [MpvEventID, c_int], c_int, ec_errcheck) 338 | _handle_func('mpv_request_log_messages', [c_char_p], c_int, ec_errcheck) 339 | _handle_func('mpv_wait_event', [c_double], POINTER(MpvEvent), errcheck=None) 340 | _handle_func('mpv_wakeup', [], None, errcheck=None) 341 | _handle_func('mpv_set_wakeup_callback', [WakeupCallback, c_void_p], None, errcheck=None) 342 | _handle_func('mpv_get_wakeup_pipe', [], c_int, errcheck=None) 343 | 344 | _handle_func('mpv_get_sub_api', [MpvSubApi], c_void_p, notnull_errcheck) 345 | 346 | _handle_gl_func('mpv_opengl_cb_set_update_callback', [OpenGlCbUpdateFn, c_void_p]) 347 | _handle_gl_func('mpv_opengl_cb_init_gl', [c_char_p, OpenGlCbGetProcAddrFn, c_void_p], c_int) 348 | _handle_gl_func('mpv_opengl_cb_draw', [c_int, c_int, c_int], c_int) 349 | _handle_gl_func('mpv_opengl_cb_render', [c_int, c_int], c_int) 350 | _handle_gl_func('mpv_opengl_cb_report_flip', [c_ulonglong], c_int) 351 | _handle_gl_func('mpv_opengl_cb_uninit_gl', [], c_int) 352 | 353 | 354 | def _ensure_encoding(possibly_bytes): 355 | return possibly_bytes.decode('utf-8') if type(possibly_bytes) is bytes else possibly_bytes 356 | 357 | 358 | def _event_generator(handle): 359 | while True: 360 | event = _mpv_wait_event(handle, -1).contents 361 | if event.event_id.value == MpvEventID.NONE: 362 | raise StopIteration() 363 | yield event 364 | 365 | def load_lua(): 366 | """ Use this function if you intend to use mpv's built-in lua interpreter. This is e.g. needed for playback of 367 | youtube urls. """ 368 | CDLL('liblua.so', mode=RTLD_GLOBAL) 369 | 370 | 371 | def _event_loop(event_handle, playback_cond, event_callbacks, message_handlers, property_handlers, log_handler): 372 | for event in _event_generator(event_handle): 373 | try: 374 | devent = event.as_dict() # copy data from ctypes 375 | eid = devent['event_id'] 376 | for callback in event_callbacks: 377 | callback(devent) 378 | if eid in (MpvEventID.SHUTDOWN, MpvEventID.END_FILE): 379 | with playback_cond: 380 | playback_cond.notify_all() 381 | if eid == MpvEventID.PROPERTY_CHANGE: 382 | pc = devent['event'] 383 | name = pc['name'] 384 | 385 | if 'value' in pc: 386 | proptype, _access = ALL_PROPERTIES[name] 387 | if proptype is bytes: 388 | args = (pc['value'],) 389 | else: 390 | args = (proptype(_ensure_encoding(pc['value'])),) 391 | elif pc['format'] == MpvFormat.NONE: 392 | args = (None,) 393 | else: 394 | args = (pc['data'], pc['format']) 395 | 396 | for handler in property_handlers[name]: 397 | handler(*args) 398 | if eid == MpvEventID.LOG_MESSAGE and log_handler is not None: 399 | ev = devent['event'] 400 | log_handler(ev['level'], ev['prefix'], ev['text']) 401 | if eid == MpvEventID.CLIENT_MESSAGE: 402 | # {'event': {'args': ['key-binding', 'foo', 'u-', 'g']}, 'reply_userdata': 0, 'error': 0, 'event_id': 16} 403 | target, *args = devent['event']['args'] 404 | if target in message_handlers: 405 | message_handlers[target](*args) 406 | if eid == MpvEventID.SHUTDOWN: 407 | _mpv_detach_destroy(event_handle) 408 | return 409 | except Exception as e: 410 | traceback.print_exc() 411 | 412 | class MPV(object): 413 | """ See man mpv(1) for the details of the implemented commands. """ 414 | def __init__(self, *extra_mpv_flags, log_handler=None, start_event_thread=True, **extra_mpv_opts): 415 | """ Create an MPV instance. 416 | 417 | Extra arguments and extra keyword arguments will be passed to mpv as options. """ 418 | 419 | self._event_thread = None 420 | self.handle = _mpv_create() 421 | 422 | _mpv_set_option_string(self.handle, b'audio-display', b'no') 423 | istr = lambda o: ('yes' if o else 'no') if type(o) is bool else str(o) 424 | try: 425 | for flag in extra_mpv_flags: 426 | _mpv_set_option_string(self.handle, flag.encode('utf-8'), b'') 427 | for k,v in extra_mpv_opts.items(): 428 | _mpv_set_option_string(self.handle, k.replace('_', '-').encode('utf-8'), istr(v).encode('utf-8')) 429 | finally: 430 | _mpv_initialize(self.handle) 431 | 432 | self._event_callbacks = [] 433 | self._property_handlers = collections.defaultdict(lambda: []) 434 | self._message_handlers = {} 435 | self._key_binding_handlers = {} 436 | self._playback_cond = threading.Condition() 437 | self._event_handle = _mpv_create_client(self.handle, b'py_event_handler') 438 | self._loop = partial(_event_loop, self._event_handle, self._playback_cond, self._event_callbacks, 439 | self._message_handlers, self._property_handlers, log_handler) 440 | if start_event_thread: 441 | self._event_thread = threading.Thread(target=self._loop, name='MPVEventHandlerThread') 442 | self._event_thread.setDaemon(True) 443 | self._event_thread.start() 444 | else: 445 | self._event_thread = None 446 | 447 | if log_handler is not None: 448 | self.set_loglevel('terminal-default') 449 | 450 | def wait_for_playback(self): 451 | """ Waits until playback of the current title is paused or done """ 452 | with self._playback_cond: 453 | self._playback_cond.wait() 454 | 455 | def wait_for_property(self, name, cond=lambda val: val, level_sensitive=True): 456 | sema = threading.Semaphore(value=0) 457 | def observer(val): 458 | if cond(val): 459 | sema.release() 460 | self.observe_property(name, observer) 461 | if not level_sensitive or not cond(getattr(self, name.replace('-', '_'))): 462 | sema.acquire() 463 | self.unobserve_property(name, observer) 464 | 465 | def __del__(self): 466 | if self.handle: 467 | self.terminate() 468 | 469 | def terminate(self): 470 | self.handle, handle = None, self.handle 471 | if threading.current_thread() is self._event_thread: 472 | # Handle special case to allow event handle to be detached. 473 | # This is necessary since otherwise the event thread would deadlock itself. 474 | grim_reaper = threading.Thread(target=lambda: _mpv_terminate_destroy(handle)) 475 | grim_reaper.start() 476 | else: 477 | _mpv_terminate_destroy(handle) 478 | if self._event_thread: 479 | self._event_thread.join() 480 | 481 | def set_loglevel(self, level): 482 | _mpv_request_log_messages(self._event_handle, level.encode('utf-8')) 483 | 484 | def command(self, name, *args): 485 | """ Execute a raw command """ 486 | args = [name.encode('utf-8')] + [ (arg if type(arg) is bytes else str(arg).encode('utf-8')) 487 | for arg in args if arg is not None ] + [None] 488 | _mpv_command(self.handle, (c_char_p*len(args))(*args)) 489 | 490 | def seek(self, amount, reference="relative", precision="default-precise"): 491 | self.command('seek', amount, reference, precision) 492 | 493 | def revert_seek(self): 494 | self.command('revert_seek'); 495 | 496 | def frame_step(self): 497 | self.command('frame_step') 498 | 499 | def frame_back_step(self): 500 | self.command('frame_back_step') 501 | 502 | def _add_property(self, name, value=None): 503 | self.command('add_property', name, value) 504 | 505 | def _cycle_property(self, name, direction='up'): 506 | self.command('cycle_property', name, direction) 507 | 508 | def _multiply_property(self, name, factor): 509 | self.command('multiply_property', name, factor) 510 | 511 | def screenshot(self, includes='subtitles', mode='single'): 512 | self.command('screenshot', includes, mode) 513 | 514 | def screenshot_to_file(self, filename, includes='subtitles'): 515 | self.command('screenshot_to_file', filename.encode(fs_enc), includes) 516 | 517 | def playlist_next(self, mode='weak'): 518 | self.command('playlist_next', mode) 519 | 520 | def playlist_prev(self, mode='weak'): 521 | self.command('playlist_prev', mode) 522 | 523 | @staticmethod 524 | def _encode_options(options): 525 | return ','.join('{}={}'.format(str(key), str(val)) for key, val in options.items()) 526 | 527 | def loadfile(self, filename, mode='replace', **options): 528 | self.command('loadfile', filename.encode(fs_enc), mode, MPV._encode_options(options)) 529 | 530 | def loadlist(self, playlist, mode='replace'): 531 | self.command('loadlist', playlist.encode(fs_enc), mode) 532 | 533 | def playlist_clear(self): 534 | self.command('playlist_clear') 535 | 536 | def playlist_remove(self, index='current'): 537 | self.command('playlist_remove', index) 538 | 539 | def playlist_move(self, index1, index2): 540 | self.command('playlist_move', index1, index2) 541 | 542 | def run(self, command, *args): 543 | self.command('run', command, *args) 544 | 545 | def quit(self, code=None): 546 | self.command('quit', code) 547 | 548 | def quit_watch_later(self, code=None): 549 | self.command('quit_watch_later', code) 550 | 551 | def sub_add(self, filename): 552 | self.command('sub_add', filename.encode(fs_enc)) 553 | 554 | def sub_remove(self, sub_id=None): 555 | self.command('sub_remove', sub_id) 556 | 557 | def sub_reload(self, sub_id=None): 558 | self.command('sub_reload', sub_id) 559 | 560 | def sub_step(self, skip): 561 | self.command('sub_step', skip) 562 | 563 | def sub_seek(self, skip): 564 | self.command('sub_seek', skip) 565 | 566 | def toggle_osd(self): 567 | self.command('osd') 568 | 569 | def show_text(self, string, duration='-', level=None): 570 | self.command('show_text', string, duration, level) 571 | 572 | def show_progress(self): 573 | self.command('show_progress') 574 | 575 | def discnav(self, command): 576 | self.command('discnav', command) 577 | 578 | def write_watch_later_config(self): 579 | self.command('write_watch_later_config') 580 | 581 | def overlay_add(self, overlay_id, x, y, file_or_fd, offset, fmt, w, h, stride): 582 | self.command('overlay_add', overlay_id, x, y, file_or_fd, offset, fmt, w, h, stride) 583 | 584 | def overlay_remove(self, overlay_id): 585 | self.command('overlay_remove', overlay_id) 586 | 587 | def script_message(self, *args): 588 | self.command('script_message', *args) 589 | 590 | def script_message_to(self, target, *args): 591 | self.command('script_message_to', target, *args) 592 | 593 | def observe_property(self, name, handler): 594 | self._property_handlers[name].append(handler) 595 | _mpv_observe_property(self._event_handle, hash(name)&0xffffffffffffffff, name.encode('utf-8'), MpvFormat.STRING) 596 | 597 | def unobserve_property(self, name, handler): 598 | handlers = self._property_handlers[name] 599 | handlers.remove(handler) 600 | if not handlers: 601 | _mpv_unobserve_property(self._event_handle, hash(name)&0xffffffffffffffff) 602 | 603 | def register_message_handler(self, target, handler): 604 | self._message_handlers[target] = handler 605 | 606 | def unregister_message_handler(self, target): 607 | del self._message_handlers[target] 608 | 609 | def register_event_callback(self, callback): 610 | self._event_callbacks.append(callback) 611 | 612 | def unregister_event_callback(self, callback): 613 | self._event_callbacks.remove(callback) 614 | 615 | @staticmethod 616 | def _binding_name(callback_or_cmd): 617 | return 'py_kb_{:016x}'.format(hash(callback_or_cmd)&0xffffffffffffffff) 618 | 619 | def register_key_binding(self, keydef, callback_or_cmd, mode='force'): 620 | """ BIG FAT WARNING: mpv's key binding mechanism is pretty powerful. This means, you essentially get arbitrary 621 | code exectution through key bindings. This interface makes some limited effort to sanitize the keydef given in 622 | the first parameter, but YOU SHOULD NOT RELY ON THIS IN FOR SECURITY. If your input comes from config files, 623 | this is completely fine--but, if you are about to pass untrusted input into this parameter, better double-check 624 | whether this is secure in your case. """ 625 | if not re.match(r'(Shift+)?(Ctrl+)?(Alt+)?(Meta+)?(.|\w+)', keydef): 626 | raise ValueError('Invalid keydef. Expected format: [Shift+][Ctrl+][Alt+][Meta+]\n' 627 | ' is either the literal character the key produces (ASCII or Unicode character), or a ' 628 | 'symbolic name (as printed by --input-keylist') 629 | binding_name = MPV._binding_name(keydef) 630 | if callable(callback_or_cmd): 631 | self._key_binding_handlers[binding_name] = callback_or_cmd 632 | self.register_message_handler('key-binding', self._handle_key_binding_message) 633 | self.command('define-section', 634 | binding_name, '{} script-binding py_event_handler/{}'.format(keydef, binding_name), mode) 635 | elif isinstance(callback_or_cmd, str): 636 | self.command('define-section', binding_name, '{} {}'.format(keydef, callback_or_cmd), mode) 637 | else: 638 | raise TypeError('register_key_binding expects either an str with an mpv command or a python callable.') 639 | self.command('enable-section', binding_name) 640 | 641 | def _handle_key_binding_message(self, binding_name, key_state, key_name): 642 | self._key_binding_handlers[binding_name](key_state, key_name) 643 | 644 | def unregister_key_binding(self, keydef): 645 | binding_name = MPV._binding_name(keydef) 646 | self.command('disable-section', binding_name) 647 | self.command('define-section', binding_name, '') 648 | if callable(callback): 649 | del self._key_binding_handlers[binding_name] 650 | if not self._key_binding_handlers: 651 | self.unregister_message_handler('key-binding') 652 | 653 | # Convenience functions 654 | def play(self, filename): 655 | self.loadfile(filename) 656 | 657 | # Property accessors 658 | def _get_property(self, name, proptype=str, decode_str=False): 659 | fmt = {int: MpvFormat.INT64, 660 | float: MpvFormat.DOUBLE, 661 | bool: MpvFormat.FLAG, 662 | str: MpvFormat.STRING, 663 | bytes: MpvFormat.STRING, 664 | commalist: MpvFormat.STRING, 665 | MpvFormat.NODE: MpvFormat.NODE}[proptype] 666 | 667 | out = cast(create_string_buffer(sizeof(c_void_p)), c_void_p) 668 | outptr = byref(out) 669 | try: 670 | cval = _mpv_get_property(self.handle, name.encode('utf-8'), fmt, outptr) 671 | rv = MpvNode.node_cast_value(outptr, fmt, decode_str or proptype in (str, commalist)) 672 | 673 | if proptype is commalist: 674 | rv = proptype(rv) 675 | 676 | if proptype is str: 677 | _mpv_free(out) 678 | elif proptype is MpvFormat.NODE: 679 | _mpv_free_node_contents(outptr) 680 | 681 | return rv 682 | except PropertyUnavailableError as ex: 683 | return None 684 | 685 | def _set_property(self, name, value, proptype=str): 686 | ename = name.encode('utf-8') 687 | if type(value) is bytes: 688 | _mpv_set_property_string(self.handle, ename, value) 689 | elif type(value) is bool: 690 | _mpv_set_property_string(self.handle, ename, b'yes' if value else b'no') 691 | elif proptype in (str, int, float): 692 | _mpv_set_property_string(self.handle, ename, str(proptype(value)).encode('utf-8')) 693 | else: 694 | raise TypeError('Cannot set {} property {} to value of type {}'.format(proptype, name, type(value))) 695 | 696 | # Dict-like option access 697 | def __getitem__(self, name, file_local=False): 698 | """ Get an option value """ 699 | prefix = 'file-local-options/' if file_local else 'options/' 700 | return self._get_property(prefix+name) 701 | 702 | def __setitem__(self, name, value, file_local=False): 703 | """ Get an option value """ 704 | prefix = 'file-local-options/' if file_local else 'options/' 705 | return self._set_property(prefix+name, value) 706 | 707 | def __iter__(self): 708 | return iter(self.options) 709 | 710 | def option_info(self, name): 711 | return self._get_property('option-info/'+name) 712 | 713 | def commalist(propval=''): 714 | return str(propval).split(',') 715 | 716 | node = MpvFormat.NODE 717 | 718 | ALL_PROPERTIES = { 719 | 'osd-level': (int, 'rw'), 720 | 'osd-scale': (float, 'rw'), 721 | 'loop': (str, 'rw'), 722 | 'loop-file': (str, 'rw'), 723 | 'speed': (float, 'rw'), 724 | 'filename': (bytes, 'r'), 725 | 'file-size': (int, 'r'), 726 | 'path': (bytes, 'r'), 727 | 'media-title': (bytes, 'r'), 728 | 'stream-pos': (int, 'rw'), 729 | 'stream-end': (int, 'r'), 730 | 'length': (float, 'r'), # deprecated for ages now 731 | 'duration': (float, 'r'), 732 | 'avsync': (float, 'r'), 733 | 'total-avsync-change': (float, 'r'), 734 | 'drop-frame-count': (int, 'r'), 735 | 'percent-pos': (float, 'rw'), 736 | # 'ratio-pos': (float, 'rw'), 737 | 'time-pos': (float, 'rw'), 738 | 'time-start': (float, 'r'), 739 | 'time-remaining': (float, 'r'), 740 | 'playtime-remaining': (float, 'r'), 741 | 'chapter': (int, 'rw'), 742 | 'edition': (int, 'rw'), 743 | 'disc-titles': (int, 'r'), 744 | 'disc-title': (str, 'rw'), 745 | # 'disc-menu-active': (bool, 'r'), 746 | 'chapters': (int, 'r'), 747 | 'editions': (int, 'r'), 748 | 'angle': (int, 'rw'), 749 | 'pause': (bool, 'rw'), 750 | 'core-idle': (bool, 'r'), 751 | 'cache': (int, 'r'), 752 | 'cache-size': (int, 'rw'), 753 | 'cache-free': (int, 'r'), 754 | 'cache-used': (int, 'r'), 755 | 'cache-speed': (int, 'r'), 756 | 'cache-idle': (bool, 'r'), 757 | 'cache-buffering-state': (int, 'r'), 758 | 'paused-for-cache': (bool, 'r'), 759 | # 'pause-for-cache': (bool, 'r'), 760 | 'eof-reached': (bool, 'r'), 761 | # 'pts-association-mode': (str, 'rw'), 762 | 'hr-seek': (str, 'rw'), 763 | 'volume': (float, 'rw'), 764 | 'volume-max': (int, 'rw'), 765 | 'ao-volume': (float, 'rw'), 766 | 'mute': (bool, 'rw'), 767 | 'ao-mute': (bool, 'rw'), 768 | 'audio-speed-correction': (float, 'r'), 769 | 'audio-delay': (float, 'rw'), 770 | 'audio-format': (str, 'r'), 771 | 'audio-codec': (str, 'r'), 772 | 'audio-codec-name': (str, 'r'), 773 | 'audio-bitrate': (float, 'r'), 774 | 'packet-audio-bitrate': (float, 'r'), 775 | 'audio-samplerate': (int, 'r'), 776 | 'audio-channels': (str, 'r'), 777 | 'aid': (str, 'rw'), 778 | 'audio': (str, 'rw'), # alias for aid 779 | 'balance': (int, 'rw'), 780 | 'fullscreen': (bool, 'rw'), 781 | 'deinterlace': (str, 'rw'), 782 | 'colormatrix': (str, 'rw'), 783 | 'colormatrix-input-range': (str, 'rw'), 784 | # 'colormatrix-output-range': (str, 'rw'), 785 | 'colormatrix-primaries': (str, 'rw'), 786 | 'ontop': (bool, 'rw'), 787 | 'border': (bool, 'rw'), 788 | 'framedrop': (str, 'rw'), 789 | 'gamma': (float, 'rw'), 790 | 'brightness': (int, 'rw'), 791 | 'contrast': (int, 'rw'), 792 | 'saturation': (int, 'rw'), 793 | 'hue': (int, 'rw'), 794 | 'hwdec': (str, 'rw'), 795 | 'panscan': (float, 'rw'), 796 | 'video-format': (str, 'r'), 797 | 'video-codec': (str, 'r'), 798 | 'video-bitrate': (float, 'r'), 799 | 'packet-video-bitrate': (float, 'r'), 800 | 'width': (int, 'r'), 801 | 'height': (int, 'r'), 802 | 'dwidth': (int, 'r'), 803 | 'dheight': (int, 'r'), 804 | 'fps': (float, 'r'), 805 | 'estimated-vf-fps': (float, 'r'), 806 | 'window-scale': (float, 'rw'), 807 | 'video-aspect': (str, 'rw'), 808 | 'osd-width': (int, 'r'), 809 | 'osd-height': (int, 'r'), 810 | 'osd-par': (float, 'r'), 811 | 'vid': (str, 'rw'), 812 | 'video': (str, 'rw'), # alias for vid 813 | 'video-align-x': (float, 'rw'), 814 | 'video-align-y': (float, 'rw'), 815 | 'video-pan-x': (float, 'rw'), 816 | 'video-pan-y': (float, 'rw'), 817 | 'video-zoom': (float, 'rw'), 818 | 'video-unscaled': (bool, 'w'), 819 | 'video-speed-correction': (float, 'r'), 820 | 'program': (int, 'w'), 821 | 'sid': (str, 'rw'), 822 | 'sub': (str, 'rw'), # alias for sid 823 | 'secondary-sid': (str, 'rw'), 824 | 'sub-delay': (float, 'rw'), 825 | 'sub-pos': (int, 'rw'), 826 | 'sub-visibility': (bool, 'rw'), 827 | 'sub-forced-only': (bool, 'rw'), 828 | 'sub-scale': (float, 'rw'), 829 | 'sub-bitrate': (float, 'r'), 830 | 'packet-sub-bitrate': (float, 'r'), 831 | # 'ass-use-margins': (bool, 'rw'), 832 | 'ass-vsfilter-aspect-compat': (bool, 'rw'), 833 | 'ass-style-override': (bool, 'rw'), 834 | 'stream-capture': (str, 'rw'), 835 | 'tv-brightness': (int, 'rw'), 836 | 'tv-contrast': (int, 'rw'), 837 | 'tv-saturation': (int, 'rw'), 838 | 'tv-hue': (int, 'rw'), 839 | 'playlist-pos': (int, 'rw'), 840 | 'playlist-pos-1': (int, 'rw'), # ugh. 841 | 'playlist-count': (int, 'r'), 842 | # 'quvi-format': (str, 'rw'), 843 | 'seekable': (bool, 'r'), 844 | 'seeking': (bool, 'r'), 845 | 'partially-seekable': (bool, 'r'), 846 | 'playback-abort': (bool, 'r'), 847 | 'cursor-autohide': (str, 'rw'), 848 | 'audio-device': (str, 'rw'), 849 | 'current-vo': (str, 'r'), 850 | 'current-ao': (str, 'r'), 851 | 'audio-out-detected-device': (str, 'r'), 852 | 'protocol-list': (str, 'r'), 853 | 'mpv-version': (str, 'r'), 854 | 'mpv-configuration': (str, 'r'), 855 | 'ffmpeg-version': (str, 'r'), 856 | 'display-sync-active': (bool, 'r'), 857 | 'stream-open-filename': (bytes, 'rw'), # Undocumented 858 | 'file-format': (commalist,'r'), # Be careful with this one. 859 | 'mistimed-frame-count': (int, 'r'), 860 | 'vsync-ratio': (float, 'r'), 861 | 'vo-drop-frame-count': (int, 'r'), 862 | 'vo-delayed-frame-count': (int, 'r'), 863 | 'playback-time': (float, 'rw'), 864 | 'demuxer-cache-duration': (float, 'r'), 865 | 'demuxer-cache-time': (float, 'r'), 866 | 'demuxer-cache-idle': (bool, 'r'), 867 | 'idle': (bool, 'r'), 868 | 'disc-title-list': (commalist,'r'), 869 | 'field-dominance': (str, 'rw'), 870 | 'taskbar-progress': (bool, 'rw'), 871 | 'on-all-workspaces': (bool, 'rw'), 872 | 'video-output-levels': (str, 'r'), 873 | 'vo-configured': (bool, 'r'), 874 | 'hwdec-current': (str, 'r'), 875 | 'hwdec-interop': (str, 'r'), 876 | 'estimated-frame-count': (int, 'r'), 877 | 'estimated-frame-number': (int, 'r'), 878 | 'sub-use-margins': (bool, 'rw'), 879 | 'ass-force-margins': (bool, 'rw'), 880 | 'video-rotate': (str, 'rw'), 881 | 'video-stereo-mode': (str, 'rw'), 882 | 'ab-loop-a': (str, 'r'), # What a mess... 883 | 'ab-loop-b': (str, 'r'), 884 | 'dvb-channel': (str, 'w'), 885 | 'dvb-channel-name': (str, 'rw'), 886 | 'window-minimized': (bool, 'r'), 887 | 'display-names': (commalist, 'r'), 888 | 'display-fps': (float, 'r'), # access apparently misdocumented in the manpage 889 | 'estimated-display-fps': (float, 'r'), 890 | 'vsync-jitter': (float, 'r'), 891 | 'video-params': (node, 'r', True), 892 | 'video-out-params': (node, 'r', True), 893 | 'track-list': (node, 'r', False), 894 | 'playlist': (node, 'r', False), 895 | 'chapter-list': (node, 'r', False), 896 | 'vo-performance': (node, 'r', True), 897 | 'filtered-metadata': (node, 'r', False), 898 | 'metadata': (node, 'r', False), 899 | 'chapter-metadata': (node, 'r', False), 900 | 'vf-metadata': (node, 'r', False), 901 | 'af-metadata': (node, 'r', False), 902 | 'edition-list': (node, 'r', False), 903 | 'disc-titles': (node, 'r', False), 904 | 'audio-params': (node, 'r', True), 905 | 'audio-out-params': (node, 'r', True), 906 | 'audio-device-list': (node, 'r', True), 907 | 'video-frame-info': (node, 'r', True), 908 | 'decoder-list': (node, 'r', True), 909 | 'encoder-list': (node, 'r', True), 910 | 'vf': (node, 'r', True), 911 | 'af': (node, 'r', True), 912 | 'options': (node, 'r', True), 913 | 'file-local-options': (node, 'r', True), 914 | 'property-list': (commalist,'r')} 915 | 916 | def bindproperty(MPV, name, proptype, access, decode_str=False): 917 | getter = lambda self: self._get_property(name, proptype, decode_str) 918 | setter = lambda self, value: self._set_property(name, value, proptype) 919 | 920 | def barf(*args): 921 | raise NotImplementedError('Access denied') 922 | 923 | setattr(MPV, name.replace('-', '_'), property(getter if 'r' in access else barf, setter if 'w' in access else barf)) 924 | 925 | for name, (proptype, access, *args) in ALL_PROPERTIES.items(): 926 | bindproperty(MPV, name, proptype, access, *args) 927 | 928 | --------------------------------------------------------------------------------