├── 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 | 
3 | 
4 | 
5 |
6 | A tiny terminal player based on Python3.
7 |
8 | 
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 |
--------------------------------------------------------------------------------