├── .gitignore ├── LICENSE.txt ├── README.rst ├── doc ├── Makefile ├── lyvi.1 └── lyvi.1.rst ├── lyvi.py ├── lyvi ├── __init__.py ├── background.py ├── config_defaults.py ├── data │ └── pianobar │ │ └── eventcmd ├── metadata.py ├── players │ ├── __init__.py │ ├── cmus.py │ ├── moc.py │ ├── mpd.py │ ├── mpg123.py │ ├── mplayer.py │ ├── mpris.py │ ├── pianobar.py │ ├── shell-fm.py │ └── xmms2.py ├── tui.py └── utils.py ├── pip_requirements.txt └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by http://gitignore.io 2 | 3 | ### Python ### 4 | *.py[cod] 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Packages 10 | *.egg 11 | *.egg-info 12 | dist 13 | build 14 | eggs 15 | parts 16 | bin 17 | var 18 | sdist 19 | develop-eggs 20 | .installed.cfg 21 | lib 22 | lib64 23 | __pycache__ 24 | 25 | # Installer logs 26 | pip-log.txt 27 | 28 | # Unit test / coverage reports 29 | .coverage 30 | .tox 31 | nosetests.xml 32 | 33 | # Translations 34 | *.mo 35 | 36 | # Mr Developer 37 | .mr.developer.cfg 38 | .project 39 | .pydevproject 40 | 41 | # Rope 42 | .ropeproject 43 | 44 | 45 | ### vim ### 46 | .*.s[a-w][a-z] 47 | *.un~ 48 | Session.vim 49 | .netrwhist 50 | *~ 51 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE 2 | Version 2, December 2004 3 | 4 | Copyright (C) 2004 Sam Hocevar 5 | 6 | Everyone is permitted to copy and distribute verbatim or modified 7 | copies of this license document, and changing it is allowed as long 8 | as the name is changed. 9 | 10 | DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE 11 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 12 | 13 | 0. You just DO WHAT THE FUCK YOU WANT TO. 14 | 15 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Lyvi 2 | ==== 3 | 4 | .. image:: https://badges.gitter.im/Join%20Chat.svg 5 | :target: https://gitter.im/ok100/lyvi?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge 6 | 7 | For more information, see http://ok100.github.io/lyvi/ 8 | 9 | 10 | You can install the python dependencies by issuing: 11 | 12 | .. code-block:: python 13 | 14 | $ sudo pip install -r pip_requirements.txt --use-mirrors 15 | 16 | This will also be done for you when issuing the setup.py script. 17 | 18 | There are other dependencies that need to be installed separately though: 19 | 20 | * ``libglyr`` (https://github.com/sahib/glyr) 21 | 22 | For MPRIS support these dependencies are needed: 23 | 24 | * ``python-dbus`` 25 | * ``python-gobject`` 26 | On OS X homebrew: 27 | 28 | * ``brew install dbus`` 29 | * ``brew install pygobject3`` 30 | 31 | Chances are that all these are available by your package manager. 32 | -------------------------------------------------------------------------------- /doc/Makefile: -------------------------------------------------------------------------------- 1 | all: 2 | rst2man lyvi.1.rst lyvi.1 3 | -------------------------------------------------------------------------------- /doc/lyvi.1: -------------------------------------------------------------------------------- 1 | .\" Man page generated from reStructuredText. 2 | . 3 | .TH LYVI 1 "" "2.0-git" "" 4 | .SH NAME 5 | Lyvi \- command-line lyrics (and more!) viewer 6 | . 7 | .nr rst2man-indent-level 0 8 | . 9 | .de1 rstReportMargin 10 | \\$1 \\n[an-margin] 11 | level \\n[rst2man-indent-level] 12 | level margin: \\n[rst2man-indent\\n[rst2man-indent-level]] 13 | - 14 | \\n[rst2man-indent0] 15 | \\n[rst2man-indent1] 16 | \\n[rst2man-indent2] 17 | .. 18 | .de1 INDENT 19 | .\" .rstReportMargin pre: 20 | . RS \\$1 21 | . nr rst2man-indent\\n[rst2man-indent-level] \\n[an-margin] 22 | . nr rst2man-indent-level +1 23 | .\" .rstReportMargin post: 24 | .. 25 | .de UNINDENT 26 | . RE 27 | .\" indent \\n[an-margin] 28 | .\" old: \\n[rst2man-indent\\n[rst2man-indent-level]] 29 | .nr rst2man-indent-level -1 30 | .\" new: \\n[rst2man-indent\\n[rst2man-indent-level]] 31 | .in \\n[rst2man-indent\\n[rst2man-indent-level]]u 32 | .. 33 | .SH SYNOPSIS 34 | .sp 35 | \fBlyvi [\-h] [\-c file] [\-l] [\-v] [command]\fP 36 | .SH DESCRIPTION 37 | .sp 38 | Lyvi is a lyrics, artist info and guitar tabs viewer. On supported terminals, Lyvi can also 39 | show artist photos and cover images. 40 | .SH OPTIONS 41 | .INDENT 0.0 42 | .TP 43 | .B \fB\-h\fP, \fB\-\-help\fP 44 | Show help message and exit. 45 | .TP 46 | .B \fB\-c\fP, \fB\-\-config\-file file\fP 47 | Change the configuration file from default \fB$HOME/.config/lyvi/lyvi.conf\fP to \fBfile\fP\&. 48 | .TP 49 | .B \fB\-l\fP, \fB\-\-list\-players\fP 50 | Print a list of supported players and exit. 51 | .TP 52 | .B \fB\-v\fP, \fB\-\-version\fP 53 | Print version information and exit. 54 | .TP 55 | .B \fBcommand\fP 56 | Send a command to the connected player and exit. 57 | .sp 58 | Available commands are: 59 | .INDENT 7.0 60 | .INDENT 3.5 61 | .INDENT 0.0 62 | .IP \(bu 2 63 | \fBplay\fP 64 | .IP \(bu 2 65 | \fBpause\fP 66 | .IP \(bu 2 67 | \fBnext\fP 68 | .IP \(bu 2 69 | \fBprev\fP 70 | .IP \(bu 2 71 | \fBstop\fP 72 | .IP \(bu 2 73 | \fBvolup\fP 74 | .IP \(bu 2 75 | \fBvoldn\fP 76 | .UNINDENT 77 | .UNINDENT 78 | .UNINDENT 79 | .sp 80 | \fBNote:\fP Not all commands are supported by all players. 81 | .UNINDENT 82 | .SH KEY BINDINGS 83 | .INDENT 0.0 84 | .TP 85 | .B \fBq\fP 86 | Quit 87 | .TP 88 | .B \fBR\fP 89 | Reload background image 90 | .TP 91 | .B \fBr\fP 92 | Reload metadata for current view 93 | .TP 94 | .B \fBs\fP 95 | Toggle background type 96 | .TP 97 | .B \fBa\fP 98 | Toggle view 99 | .TP 100 | .B \fBh\fP 101 | Toggle UI 102 | .TP 103 | .B \fBUp/k/Mouse wheel\fP 104 | Scroll up 105 | .TP 106 | .B \fBDown/j/Mouse wheel\fP 107 | Scroll down 108 | .TP 109 | .B \fBg\fP 110 | Scroll to the top 111 | .TP 112 | .B \fBG\fP 113 | Scroll to the bottom 114 | .UNINDENT 115 | .SH PLAYER SETUP 116 | .sp 117 | Players not mentioned here should work out\-of\-box. 118 | .SS Mpd 119 | .INDENT 0.0 120 | .IP \(bu 2 121 | \fIOptional:\fP Set \fBmpd_host\fP, \fBmpd_port\fP and \fBmpd_config_file\fP configuration options (see CONFIGURATION section below) 122 | .UNINDENT 123 | .SS Mpg123 124 | .INDENT 0.0 125 | .IP \(bu 2 126 | Redirect \fBmpg123\fP output to \fB/tmp/mpg123.log\fP, e.g.: 127 | .INDENT 2.0 128 | .INDENT 3.5 129 | .sp 130 | .nf 131 | .ft C 132 | mpg123 *.mp3 2> /tmp/mpg123.log 133 | .ft P 134 | .fi 135 | .UNINDENT 136 | .UNINDENT 137 | .UNINDENT 138 | .SS Mplayer 139 | .INDENT 0.0 140 | .IP \(bu 2 141 | \fIOptional:\fP Set \fBmplayer_config_dir\fP configuration option (see CONFIGURATION section below) 142 | .IP \(bu 2 143 | Create fifo: 144 | .INDENT 2.0 145 | .INDENT 3.5 146 | .sp 147 | .nf 148 | .ft C 149 | mkfifo /path/to/mplayer/config/dir/fifo 150 | .ft P 151 | .fi 152 | .UNINDENT 153 | .UNINDENT 154 | .IP \(bu 2 155 | Add this line to mplayer configuration file: 156 | .INDENT 2.0 157 | .INDENT 3.5 158 | .sp 159 | .nf 160 | .ft C 161 | input=file=/path/to/mplayer/config/dir/fifo 162 | .ft P 163 | .fi 164 | .UNINDENT 165 | .UNINDENT 166 | .IP \(bu 2 167 | If you\(aqre using MPlayer with a front\-end (e.g. SMPlayer, UMPlayer...), configure it to save 168 | MPlayer log to \fB/path/to/mplayer/config/dir/log\fP file 169 | .INDENT 2.0 170 | .INDENT 3.5 171 | .INDENT 0.0 172 | .IP a. 3 173 | For SMPlayer/UMPlayer, this option is located at 174 | Options > Preferences > Advanced > Logs > Autosave MPlayer log to file 175 | .IP b. 3 176 | For standalone MPlayer, you need to run it with the following command\-line arguments: 177 | .INDENT 2.0 178 | .INDENT 3.5 179 | .sp 180 | .nf 181 | .ft C 182 | mplayer \-quiet \-msglevel all=0 \-identify *.mp3 > /path/to/mplayer/config/dir/log 183 | .ft P 184 | .fi 185 | .UNINDENT 186 | .UNINDENT 187 | .UNINDENT 188 | .UNINDENT 189 | .UNINDENT 190 | .UNINDENT 191 | .SS Pianobar 192 | .INDENT 0.0 193 | .IP \(bu 2 194 | Copy \fBeventcmd\fP script from \fBlyvi/data/pianobar/\fP to \fB~/.config/pianobar/\fP and make it executable. 195 | If you already have custom \fBeventcmd\fP script, add \fBsongstart\fP event like in the example script. 196 | .IP \(bu 2 197 | Add this line to \fB~/.config/pianobar/config\fP: 198 | .INDENT 2.0 199 | .INDENT 3.5 200 | .sp 201 | .nf 202 | .ft C 203 | event_command = /home/USER/.config/pianobar/eventcmd 204 | .ft P 205 | .fi 206 | .UNINDENT 207 | .UNINDENT 208 | .IP \(bu 2 209 | Create fifo: 210 | .INDENT 2.0 211 | .INDENT 3.5 212 | .sp 213 | .nf 214 | .ft C 215 | mkfifo ~/.config/pianobar/ctl 216 | .ft P 217 | .fi 218 | .UNINDENT 219 | .UNINDENT 220 | .UNINDENT 221 | .SS Shell\-fm 222 | .INDENT 0.0 223 | .IP \(bu 2 224 | Add these lines to \fB~/.shell\-fm/shell\-fm.rc\fP: 225 | .INDENT 2.0 226 | .INDENT 3.5 227 | .sp 228 | .nf 229 | .ft C 230 | np\-file = /home/USER/.shell\-fm/nowplaying 231 | np\-file\-format = %a|%t|%l|%p 232 | unix = /home/USER/.shell\-fm/socket 233 | .ft P 234 | .fi 235 | .UNINDENT 236 | .UNINDENT 237 | .UNINDENT 238 | .SH CONFIGURATION 239 | .sp 240 | Default path to the configuration file is \fB$HOME/.config/lyvi/lyvi.conf\fP\&. 241 | The configuration file has Python syntax. Basically, each line should contain one configuration option 242 | in the \fBoption = value\fP format. 243 | .SS Options 244 | .sp 245 | Each option is in the format \fBoption [type] (default_value)\fP\&. 246 | .INDENT 0.0 247 | .TP 248 | .B \fBautoscroll [bool] (False)\fP 249 | Enable autoscroll. 250 | .TP 251 | .B \fBbg [bool] (False)\fP 252 | Enable background. Currently, the background is supported only in urxvt. 253 | .TP 254 | .B \fBbg_opacity [float] (0.15)\fP 255 | Background opacity. 256 | .TP 257 | .B \fBbg_tmux_backdrops_pane [int or None] (None)\fP 258 | A tmux pane where the backdrops are displayed. Panes are numbered from 0. 259 | To enable tmux support, this option must be set. 260 | .TP 261 | .B \fBbg_tmux_backdrops_underlying [bool] (False)\fP 262 | Set to True if Lyvi is running in the same pane where backdrops are displayed. 263 | .TP 264 | .B \fBbg_tmux_cover_pane [int or None] (None)\fP 265 | A tmux pane where the covers are displayed. Panes are numbered from 0. 266 | To enable tmux support, this option must be set. 267 | .TP 268 | .B \fBbg_tmux_cover_underlying [bool] (False)\fP 269 | Set to True if Lyvi is running in the same pane where covers are displayed. 270 | .TP 271 | .B \fBbg_tmux_window_title [str or None] (None)\fP 272 | A title of the terminal window running tmux. 273 | To enable tmux support, this option must be set. 274 | .TP 275 | .B \fBbg_type [\(aqbackdrops\(aq or \(aqcover\(aq] (\(aqcover\(aq)\fP 276 | Default background type. 277 | .TP 278 | .B \fBdefault_player [str or None] (None)\fP 279 | Try to find player specified with this option first. 280 | .TP 281 | .B \fBdefault_view [\(aqlyrics\(aq or \(aqartistbio\(aq or \(aqguitartabs\(aq] (\(aqlyrics\(aq)\fP 282 | Default view. 283 | .TP 284 | .B \fBheader_bg [str] (\(aqdefault\(aq)\fP 285 | Background color of the header. 286 | .TP 287 | .B \fBheader_fg [str] (\(aqwhite\(aq)\fP 288 | Foreground color of the header. 289 | .TP 290 | .B \fBkey_quit [str] (\(aqq\(aq)\fP 291 | "Quit" key. 292 | .TP 293 | .B \fBkey_reload_bg [str] (\(aqR\(aq)\fP 294 | "Reload background" key. 295 | .TP 296 | .B \fBkey_reload_view [str] (\(aqr\(aq)\fP 297 | "Reload current view" key. 298 | .TP 299 | .B \fBkey_toggle_bg_type [str] (\(aqs\(aq)\fP 300 | "Toggle background type" key. 301 | .TP 302 | .B \fBkey_toggle_views [str] (\(aqa\(aq)\fP 303 | "Toggle view" key. 304 | .TP 305 | .B \fBkey_toggle_ui [str] (\(aqh\(aq)\fP 306 | "Toggle UI" key. 307 | .TP 308 | .B \fBmpd_config_file [str] (\(aq~/.mpdconf\(aq or \(aq/etc/mpd.conf\(aq)\fP 309 | Path to the mpd configuration file. 310 | .TP 311 | .B \fBmpd_host [str] (same as MPD_HOST environment variable or \(aqlocalhost\(aq)\fP 312 | Mpd host. 313 | .TP 314 | .B \fBmpd_port [int] (same as MPD_PORT environment variable or 6600)\fP 315 | Mpd port. 316 | .TP 317 | .B \fBmplayer_config_dir [str] (os.environ[\(aqHOME\(aq] + \(aq/.mplayer/\(aq)\fP 318 | Path to the mplayer configuration directory. 319 | .TP 320 | .B \fBsave_cover [str or None] (None)\fP 321 | Path to the saved cover (see below). 322 | .TP 323 | .B \fBsave_lyrics [str or None] (None)\fP 324 | Path to the saved lyrics (see below). 325 | .TP 326 | .B \fBstatusbar_bg [str] (\(aqdefault\(aq)\fP 327 | Background color of the statusbar. 328 | .TP 329 | .B \fBstatusbar_fg [str] (\(aqdefault\(aq)\fP 330 | Foreground color of the statusbar. 331 | .TP 332 | .B \fBtext_bg [str] (\(aqdefault\(aq)\fP 333 | Background color of the text. 334 | .TP 335 | .B \fBtext_fg [str] (\(aqdefault\(aq)\fP 336 | Foreground color of the text. 337 | .TP 338 | .B \fBui_hidden [bool] (False)\fP 339 | Hide UI by default. 340 | .UNINDENT 341 | .SS Metadata saving 342 | .sp 343 | In the \fBsave_lyrics\fP and \fBsave_cover\fP options, the following variables can be used: 344 | .INDENT 0.0 345 | .INDENT 3.5 346 | .INDENT 0.0 347 | .IP \(bu 2 348 | \fB\fP \-\- current song\(aqs file name without the suffix 349 | .IP \(bu 2 350 | \fB\fP \-\- current song\(aqs directory 351 | .IP \(bu 2 352 | \fB\fP \-\- current song\(aqs artist 353 | .IP \(bu 2 354 | \fB\fP \-\- current song\(aqs title 355 | .IP \(bu 2 356 | \fB<album>\fP \-\- current song\(aqs album 357 | .UNINDENT 358 | .UNINDENT 359 | .UNINDENT 360 | .sp 361 | E.g.: 362 | .INDENT 0.0 363 | .INDENT 3.5 364 | .sp 365 | .nf 366 | .ft C 367 | save_lyrics = \(aq<songdir>/<filename>.lyric\(aq 368 | .ft P 369 | .fi 370 | .UNINDENT 371 | .UNINDENT 372 | .SS Examples 373 | .INDENT 0.0 374 | .IP \(bu 2 375 | MPD as a default player, normal background: 376 | .INDENT 2.0 377 | .INDENT 3.5 378 | .sp 379 | .nf 380 | .ft C 381 | default_player = \(aqmpd\(aq 382 | bg = True 383 | .ft P 384 | .fi 385 | .UNINDENT 386 | .UNINDENT 387 | .IP \(bu 2 388 | Tmux background, assuming that tmux window title is "music" and both cover and backdrops 389 | are displayed in the pane 2: 390 | .INDENT 2.0 391 | .INDENT 3.5 392 | .sp 393 | .nf 394 | .ft C 395 | bg = True 396 | bg_tmux_window_title = \(aqmusic\(aq 397 | bg_tmux_backdrops_pane = 2 398 | bg_tmux_cover_pane = 2 399 | .ft P 400 | .fi 401 | .UNINDENT 402 | .UNINDENT 403 | .IP \(bu 2 404 | Disable "Quit" and "Toggle UI" keys if Lyvi is running in tmux: 405 | .INDENT 2.0 406 | .INDENT 3.5 407 | .sp 408 | .nf 409 | .ft C 410 | import os 411 | 412 | if \(aqTMUX\(aq in os.environ: 413 | key_quit = None 414 | key_toggle_ui = None 415 | .ft P 416 | .fi 417 | .UNINDENT 418 | .UNINDENT 419 | .UNINDENT 420 | .SH AUTHOR 421 | Ondrej Kipila <ok100 at openmailbox dot org> 422 | .\" Generated by docutils manpage writer. 423 | . 424 | -------------------------------------------------------------------------------- /doc/lyvi.1.rst: -------------------------------------------------------------------------------- 1 | ==== 2 | Lyvi 3 | ==== 4 | 5 | -------------------------------------- 6 | command-line lyrics (and more!) viewer 7 | -------------------------------------- 8 | 9 | :Author: Ondrej Kipila ``<ok100 at openmailbox dot org>`` 10 | :Version: 2.0-git 11 | :Manual section: 1 12 | 13 | SYNOPSIS 14 | ======== 15 | 16 | ``lyvi [-h] [-c file] [-l] [-v] [command]`` 17 | 18 | DESCRIPTION 19 | =========== 20 | 21 | Lyvi is a lyrics, artist info and guitar tabs viewer. On supported terminals, Lyvi can also 22 | show artist photos and cover images. 23 | 24 | OPTIONS 25 | ======= 26 | ``-h``, ``--help`` 27 | Show help message and exit. 28 | ``-c``, ``--config-file file`` 29 | Change the configuration file from default ``$HOME/.config/lyvi/lyvi.conf`` to ``file``. 30 | ``-l``, ``--list-players`` 31 | Print a list of supported players and exit. 32 | ``-v``, ``--version`` 33 | Print version information and exit. 34 | ``command`` 35 | Send a command to the connected player and exit. 36 | 37 | Available commands are: 38 | 39 | - ``play`` 40 | - ``pause`` 41 | - ``next`` 42 | - ``prev`` 43 | - ``stop`` 44 | - ``volup`` 45 | - ``voldn`` 46 | 47 | **Note:** Not all commands are supported by all players. 48 | 49 | KEY BINDINGS 50 | ============ 51 | 52 | ``q`` 53 | Quit 54 | 55 | ``R`` 56 | Reload background image 57 | 58 | ``r`` 59 | Reload metadata for current view 60 | 61 | ``s`` 62 | Toggle background type 63 | 64 | ``a`` 65 | Toggle view 66 | 67 | ``h`` 68 | Toggle UI 69 | 70 | ``Up/k/Mouse wheel`` 71 | Scroll up 72 | 73 | ``Down/j/Mouse wheel`` 74 | Scroll down 75 | 76 | ``g`` 77 | Scroll to the top 78 | 79 | ``G`` 80 | Scroll to the bottom 81 | 82 | PLAYER SETUP 83 | ============ 84 | 85 | Players not mentioned here should work out-of-box. 86 | 87 | Mpd 88 | --- 89 | 90 | - *Optional:* Set ``mpd_host``, ``mpd_port`` and ``mpd_config_file`` configuration options (see CONFIGURATION section below) 91 | 92 | Mpg123 93 | ------ 94 | 95 | - Redirect ``mpg123`` output to ``/tmp/mpg123.log``, e.g.:: 96 | 97 | mpg123 *.mp3 2> /tmp/mpg123.log 98 | 99 | Mplayer 100 | ------- 101 | 102 | - *Optional:* Set ``mplayer_config_dir`` configuration option (see CONFIGURATION section below) 103 | - Create fifo:: 104 | 105 | mkfifo /path/to/mplayer/config/dir/fifo 106 | 107 | - Add this line to mplayer configuration file:: 108 | 109 | input=file=/path/to/mplayer/config/dir/fifo 110 | 111 | - If you're using MPlayer with a front-end (e.g. SMPlayer, UMPlayer...), configure it to save 112 | MPlayer log to ``/path/to/mplayer/config/dir/log`` file 113 | 114 | a. For SMPlayer/UMPlayer, this option is located at 115 | Options > Preferences > Advanced > Logs > Autosave MPlayer log to file 116 | 117 | b. For standalone MPlayer, you need to run it with the following command-line arguments:: 118 | 119 | mplayer -quiet -msglevel all=0 -identify *.mp3 > /path/to/mplayer/config/dir/log 120 | 121 | Pianobar 122 | -------- 123 | 124 | - Copy ``eventcmd`` script from ``lyvi/data/pianobar/`` to ``~/.config/pianobar/`` and make it executable. 125 | If you already have custom ``eventcmd`` script, add ``songstart`` event like in the example script. 126 | 127 | - Add this line to ``~/.config/pianobar/config``:: 128 | 129 | event_command = /home/USER/.config/pianobar/eventcmd 130 | 131 | - Create fifo:: 132 | 133 | mkfifo ~/.config/pianobar/ctl 134 | 135 | Shell-fm 136 | -------- 137 | 138 | - Add these lines to ``~/.shell-fm/shell-fm.rc``:: 139 | 140 | np-file = /home/USER/.shell-fm/nowplaying 141 | np-file-format = %a|%t|%l|%p 142 | unix = /home/USER/.shell-fm/socket 143 | 144 | CONFIGURATION 145 | ============= 146 | 147 | Default path to the configuration file is ``$HOME/.config/lyvi/lyvi.conf``. 148 | The configuration file has Python syntax. Basically, each line should contain one configuration option 149 | in the ``option = value`` format. 150 | 151 | Options 152 | ------- 153 | 154 | Each option is in the format ``option [type] (default_value)``. 155 | 156 | ``autoscroll [bool] (False)`` 157 | Enable autoscroll. 158 | 159 | ``bg [bool] (False)`` 160 | Enable background. Currently, the background is supported only in urxvt. 161 | 162 | ``bg_opacity [float] (0.15)`` 163 | Background opacity. 164 | 165 | ``bg_tmux_backdrops_pane [int or None] (None)`` 166 | A tmux pane where the backdrops are displayed. Panes are numbered from 0. 167 | To enable tmux support, this option must be set. 168 | 169 | ``bg_tmux_backdrops_underlying [bool] (False)`` 170 | Set to True if Lyvi is running in the same pane where backdrops are displayed. 171 | 172 | ``bg_tmux_cover_pane [int or None] (None)`` 173 | A tmux pane where the covers are displayed. Panes are numbered from 0. 174 | To enable tmux support, this option must be set. 175 | 176 | ``bg_tmux_cover_underlying [bool] (False)`` 177 | Set to True if Lyvi is running in the same pane where covers are displayed. 178 | 179 | ``bg_tmux_window_title [str or None] (None)`` 180 | A title of the terminal window running tmux. 181 | To enable tmux support, this option must be set. 182 | 183 | ``bg_type ['backdrops' or 'cover'] ('cover')`` 184 | Default background type. 185 | 186 | ``default_player [str or None] (None)`` 187 | Try to find player specified with this option first. 188 | 189 | ``default_view ['lyrics' or 'artistbio' or 'guitartabs'] ('lyrics')`` 190 | Default view. 191 | 192 | ``header_bg [str] ('default')`` 193 | Background color of the header. 194 | 195 | ``header_fg [str] ('white')`` 196 | Foreground color of the header. 197 | 198 | ``key_quit [str] ('q')`` 199 | "Quit" key. 200 | 201 | ``key_reload_bg [str] ('R')`` 202 | "Reload background" key. 203 | 204 | ``key_reload_view [str] ('r')`` 205 | "Reload current view" key. 206 | 207 | ``key_toggle_bg_type [str] ('s')`` 208 | "Toggle background type" key. 209 | 210 | ``key_toggle_views [str] ('a')`` 211 | "Toggle view" key. 212 | 213 | ``key_toggle_ui [str] ('h')`` 214 | "Toggle UI" key. 215 | 216 | ``mpd_config_file [str] ('~/.mpdconf' or '/etc/mpd.conf')`` 217 | Path to the mpd configuration file. 218 | 219 | ``mpd_host [str] (same as MPD_HOST environment variable or 'localhost')`` 220 | Mpd host. 221 | 222 | ``mpd_port [int] (same as MPD_PORT environment variable or 6600)`` 223 | Mpd port. 224 | 225 | ``mplayer_config_dir [str] (os.environ['HOME'] + '/.mplayer/')`` 226 | Path to the mplayer configuration directory. 227 | 228 | ``save_cover [str or None] (None)`` 229 | Path to the saved cover (see below). 230 | 231 | ``save_lyrics [str or None] (None)`` 232 | Path to the saved lyrics (see below). 233 | 234 | ``statusbar_bg [str] ('default')`` 235 | Background color of the statusbar. 236 | 237 | ``statusbar_fg [str] ('default')`` 238 | Foreground color of the statusbar. 239 | 240 | ``text_bg [str] ('default')`` 241 | Background color of the text. 242 | 243 | ``text_fg [str] ('default')`` 244 | Foreground color of the text. 245 | 246 | ``ui_hidden [bool] (False)`` 247 | Hide UI by default. 248 | 249 | Metadata saving 250 | --------------- 251 | In the ``save_lyrics`` and ``save_cover`` options, the following variables can be used: 252 | 253 | - ``<filename>`` -- current song's file name without the suffix 254 | - ``<songdir>`` -- current song's directory 255 | - ``<artist>`` -- current song's artist 256 | - ``<title>`` -- current song's title 257 | - ``<album>`` -- current song's album 258 | 259 | E.g.:: 260 | 261 | save_lyrics = '<songdir>/<filename>.lyric' 262 | 263 | Examples 264 | -------- 265 | 266 | - MPD as a default player, normal background:: 267 | 268 | default_player = 'mpd' 269 | bg = True 270 | 271 | - Tmux background, assuming that tmux window title is "music" and both cover and backdrops 272 | are displayed in the pane 2:: 273 | 274 | bg = True 275 | bg_tmux_window_title = 'music' 276 | bg_tmux_backdrops_pane = 2 277 | bg_tmux_cover_pane = 2 278 | 279 | - Disable "Quit" and "Toggle UI" keys if Lyvi is running in tmux:: 280 | 281 | import os 282 | 283 | if 'TMUX' in os.environ: 284 | key_quit = None 285 | key_toggle_ui = None 286 | -------------------------------------------------------------------------------- /lyvi.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import lyvi 4 | 5 | 6 | lyvi.main() 7 | -------------------------------------------------------------------------------- /lyvi/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2013 Ondrej Kipila <ok100 at openmailbox dot org> 2 | # This work is free. You can redistribute it and/or modify it under the 3 | # terms of the Do What The Fuck You Want To Public License, Version 2, 4 | # as published by Sam Hocevar. See the COPYING file for more details. 5 | 6 | """Command-line lyrics (and more!) viewer.""" 7 | 8 | 9 | import sys 10 | 11 | if sys.version < '3': 12 | print('Lyvi only supports Python 3.') 13 | sys.exit(1) 14 | 15 | import argparse 16 | import os 17 | import runpy 18 | import signal 19 | import time 20 | from tempfile import gettempdir 21 | 22 | import lyvi.config_defaults 23 | from lyvi.utils import thread 24 | 25 | # Make this PEP 386 compatible: 26 | __version__ = '2.0.0' 27 | 28 | 29 | USERAGENT = 'lyvi/' + __version__ 30 | TEMP = gettempdir() 31 | PID = os.getpid() 32 | 33 | 34 | def parse_args(): 35 | """Return the populated Namespace of command-line args.""" 36 | parser = argparse.ArgumentParser(prog='lyvi') 37 | parser.add_argument('command', nargs='?', 38 | help='send a command to the player and exit') 39 | parser.add_argument('-c', '--config-file', 40 | help='path to an alternate config file') 41 | parser.add_argument('-l', '--list-players', 42 | help='print a list of supported players and exit', 43 | action='store_true') 44 | parser.add_argument('-v', '--version', 45 | help='print version information and exit', 46 | action='store_true') 47 | return parser.parse_args() 48 | 49 | 50 | def parse_config(): 51 | """Return a dict with updated configuration options.""" 52 | config = dict(lyvi.config_defaults.defaults) 53 | file = args.config_file or os.path.join(os.environ['HOME'], '.config', 'lyvi', 'lyvi.conf') 54 | if os.path.exists(file): 55 | try: 56 | config.update((k, v) for k, v in runpy.run_path(file).items() if k in config) 57 | except: 58 | # Error in configuration file 59 | import traceback 60 | tbtype, tbvalue, tb = sys.exc_info() 61 | sys.stderr.write('\033[31mError in configuration file.\033[0m\n\n%s\n' 62 | % ''.join(traceback.format_exception_only(tbtype, tbvalue)).strip()) 63 | sys.exit(1) 64 | elif args.config_file: 65 | sys.stderr.write('Configuration file not found: ' + file + '\n') 66 | sys.exit(1) 67 | return config 68 | 69 | 70 | def print_version(): 71 | """Print version information.""" 72 | import plyr 73 | print('Lyvi %s, using libglyr %s' % (__version__, plyr.version().split()[1])) 74 | 75 | 76 | def init_background(): 77 | """If background is enabled, return the initialized Background class, 78 | otherwise return None. 79 | """ 80 | if not config['bg']: 81 | return None 82 | 83 | try: 84 | import lyvi.background 85 | except ImportError: 86 | return None 87 | 88 | if (config['bg_tmux_backdrops_pane'] is not None 89 | and config['bg_tmux_cover_pane'] is not None 90 | and config['bg_tmux_window_title'] is not None 91 | and 'TMUX' in os.environ): 92 | return lyvi.background.TmuxBackground() 93 | elif 'rxvt' in os.environ['TERM']: 94 | return lyvi.background.Background() 95 | 96 | return None 97 | 98 | 99 | def init_ui(): 100 | """Return the initialized Ui class.""" 101 | import lyvi.tui 102 | return lyvi.tui.Ui() 103 | 104 | 105 | def init_metadata(): 106 | """Return the initialized Metadata class.""" 107 | import lyvi.metadata 108 | return lyvi.metadata.Metadata() 109 | 110 | 111 | def watch_player(): 112 | """Main loop which checks for new song and updates the metadata.""" 113 | while True: 114 | if not player.running(): 115 | exit() 116 | player.get_status() 117 | if player.state == 'stop': 118 | md.reset_tags() 119 | elif (player.artist != md.artist 120 | or player.title != md.title 121 | or player.album != md.album): 122 | needsupdate = ['lyrics', 'guitartabs'] 123 | if player.artist != md.artist: 124 | needsupdate += ['artistbio'] 125 | if bg: 126 | needsupdate += ['backdrops'] 127 | if player.album != md.album: 128 | needsupdate += ['cover'] 129 | md.set_tags() 130 | for item in needsupdate: 131 | thread(md.get, (item,)) 132 | time.sleep(1) 133 | 134 | 135 | def exit(signum=None, frame=None): 136 | """Do the cleanup and exit the app. 137 | 138 | Parameters are not used, but required for signal callback. 139 | """ 140 | ui.quit = True 141 | if bg: 142 | bg.cleanup() 143 | player.cleanup() 144 | 145 | 146 | def main(): 147 | """Start the app.""" 148 | thread(watch_player) 149 | ui.init() 150 | try: 151 | ui.mainloop() 152 | except KeyboardInterrupt: 153 | pass 154 | finally: 155 | exit() 156 | 157 | 158 | args = parse_args() 159 | config = parse_config() 160 | if args.version: 161 | print_version() 162 | sys.exit() 163 | import lyvi.players 164 | if args.list_players: 165 | lyvi.players.list() 166 | sys.exit() 167 | player = lyvi.players.find() 168 | if not player: 169 | sys.stderr.write('No running supported player found!\n') 170 | sys.exit(1) 171 | if args.command: 172 | if not player.send_command(args.command): 173 | sys.stderr.write('Unknown command: ' + args.command + '\n') 174 | sys.exit(1) 175 | sys.exit() 176 | md = init_metadata() 177 | bg = init_background() 178 | ui = init_ui() 179 | 180 | # Also do the cleanup when the terminal is closed 181 | signal.signal(signal.SIGHUP, exit) 182 | -------------------------------------------------------------------------------- /lyvi/background.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2013 Ondrej Kipila <ok100 at openmailbox dot org> 2 | # This work is free. You can redistribute it and/or modify it under the 3 | # terms of the Do What The Fuck You Want To Public License, Version 2, 4 | # as published by Sam Hocevar. See the COPYING file for more details. 5 | 6 | """Classes for normal and Tmux backgrounds.""" 7 | 8 | 9 | import os 10 | import sys 11 | from io import BytesIO 12 | 13 | from PIL import Image 14 | 15 | import lyvi 16 | from lyvi.utils import check_output 17 | 18 | 19 | # Get the terminal background color from the 'xrdb' command 20 | for line in check_output('xrdb -query').splitlines(): 21 | if 'background' in line: 22 | BG_COLOR = line.split(':')[1].strip() 23 | break 24 | else: 25 | BG_COLOR = '#FFFFFF' 26 | 27 | 28 | def pil_image(image): 29 | """Return the initialized Image class. 30 | 31 | Keyword arguments: 32 | image -- bytes or Image instance 33 | """ 34 | if isinstance(image, bytes): 35 | buf = BytesIO(image) 36 | return Image.open(buf) 37 | return image 38 | 39 | 40 | def blend(image, opacity): 41 | """Return the image blended with terminal background color. 42 | 43 | Keyword arguments: 44 | image -- image to blend 45 | opacity -- opacity of the background color layer 46 | """ 47 | image = pil_image(image) 48 | layer = Image.new(image.mode, image.size, BG_COLOR) 49 | return Image.blend(image, layer, 1 - opacity) 50 | 51 | 52 | def paste(root, image_to_paste, x, y): 53 | """Return root image with pasted image. 54 | 55 | Keyword arguments: 56 | root -- root image 57 | image_to_paste -- image to paste 58 | x -- top-left x coordinates of the image to paste 59 | y -- top-left y coordinates of the image to paste 60 | """ 61 | root = pil_image(root) 62 | image_to_paste = pil_image(image_to_paste) 63 | root.paste(image_to_paste, (x, y)) 64 | return root 65 | 66 | 67 | def resize(image, x, y): 68 | """Return the resized image. 69 | 70 | Keyword argumants: 71 | image -- image to resize 72 | x -- new x resolution in px 73 | y -- new y resolution in px 74 | """ 75 | image = pil_image(image) 76 | image.thumbnail((x, y), Image.ANTIALIAS) 77 | return image 78 | 79 | 80 | class Background: 81 | ESCAPE_STR_BEG = "\033]20;" 82 | ESCAPE_STR_END = ";100:op=keep-aspect\a" 83 | 84 | def __init__(self): 85 | """Initialize the class.""" 86 | self.FILE = os.path.join(lyvi.TEMP, 'lyvi-%s.jpg' % lyvi.PID) 87 | self.type = lyvi.config['bg_type'] 88 | self.opacity = lyvi.config['bg_opacity'] 89 | 90 | def toggle_type(self): 91 | """Toggle background type.""" 92 | self.type = 'cover' if self.type == 'backdrops' else 'backdrops' 93 | self.update() 94 | 95 | def _make(self, clean=False): 96 | """Save the background to a temporary file. 97 | 98 | Keyword arguments: 99 | clean -- whether the background should be unset 100 | """ 101 | if (((self.type == 'backdrops' and lyvi.md.backdrops and lyvi.md.artist) 102 | or (self.type == 'cover' and lyvi.md.cover and lyvi.md.album)) 103 | and not clean): 104 | image = blend(getattr(lyvi.md, self.type), self.opacity) 105 | else: 106 | image = Image.new('RGB', (100, 100), BG_COLOR) 107 | image.save(self.FILE) 108 | 109 | def _set(self): 110 | """Set the image file as a terminal background.""" 111 | sys.stdout.write(self.ESCAPE_STR_BEG + self.FILE + self.ESCAPE_STR_END) 112 | 113 | def update(self, clean=False): 114 | """Update the background. 115 | 116 | Keyword arguments: 117 | clean -- whether the background should be unset 118 | """ 119 | self._make(clean=clean) 120 | self._set() 121 | 122 | def cleanup(self): 123 | """Unset the background and delete the image file.""" 124 | sys.stdout.write(self.ESCAPE_STR_BEG + ';+1000000\a') 125 | if os.path.exists(self.FILE): 126 | os.remove(self.FILE) 127 | 128 | 129 | class Tmux: 130 | """A class which represents Tmux layout and dimensions. 131 | 132 | Properties: 133 | layout -- a list containing Pane instances representing all tmux panes 134 | width -- window width in px 135 | height -- window height in px 136 | cell -- Cell instance representing a terminal cell 137 | """ 138 | class Cell: 139 | """Class used as a placeholder for terminal cell properties. 140 | 141 | Properties: 142 | w -- cell width in px 143 | h -- cell height in px 144 | """ 145 | pass 146 | 147 | class Pane: 148 | """Class used as a placeholder for pane properties. 149 | 150 | Properties: 151 | active -- whether the pane is active 152 | x -- horizontal pane offset from the top left corner of the terminal in cells 153 | y -- vertical pane offset from the top left corner of the terminal in cells 154 | w -- pane width in cells 155 | h -- pane height in cells 156 | """ 157 | pass 158 | 159 | def __init__(self): 160 | """Initialize the class and update the class properties.""" 161 | self.cell = self.Cell() 162 | self.update() 163 | 164 | def _get_layout(self): 165 | """Return a list containing Pane instances representing all tmux panes.""" 166 | display = check_output('tmux display -p \'#{window_layout}\'') 167 | for delim in '[]{}': 168 | display = display.replace(delim, ',') 169 | layout = [self.Pane()] 170 | layout[0].w, layout[0].h = (int(a) for a in display.split(',')[1].split('x')) 171 | display = display.split(',', 1)[1] 172 | chunks = display.split(',') 173 | for i in range(0, len(chunks) - 1): 174 | if 'x' in chunks[i] and 'x' not in chunks[i + 3]: 175 | layout.append(self.Pane()) 176 | layout[-1].w, layout[-1].h = (int(a) for a in chunks[i].split('x')) 177 | layout[-1].x = int(chunks[i + 1]) 178 | layout[-1].y = int(chunks[i + 2]) 179 | lsp = check_output('tmux lsp').splitlines() 180 | for chunk in lsp: 181 | layout[lsp.index(chunk) + 1].active = 'active' in chunk 182 | return layout 183 | 184 | def _get_size_px(self): 185 | """Return a tuple (width, height) with the tmux window dimensions in px.""" 186 | while(True): 187 | # Use xwininfo command to get the window dimensions 188 | info = check_output('xwininfo -name ' + lyvi.config['bg_tmux_window_title']) 189 | try: 190 | width = int(info.split('Width: ')[1].split('\n')[0]) 191 | height = int(info.split('Height: ')[1].split('\n')[0]) 192 | except IndexError: 193 | continue 194 | else: 195 | return width, height 196 | 197 | def update(self): 198 | """Set class properties to the actual values.""" 199 | self.layout = self._get_layout() 200 | self.width, self.height = self._get_size_px() 201 | self.cell.w = round(self.width / self.layout[0].w) 202 | self.cell.h = round(self.height / self.layout[0].h) 203 | 204 | 205 | class TmuxBackground(Background): 206 | ESCAPE_STR_BEG = "\033Ptmux;\033\033]20;" 207 | ESCAPE_STR_END = ";100x100+50+50:op=keep-aspect\a\033\\\\" 208 | 209 | def __init__(self): 210 | """Initialize the class.""" 211 | super().__init__() 212 | self._tmux = Tmux() 213 | 214 | def _make(self, clean=False): 215 | self._tmux.update() 216 | image = Image.new('RGB', (self._tmux.width, self._tmux.height), BG_COLOR) 217 | if not clean: 218 | cover = { 219 | 'image': lyvi.md.cover, 220 | 'pane': self._tmux.layout[lyvi.config['bg_tmux_cover_pane'] + 1], 221 | 'underlying': lyvi.config['bg_tmux_cover_underlying'] 222 | } 223 | backdrops = { 224 | 'image': lyvi.md.backdrops, 225 | 'pane': self._tmux.layout[lyvi.config['bg_tmux_backdrops_pane'] + 1], 226 | 'underlying': lyvi.config['bg_tmux_backdrops_underlying'] 227 | } 228 | if lyvi.config['bg_tmux_backdrops_pane'] == lyvi.config['bg_tmux_cover_pane']: 229 | to_paste = [cover if self.type == 'cover' else backdrops] 230 | else: 231 | to_paste = [cover, backdrops] 232 | for t in (t for t in to_paste if t['image']): 233 | t['image'] = resize(t['image'], t['pane'].w * self._tmux.cell.w, 234 | t['pane'].h * self._tmux.cell.h) 235 | if t['underlying']: 236 | t['image'] = blend(t['image'], self.opacity) 237 | x1 = t['pane'].x * self._tmux.cell.w 238 | y1 = t['pane'].y * self._tmux.cell.h 239 | x2 = (t['pane'].x + t['pane'].w) * self._tmux.cell.w 240 | y2 = (t['pane'].y + t['pane'].h) * self._tmux.cell.h 241 | x = round(x1 + (x2 - x1) / 2 - t['image'].size[0] / 2) 242 | y = round(y1 + (y2 - y1) / 2 - t['image'].size[1] / 2) 243 | image = paste(image, t['image'], x, y) 244 | image.save(self.FILE) 245 | -------------------------------------------------------------------------------- /lyvi/config_defaults.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2013 Ondrej Kipila <ok100 at openmailbox dot org> 2 | # This work is free. You can redistribute it and/or modify it under the 3 | # terms of the Do What The Fuck You Want To Public License, Version 2, 4 | # as published by Sam Hocevar. See the COPYING file for more details. 5 | 6 | """Default configuration options.""" 7 | 8 | 9 | import os 10 | 11 | 12 | defaults = { 13 | # Enable autoscroll. 14 | # Type: bool 15 | # Default value: False 16 | 'autoscroll': False, 17 | 18 | # Enable background. Currently, the background is supported only in urxvt. 19 | # Type: bool 20 | # Default value: False 21 | 'bg': False, 22 | 23 | # Background opacity. 24 | # Type: float 25 | # Default value: 0.15 26 | 'bg_opacity': 0.15, 27 | 28 | # A tmux pane where the backdrops are displayed. Panes are numbered from 0. 29 | # To enable tmux support, this option must be set. 30 | # Type: int 31 | # Default value: None 32 | 'bg_tmux_backdrops_pane': None, 33 | 34 | # Set to True if Lyvi is running in the same pane where backdrops are displayed. 35 | # Type: bool 36 | # Default value: False 37 | 'bg_tmux_backdrops_underlying': False, 38 | 39 | # A tmux pane where the covers are displayed. Panes are numbered from 0. 40 | # To enable tmux support, this option must be set. 41 | # Type: int 42 | # Default value: None 43 | 'bg_tmux_cover_pane': None, 44 | 45 | # Set to True if Lyvi is running in the same pane where covers are displayed. 46 | # Type: bool 47 | # Default value: False 48 | 'bg_tmux_cover_underlying': False, 49 | 50 | # A title of the terminal window running tmux. 51 | # To enable tmux support, this option must be set. 52 | # Type: str 53 | # Default value: None 54 | 'bg_tmux_window_title': None, 55 | 56 | # Default background type. 57 | # Type: 'cover', 'backdrops' 58 | # Default value: 'cover' 59 | 'bg_type': 'cover', 60 | 61 | # Try to find player specified with this option first. 62 | # Type: str 63 | # Default value: None 64 | 'default_player': None, 65 | 66 | # Default view. 67 | # Type: 'lyrics', 'artistbio', 'guitartabs' 68 | # Default value: 'lyrics' 69 | 'default_view': 'lyrics', 70 | 71 | # Background color of the header. 72 | # Type: str 73 | # Default value: 'default' 74 | 'header_bg': 'default', 75 | 76 | # Foreground color of the header. 77 | # Type: str 78 | # Default value: 'default,bold' 79 | 'header_fg': 'default,bold', 80 | 81 | # 'Quit' key. 82 | # Type: str 83 | # Default value: 'q' 84 | 'key_quit': 'q', 85 | 86 | # 'Reload background' key. 87 | # Type: str 88 | # Default value: 'R' 89 | 'key_reload_bg': 'R', 90 | 91 | # 'Reload current view' key. 92 | # Type: str 93 | # Default value: 'r' 94 | 'key_reload_view': 'r', 95 | 96 | # 'Toggle background type' key. 97 | # Type: str 98 | # Default value: 's' 99 | 'key_toggle_bg_type': 's', 100 | 101 | # 'Toggle view' key. 102 | # Type: str 103 | # Default value: 'a' 104 | 'key_toggle_views': 'a', 105 | 106 | # 'Toggle UI' key. 107 | # Type: str 108 | # Default value: 'h' 109 | 'key_toggle_ui': 'h', 110 | 111 | # Path to the mpd configuration file. 112 | # Type: str 113 | # Default value: '~/.mpdconf' or '/etc/mpd.conf' 114 | 'mpd_config_file': os.path.join(os.environ['HOME'], '.mpdconf') 115 | if os.path.exists(os.path.join(os.environ['HOME'], '.mpdconf')) else '/etc/mpd.conf', 116 | 117 | # Mpd host. 118 | # Type: str 119 | # Default value: same as MPD_HOST environment variable or 'localhost' 120 | 'mpd_host': os.environ['MPD_HOST'] if 'MPD_HOST' in os.environ else 'localhost', 121 | 122 | # Mpd port. 123 | # Type: int 124 | # Default value: same as MPD_PORT environment variable or 6600 125 | 'mpd_port': os.environ['MPD_PORT'] if 'MPD_PORT' in os.environ else 6600, 126 | 127 | # Path to the mplayer configuration directory. 128 | # Type: str 129 | # Default value: '~/.mplayer' 130 | 'mplayer_config_dir': os.path.join(os.environ['HOME'], '.mplayer'), 131 | 132 | # Path to the saved cover. 133 | # Type: str 134 | # Default value: None 135 | 'save_cover': None, 136 | 137 | # Path to the saved lyrics. 138 | # Type: str 139 | # Default value: None 140 | 'save_lyrics': None, 141 | 142 | # Background color of the statusbar. 143 | # Type: str 144 | # Default value: 'default' 145 | 'statusbar_bg': 'default', 146 | 147 | # Foreground color of the statusbar. 148 | # Type: str 149 | # Default value: 'default' 150 | 'statusbar_fg': 'default', 151 | 152 | # Background color of the text. 153 | # Type: str 154 | # Default value: 'default' 155 | 'text_bg': 'default', 156 | 157 | # Foreground color of the text. 158 | # Type: str 159 | # Default value: 'default' 160 | 'text_fg': 'default', 161 | 162 | # Hide UI by default. 163 | # Type: bool 164 | # Default value: False 165 | 'ui_hidden': False, 166 | } 167 | -------------------------------------------------------------------------------- /lyvi/data/pianobar/eventcmd: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | while read L; do 4 | k="`echo "$L" | cut -d '=' -f 1`" 5 | v="`echo "$L" | cut -d '=' -f 2`" 6 | export "$k=$v" 7 | done < <(grep -e '^\(title\|artist\|album\|stationName\|pRet\|pRetStr\|wRet\|wRetStr\|songDuration\|songPlayed\|rating\)=' /dev/stdin) 8 | 9 | case "$1" in 10 | songstart) 11 | echo "$artist|$title|$album" > $HOME/.config/pianobar/nowplaying ;; 12 | esac 13 | -------------------------------------------------------------------------------- /lyvi/metadata.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2013 Ondrej Kipila <ok100 at openmailbox dot org> 2 | # This work is free. You can redistribute it and/or modify it under the 3 | # terms of the Do What The Fuck You Want To Public License, Version 2, 4 | # as published by Sam Hocevar. See the COPYING file for more details. 5 | 6 | """Metadata-related code.""" 7 | 8 | 9 | import os 10 | import random 11 | from threading import Lock 12 | 13 | import plyr 14 | 15 | import lyvi 16 | 17 | 18 | class Metadata: 19 | """A class which holds metadata for the currently playing song.""" 20 | artist = None 21 | album = None 22 | title = None 23 | file = None 24 | _lyrics = None 25 | _artistbio = None 26 | _guitartabs = None 27 | _backdrops = None 28 | _cover = None 29 | 30 | @property 31 | def lyrics(self): 32 | return self._lyrics 33 | 34 | @lyrics.setter 35 | def lyrics(self, value): 36 | """Update ui and save the lyrics.""" 37 | self._lyrics = value 38 | lyvi.ui.update() 39 | if lyvi.ui.autoscroll: 40 | lyvi.ui.autoscroll.reset() 41 | if lyvi.config['save_lyrics']: 42 | self.save('lyrics', lyvi.config['save_lyrics']) 43 | 44 | @property 45 | def artistbio(self): 46 | return self._artistbio 47 | 48 | @artistbio.setter 49 | def artistbio(self, value): 50 | """Update UI.""" 51 | self._artistbio = value 52 | lyvi.ui.update() 53 | 54 | @property 55 | def guitartabs(self): 56 | return self._guitartabs 57 | 58 | @guitartabs.setter 59 | def guitartabs(self, value): 60 | """Update UI.""" 61 | self._guitartabs = value 62 | lyvi.ui.update() 63 | 64 | @property 65 | def backdrops(self): 66 | return self._backdrops 67 | 68 | @backdrops.setter 69 | def backdrops(self, value): 70 | """Update background.""" 71 | self._backdrops = value 72 | if lyvi.bg: 73 | lyvi.bg.update() 74 | 75 | @property 76 | def cover(self): 77 | return self._cover 78 | 79 | @cover.setter 80 | def cover(self, value): 81 | """Update background and save the cover.""" 82 | self._cover = value 83 | if lyvi.bg: 84 | lyvi.bg.update() 85 | if lyvi.config['save_cover']: 86 | self.save('cover', lyvi.config['save_cover_filename']) 87 | 88 | def __init__(self): 89 | """Initialize the class.""" 90 | cache_dir = os.path.join(os.environ['HOME'], '.local/share/lyvi') 91 | if not os.path.exists(cache_dir): 92 | os.makedirs(cache_dir) 93 | self.cache = plyr.Database(cache_dir) 94 | self.lock = Lock() 95 | 96 | def set_tags(self): 97 | """Set all tag properties to the actual values.""" 98 | self.artist = lyvi.player.artist 99 | self.title = lyvi.player.title 100 | self.album = lyvi.player.album 101 | self.file = lyvi.player.file 102 | 103 | def reset_tags(self): 104 | """Set all tag and metadata properties to None.""" 105 | self.artist = self.title = self.album = self.file = None 106 | self.lyrics = self.artistbio = self.guitartabs = None 107 | self.backdrops = self.cover = None 108 | 109 | def delete(self, type, artist, title, album): 110 | """Delete metadata from the cache. 111 | 112 | Keyword arguments: 113 | type -- type of the metadata 114 | artist -- artist tag 115 | title -- title tag 116 | album -- album tag 117 | """ 118 | if artist and title and album: 119 | self.cache.delete(plyr.Query(get_type=type, artist=artist, title=title, album=album)) 120 | 121 | def save(self, type, file): 122 | """Save the given metadata type. 123 | 124 | Keyword arguments: 125 | type -- type of the metadata 126 | file -- path to the file metadata will be saved to 127 | 128 | Some special substrings can be used in the filename: 129 | <filename> -- name of the current song without extension 130 | <songdir> -- directory containing the current song 131 | <artist> -- artist of the current song 132 | <title> -- title of the current song 133 | <album> -- album of the current song 134 | """ 135 | data = getattr(self, type) 136 | if self.file and data and data != 'Searching...': 137 | for k, v in { 138 | '<filename>': os.path.splitext(os.path.basename(self.file))[0], 139 | '<songdir>': os.path.dirname(self.file), 140 | '<artist>': self.artist, 141 | '<title>': self.title, 142 | '<album>': self.album 143 | }.items(): 144 | file = file.replace(k, v) 145 | if not os.path.exists(os.path.dirname(file)): 146 | os.makedirs(os.path.dirname(file)) 147 | if not os.path.exists(file): 148 | mode = 'wb' if isinstance(data, bytes) else 'w' 149 | with open(file, mode) as f: 150 | f.write(data) 151 | 152 | def _query(self, type, normalize=True, number=1): 153 | """Return a list containing results from glyr.Query, 154 | or None if some tags are missing. 155 | 156 | Keyword arguments: 157 | type -- type of the metadata 158 | normalize -- whether the search strings should be normalized by glyr 159 | """ 160 | try: 161 | query = plyr.Query( 162 | number=number, 163 | parallel=20, 164 | get_type=type, 165 | artist=self.artist, 166 | title=self.title, 167 | album=self.album 168 | ) 169 | except AttributeError: # Missing tags? 170 | return None 171 | query.useragent = lyvi.USERAGENT 172 | query.database = self.cache 173 | if not normalize: 174 | query.normalize = ('none', 'artist', 'album', 'title') 175 | return query.commit() 176 | 177 | def get(self, type): 178 | """Download and set the metadata for the given property. 179 | 180 | Keyword arguments: 181 | type -- type of the metadata 182 | """ 183 | if lyvi.ui.view == type: 184 | lyvi.ui.home() 185 | 186 | artist = self.artist 187 | title = self.title 188 | 189 | number = 1 190 | if type in ('lyrics', 'artistbio', 'guitartabs'): 191 | setattr(self, type, 'Searching...') 192 | elif type in ('backdrops', 'cover'): 193 | setattr(self, type, None) 194 | if type == 'backdrops': 195 | number = 20 196 | 197 | items = (self._query(type, number=number) 198 | or self._query(type, number=number, normalize=False)) 199 | data = None 200 | if items: 201 | if type == 'backdrops': 202 | data = random.choice(items).data 203 | elif type == 'cover': 204 | data = items[0].data 205 | else: 206 | data = items[0].data.decode() 207 | with self.lock: 208 | if artist == self.artist and title == self.title: 209 | setattr(self, type, data) 210 | -------------------------------------------------------------------------------- /lyvi/players/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2013 Ondrej Kipila <ok100 at openmailbox dot org> 2 | # This work is free. You can redistribute it and/or modify it under the 3 | # terms of the Do What The Fuck You Want To Public License, Version 2, 4 | # as published by Sam Hocevar. See the COPYING file for more details. 5 | 6 | """A package which contains all player-related code.""" 7 | 8 | 9 | import importlib 10 | import pkgutil 11 | import sys 12 | 13 | import lyvi 14 | 15 | 16 | def list(): 17 | """Print a list of supported players.""" 18 | print('\033[1mSupported players:\033[0m') 19 | for player in sorted(players): 20 | print('* ' + player) 21 | 22 | 23 | def find(): 24 | """Return the initialized player class, or None if no player was found.""" 25 | if mpris: 26 | players.pop(players.index('mpris')) 27 | if lyvi.config['default_player']: 28 | if mpris and mpris.running(lyvi.config['default_player']): 29 | return mpris.Player(lyvi.config['default_player']) 30 | if lyvi.config['default_player'] in players: 31 | players.insert(0, players.pop(players.index(lyvi.config['default_player']))) 32 | for name in players: 33 | obj = getattr(sys.modules[__name__], name).Player 34 | if obj.running(): 35 | return obj() 36 | if mpris: 37 | obj = mpris.find() 38 | if obj: 39 | return obj 40 | return None 41 | 42 | 43 | class Player: 44 | """Base Player class. 45 | 46 | This class should be subclassed for a specific player. 47 | """ 48 | 49 | def _getter(var): 50 | def get(self): 51 | return getattr(self, var) 52 | return get 53 | 54 | def _setter(var, vtype): 55 | def set(self, value): 56 | # Check if the property has the right type 57 | if type(vtype) is tuple: 58 | if value in vtype: 59 | setattr(self, var, value) 60 | else: 61 | raise ValueError('unsupported value for \'%s\': %s' % (var[1:], value)) 62 | elif isinstance(value, vtype) or value is None: 63 | setattr(self, var, value) 64 | else: 65 | raise ValueError('unsupported type for \'%s\': %s' % (var[1:], type(value).__name__)) 66 | return set 67 | 68 | _state = 'stop' 69 | _artist = _album = _title = _file = _length = None 70 | artist = property(_getter('_artist'), _setter('_artist', str)) 71 | album = property(_getter('_album'), _setter('_album', str)) 72 | title = property(_getter('_title'), _setter('_title', str)) 73 | file = property(_getter('_file'), _setter('_file', str)) 74 | length = property(_getter('_length'), _setter('_length', int)) 75 | state = property(_getter('_state'), _setter('_state', ('play', 'pause', 'stop'))) 76 | 77 | @classmethod 78 | def running(self): 79 | """Return True if the player is running.""" 80 | raise NotImplementedError('running() should be implemented in subclass') 81 | 82 | def get_status(self): 83 | """Set the class properties to the actual values. 84 | 85 | Properties: 86 | state -- str ('play', 'pause' or 'stop') 87 | artist -- str or None 88 | album -- str or None 89 | title -- str or None 90 | file -- str or None 91 | length -- int (in seconds) or None 92 | """ 93 | raise NotImplementedError('get_status() should be implemented in subclass') 94 | 95 | def send_command(self, command): 96 | """Send a given command to the player. Return True if the command was recognized. 97 | This method don't have to implement all commands. 98 | 99 | Keyword arguments: 100 | command -- a command to send 101 | 102 | Command names: 103 | play -- start playback 104 | pause -- pause playback 105 | next -- next song 106 | prev -- previous song 107 | stop -- stop playback 108 | volup -- volume up 109 | voldn -- volume down 110 | """ 111 | pass 112 | 113 | def cleanup(self): 114 | """Cleanup the player object.""" 115 | pass 116 | 117 | 118 | # Find and import player-specific submodules 119 | mpris = False 120 | players = [] 121 | for _, player, _ in pkgutil.iter_modules(__path__): 122 | try: 123 | importlib.import_module(__name__ + '.' + player) 124 | players.append(player) 125 | except ImportError: 126 | pass 127 | -------------------------------------------------------------------------------- /lyvi/players/cmus.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2013 Ondrej Kipila <ok100 at openmailbox dot org> 2 | # This work is free. You can redistribute it and/or modify it under the 3 | # terms of the Do What The Fuck You Want To Public License, Version 2, 4 | # as published by Sam Hocevar. See the COPYING file for more details. 5 | 6 | """Cmus plugin for Lyvi.""" 7 | 8 | 9 | import os 10 | import subprocess 11 | 12 | from lyvi.players import Player 13 | from lyvi.utils import check_output 14 | 15 | 16 | class Player(Player): 17 | @classmethod 18 | def running(self): 19 | try: 20 | return subprocess.call(['cmus-remote', '-C']) == 0 21 | except OSError: 22 | return False 23 | 24 | def get_status(self): 25 | data = {'artist': None, 'album': None, 'title': None, 'file': None, 'length': None} 26 | 27 | for line in check_output('cmus-remote -Q').splitlines(): 28 | if line.startswith('status '): 29 | data['state'] = line.split()[1].replace('playing', 'play') 30 | for x, y in (('playing', 'play'), ('paused', 'pause'), ('stopped', 'stop')): 31 | data['state'] = data['state'].replace(x, y) 32 | elif line.startswith('tag artist '): 33 | data['artist'] = line.split(maxsplit=2)[2] 34 | elif line.startswith('tag album '): 35 | data['album'] = line.split(maxsplit=2)[2] 36 | elif line.startswith('tag title '): 37 | data['title'] = line.split(maxsplit=2)[2] 38 | elif line.startswith('file '): 39 | data['file'] = line.split(maxsplit=1)[1] 40 | elif line.startswith('duration '): 41 | data['length'] = int(line.split(maxsplit=1)[1]) 42 | 43 | for k in data: 44 | setattr(self, k, data[k]) 45 | 46 | def send_command(self, command): 47 | cmd = { 48 | 'play': 'cmus-remote -p', 49 | 'pause': 'cmus-remote -u', 50 | 'next': 'cmus-remote -n', 51 | 'prev': 'cmus-remote -r', 52 | 'stop': 'cmus-remote -s', 53 | 'volup': 'cmus-remote -v +5', 54 | 'voldn': 'cmus-remote -v -5', 55 | }.get(command) 56 | 57 | if cmd: 58 | os.system(cmd) 59 | return True 60 | -------------------------------------------------------------------------------- /lyvi/players/moc.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2013 Ondrej Kipila <ok100 at openmailbox dot org> 2 | # This work is free. You can redistribute it and/or modify it under the 3 | # terms of the Do What The Fuck You Want To Public License, Version 2, 4 | # as published by Sam Hocevar. See the COPYING file for more details. 5 | 6 | """MOC plugin for Lyvi.""" 7 | 8 | 9 | import os 10 | 11 | from lyvi.players import Player 12 | from lyvi.utils import check_output 13 | 14 | 15 | class Player(Player): 16 | @classmethod 17 | def running(self): 18 | return os.path.exists(os.path.join(os.environ['HOME'], '.moc', 'pid')) 19 | 20 | def get_info_value(self, info_line): 21 | """Extract 'A value' from line 'ValueName: A value'.""" 22 | try: 23 | return info_line.split(maxsplit=1)[1] 24 | except IndexError: 25 | # Empty value. 26 | return None 27 | 28 | def get_status(self): 29 | data = {'artist': None, 'album': None, 'title': None, 'file': None, 'length': None} 30 | 31 | for line in check_output('mocp -i').splitlines(): 32 | info_value = self.get_info_value(line) 33 | if line.startswith('State: '): 34 | data['state'] = info_value.lower() 35 | elif line.startswith('Artist: '): 36 | data['artist'] = info_value or '' 37 | elif line.startswith('Album: '): 38 | data['album'] = info_value or '' 39 | elif line.startswith('SongTitle: '): 40 | data['title'] = info_value or '' 41 | elif line.startswith('File: '): 42 | data['file'] = info_value 43 | elif line.startswith('TotalSec: '): 44 | data['length'] = int(info_value) 45 | 46 | for k in data: 47 | setattr(self, k, data[k]) 48 | 49 | def send_command(self, command): 50 | cmd = { 51 | 'play': 'mocp -U', 52 | 'pause': 'mocp -P', 53 | 'next': 'mocp -f', 54 | 'prev': 'mocp -r', 55 | 'stop': 'mocp -s', 56 | 'volup': 'mocp --volume +5', 57 | 'voldn': 'mocp --volume -5', 58 | }.get(command) 59 | 60 | if cmd: 61 | os.system(cmd) 62 | return True 63 | -------------------------------------------------------------------------------- /lyvi/players/mpd.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2013 Ondrej Kipila <ok100 at openmailbox dot org> 2 | # This work is free. You can redistribute it and/or modify it under the 3 | # terms of the Do What The Fuck You Want To Public License, Version 2, 4 | # as published by Sam Hocevar. See the COPYING file for more details. 5 | 6 | """MPD plugin for Lyvi.""" 7 | 8 | 9 | import os 10 | from telnetlib import Telnet 11 | 12 | import lyvi 13 | from lyvi.players import Player 14 | 15 | 16 | class Player(Player): 17 | @classmethod 18 | def running(self): 19 | try: 20 | Telnet(lyvi.config['mpd_host'], lyvi.config['mpd_port']).close() 21 | return True 22 | except OSError: 23 | return False 24 | 25 | def __init__(self): 26 | """Get a path to the music directory and initialize the telnet connection.""" 27 | self.music_dir = None 28 | if os.path.exists(lyvi.config['mpd_config_file']): 29 | for line in open(lyvi.config['mpd_config_file']): 30 | if line.strip().startswith('music_directory'): 31 | self.music_dir = line.split('"')[1] 32 | self.telnet = Telnet(lyvi.config['mpd_host'], lyvi.config['mpd_port']) 33 | self.telnet.read_until(b'\n') 34 | 35 | def get_status(self): 36 | data = {'artist': None, 'album': None, 'title': None, 'file': None, 'length': None} 37 | 38 | self.telnet.write(b'status\n') 39 | response = self.telnet.read_until(b'OK').decode() 40 | self.telnet.write(b'currentsong\n') 41 | response += self.telnet.read_until(b'OK').decode() 42 | t = { 43 | 'state: ': 'state', 44 | 'Artist: ': 'artist', 45 | 'Title: ': 'title', 46 | 'Album: ': 'album', 47 | 'file: ': 'file', 48 | 'time: ': 'length', 49 | } 50 | for line in response.splitlines(): 51 | for k in t: 52 | if line.startswith(k): 53 | data[t[k]] = line.split(k, 1)[1] 54 | break 55 | data['file'] = os.path.join(self.music_dir, data['file']) if data['file'] and self.music_dir else None 56 | data['length'] = int(data['length'].split(':')[1]) if data['length'] else None 57 | 58 | for k in data: 59 | setattr(self, k, data[k]) 60 | 61 | def send_command(self, command): 62 | cmd = { 63 | 'play': b'play\n', 64 | 'pause': b'pause\n', 65 | 'next': b'next\n', 66 | 'prev': b'previous\n', 67 | 'stop': b'stop\n', 68 | }.get(command) 69 | 70 | if cmd: 71 | self.telnet.write(cmd) 72 | return True 73 | 74 | def cleanup(self): 75 | self.telnet.close() 76 | -------------------------------------------------------------------------------- /lyvi/players/mpg123.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2013 Ondrej Kipila <ok100 at openmailbox dot org> 2 | # This work is free. You can redistribute it and/or modify it under the 3 | # terms of the Do What The Fuck You Want To Public License, Version 2, 4 | # as published by Sam Hocevar. See the COPYING file for more details. 5 | 6 | """Mpg123 plugin for Lyvi.""" 7 | 8 | 9 | import os 10 | 11 | import lyvi 12 | from lyvi.players import Player 13 | from lyvi.utils import running 14 | 15 | 16 | class Player(Player): 17 | LOG_FILE = os.path.join(lyvi.TEMP, 'mpg123.log') 18 | 19 | @classmethod 20 | def running(self): 21 | return running('mpg123') and os.path.exists(self.LOG_FILE) 22 | 23 | def get_status(self): 24 | data = {'artist': None, 'album': None, 'title': None, 'file': None, 'state': 'play', 'length': None} 25 | 26 | with open(self.LOG_FILE) as f: 27 | for line in f.read().splitlines(): 28 | if 'Title:' and 'Artist:' in line: 29 | data['title'], data['artist'] = ( 30 | x.strip() for x in line.split('Title: ')[1].split('Artist: ') 31 | ) 32 | elif 'Album: ' in line: 33 | data['album'] = line.split('Album: ')[1].strip() 34 | 35 | for k in data: 36 | setattr(self, k, data[k]) 37 | -------------------------------------------------------------------------------- /lyvi/players/mplayer.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2013 Ondrej Kipila <ok100 at openmailbox dot org> 2 | # This work is free. You can redistribute it and/or modify it under the 3 | # terms of the Do What The Fuck You Want To Public License, Version 2, 4 | # as published by Sam Hocevar. See the COPYING file for more details. 5 | 6 | """MPlayer/mpv plugin for Lyvi.""" 7 | 8 | 9 | import os 10 | 11 | import lyvi 12 | from lyvi.players import Player 13 | from lyvi.utils import process_fifo, running 14 | 15 | 16 | class Player(Player): 17 | LOG_FILE = os.path.join(lyvi.config['mplayer_config_dir'], 'log') 18 | FIFO = os.path.join(lyvi.config['mplayer_config_dir'], 'fifo') 19 | ID = { 20 | 'ID_CLIP_INFO_VALUE0=': 'title', 21 | 'ID_CLIP_INFO_VALUE1=': 'artist', 22 | 'ID_CLIP_INFO_VALUE3=': 'album', 23 | 'ID_FILENAME=': 'file', 24 | 'ID_LENGTH=': 'length', 25 | } 26 | 27 | @classmethod 28 | def running(self): 29 | return (running('mplayer') or running('mpv')) and os.path.exists(self.LOG_FILE) 30 | 31 | def get_status(self): 32 | data = {'artist': None, 'album': None, 'title': None, 'file': None, 'state': 'play', 'length': None} 33 | 34 | with open(self.LOG_FILE) as f: 35 | for line in f.read().splitlines(): 36 | for i in self.ID: 37 | if i in line: 38 | data[self.ID[i]] = line.split(i)[1] 39 | 40 | if data['length']: 41 | data['length'] = int(data['length'].split('.')[0]) 42 | 43 | for k in data: 44 | setattr(self, k, data[k]) 45 | 46 | def send_command(self, command): 47 | if not os.path.exists(self.FIFO): 48 | return 49 | 50 | cmd = { 51 | 'play': 'pause', 52 | 'pause': 'pause', 53 | 'next': 'pt_step 1', 54 | 'prev': 'pt_step -1', 55 | 'stop': 'stop', 56 | 'volup': 'volume +5', 57 | 'voldn': 'volume -5', 58 | }.get(command) 59 | 60 | if cmd: 61 | process_fifo(self.FIFO, cmd) 62 | return True 63 | -------------------------------------------------------------------------------- /lyvi/players/mpris.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2013 Ondrej Kipila <ok100 at openmailbox dot org> 2 | # This work is free. You can redistribute it and/or modify it under the 3 | # terms of the Do What The Fuck You Want To Public License, Version 2, 4 | # as published by Sam Hocevar. See the COPYING file for more details. 5 | 6 | """MPRIS plugin for Lyvi.""" 7 | 8 | 9 | import dbus 10 | import dbus.exceptions 11 | import dbus.glib 12 | import dbus.mainloop.glib 13 | from gi.repository import GObject 14 | 15 | from lyvi.players import Player 16 | from lyvi.utils import thread 17 | 18 | 19 | # Initialize the DBus loop, required to enable asynchronous dbus calls 20 | dbus.mainloop.glib.DBusGMainLoop(set_as_default=True) 21 | GObject.threads_init() 22 | dbus.glib.init_threads() 23 | loop = GObject.MainLoop() 24 | thread(loop.run) 25 | 26 | 27 | def find(): 28 | """Return the initialized mpris.Player class, otherwise 29 | return None if no player was found.""" 30 | try: 31 | for name in dbus.SessionBus().list_names(): 32 | if name.startswith('org.mpris.MediaPlayer2.'): 33 | return Player(name[len('org.mpris.MediaPlayer2.'):]) 34 | except dbus.exceptions.DBusException: 35 | pass 36 | return None 37 | 38 | 39 | def running(playername): 40 | """Return True if a MPRIS player with the given name is running. 41 | 42 | Keyword arguments: 43 | playername -- mpris player name 44 | """ 45 | try: 46 | bus = dbus.SessionBus() 47 | bus.get_object('org.mpris.MediaPlayer2.%s' % playername, '/org/mpris/MediaPlayer2') 48 | return True 49 | except dbus.exceptions.DBusException: 50 | return False 51 | 52 | 53 | class Player(Player): 54 | """Class which supports all players that implement the MPRIS Interface.""" 55 | def running(self): 56 | return running(self.playername) 57 | 58 | def __init__(self, playername): 59 | """Initialize the player. 60 | 61 | Keyword arguments: 62 | playername -- mpris player name 63 | """ 64 | self.playername = playername 65 | 66 | # Player status cache 67 | self.playerstatus = {} 68 | 69 | # Store the interface in this object, so it does not have to reinitialized each second 70 | # in the main loop 71 | bus = dbus.SessionBus() 72 | playerobject = bus.get_object('org.mpris.MediaPlayer2.' + self.playername, 73 | '/org/mpris/MediaPlayer2') 74 | self.mprisplayer = dbus.Interface(playerobject, 'org.mpris.MediaPlayer2.Player') 75 | self.mprisprops = dbus.Interface(playerobject, 'org.freedesktop.DBus.Properties') 76 | self.mprisprops.connect_to_signal("PropertiesChanged", self.loaddata) 77 | self.loaddata() 78 | 79 | def loaddata(self, *args, **kwargs): 80 | """Retrieve the player status over DBUS. 81 | 82 | Arguments are ignored, but *args and **kwargs enable support the dbus callback. 83 | """ 84 | self.playerstatus = self.mprisprops.GetAll('org.mpris.MediaPlayer2.Player') 85 | 86 | def get_status(self): 87 | data = {'artist': None, 'album': None, 'title': None, 'file': None, 'length': None} 88 | 89 | data['state'] = (self.playerstatus['PlaybackStatus'] 90 | .replace('Stopped', 'stop') 91 | .replace('Playing', 'play') 92 | .replace('Paused', 'pause')) 93 | try: 94 | data['length'] = round(int(self.playerstatus['Metadata']['mpris:length']) / 1000000) 95 | except KeyError: 96 | pass 97 | try: 98 | data['artist'] = self.playerstatus['Metadata']['xesam:artist'][0] 99 | except KeyError: 100 | pass 101 | try: 102 | title = self.playerstatus['Metadata']['xesam:title'] 103 | # According to MPRIS/Xesam, title is a String, but some players seem return an array 104 | data['title'] = title[0] if isinstance(title, dbus.Array) else title 105 | except KeyError: 106 | pass 107 | try: 108 | data['album'] = self.playerstatus['Metadata']['xesam:album'] 109 | except KeyError: 110 | pass 111 | try: 112 | data['file'] = self.playerstatus['Metadata']['xesam:url'].split('file://')[1] 113 | except (KeyError, IndexError): 114 | pass 115 | 116 | for k in data: 117 | setattr(self, k, data[k]) 118 | 119 | def send_command(self, command): 120 | if command == 'volup': 121 | volume = self.playerstatus['Volume'] + 0.1 122 | self.mprisprops.Set('org.mpris.MediaPlayer2.Player', 'Volume', min(volume, 1.0)) 123 | return True 124 | if command == 'voldn': 125 | volume = self.playerstatus['Volume'] - 0.1 126 | self.mprisprops.Set('org.mpris.MediaPlayer2.Player', 'Volume', max(volume, 0.0)) 127 | return True 128 | 129 | cmd = { 130 | 'play': self.mprisplayer.PlayPause, 131 | 'pause': self.mprisplayer.Pause, 132 | 'next': self.mprisplayer.Next, 133 | 'prev': self.mprisplayer.Previous, 134 | 'stop': self.mprisplayer.Stop, 135 | }.get(command) 136 | 137 | if cmd: 138 | try: 139 | cmd() 140 | except dbus.DBusException: 141 | # Some players (rhythmbox) raises DBusException when attempt to 142 | # use "next"/"prev" command on first/last item of the playlist 143 | pass 144 | return True 145 | -------------------------------------------------------------------------------- /lyvi/players/pianobar.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2013 Ondrej Kipila <ok100 at openmailbox dot org> 2 | # This work is free. You can redistribute it and/or modify it under the 3 | # terms of the Do What The Fuck You Want To Public License, Version 2, 4 | # as published by Sam Hocevar. See the COPYING file for more details. 5 | 6 | """Pianobar plugin for Lyvi.""" 7 | 8 | 9 | import os 10 | 11 | from lyvi.players import Player 12 | from lyvi.utils import process_fifo, running 13 | 14 | 15 | class Player(Player): 16 | CONFIG_FILE = os.path.join(os.environ['HOME'], '.config', 'pianobar', 'config') 17 | NOWPLAYING_FILE = os.path.join(os.environ['HOME'], '.config', 'pianobar', 'nowplaying') 18 | FIFO = os.path.join(os.environ['HOME'], '.config', 'pianobar', 'ctl') 19 | # Default control keys 20 | config = { 21 | 'act_songpausetoggle': 'p', 22 | 'act_songnext': 'n', 23 | 'act_volup': ')', 24 | 'act_voldown': '(', 25 | } 26 | 27 | @classmethod 28 | def running(self): 29 | return running('pianobar') and os.path.exists(self.NOWPLAYING_FILE) 30 | 31 | def __init__(self): 32 | """Get the actual control keys from the pianobar configuration file 33 | so we can send the right commands to the fifo.""" 34 | with open(self.CONFIG_FILE) as f: 35 | for line in f.read().splitlines(): 36 | if not line.strip().startswith('#') and line.split('=')[0].strip() in self.config: 37 | self.config[line.split('=')[0].strip()] = line.split('=')[1].strip() 38 | 39 | def get_status(self): 40 | data = {'artist': None, 'album': None, 'title': None, 'file': None, 'state': 'play'} 41 | 42 | with open(self.NOWPLAYING_FILE) as f: 43 | data['artist'], data['title'], data['album'] = f.read().split('|') 44 | 45 | for k in data: 46 | setattr(self, k, data[k]) 47 | 48 | def send_command(self, command): 49 | if not os.path.exists(self.FIFO): 50 | return 51 | 52 | cmd = { 53 | 'play': 'act_songpausetoggle', 54 | 'pause': 'act_songpausetoggle', 55 | 'next': 'act_songnext', 56 | 'volup': 'act_volup', 57 | 'voldn': 'act_voldown', 58 | }.get(command) 59 | 60 | if cmd: 61 | process_fifo(self.FIFO, self.config[cmd]) 62 | return True 63 | -------------------------------------------------------------------------------- /lyvi/players/shell-fm.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2013 Ondrej Kipila <ok100 at openmailbox dot org> 2 | # This work is free. You can redistribute it and/or modify it under the 3 | # terms of the Do What The Fuck You Want To Public License, Version 2, 4 | # as published by Sam Hocevar. See the COPYING file for more details. 5 | 6 | """Shell-fm plugin for Lyvi.""" 7 | 8 | 9 | import os 10 | 11 | from lyvi.players import Player 12 | from lyvi.utils import process_socket, running 13 | 14 | 15 | class Player(Player): 16 | NOWPLAYING_FILE = os.path.join(os.environ['HOME'], '.shell-fm', 'nowplaying') 17 | SOCKET = os.path.join(os.environ['HOME'], '.shell-fm', 'socket') 18 | 19 | @classmethod 20 | def running(self): 21 | return running('shell-fm') and os.path.exists(self.NOWPLAYING_FILE) 22 | 23 | def get_status(self): 24 | data = {'artist': None, 'album': None, 'title': None, 'file': None, 'state': 'stop'} 25 | 26 | with open(self.NOWPLAYING_FILE) as f: 27 | data['artist'], data['title'], data['album'], data['state'] = f.read().split('|') 28 | for x, y in ( 29 | ('PLAYING', 'play'), 30 | ('PAUSED', 'pause'), 31 | ('STOPPED', 'stop') 32 | ): 33 | data['state'] = data['state'].replace(x, y) 34 | 35 | for k in data: 36 | setattr(self, k, data[k]) 37 | 38 | def send_command(self, command): 39 | if not os.path.exists(self.SOCKET): 40 | return 41 | 42 | cmd = { 43 | 'pause': 'pause', 44 | 'next': 'skip', 45 | 'stop': 'stop', 46 | 'volup': 'volume +5', 47 | 'voldn': 'volume -5', 48 | }.get(command) 49 | 50 | if cmd: 51 | process_socket(self.SOCKET, cmd) 52 | return True 53 | -------------------------------------------------------------------------------- /lyvi/players/xmms2.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2013 Ondrej Kipila <ok100 at openmailbox dot org> 2 | # This work is free. You can redistribute it and/or modify it under the 3 | # terms of the Do What The Fuck You Want To Public License, Version 2, 4 | # as published by Sam Hocevar. See the COPYING file for more details. 5 | 6 | """Xmms2 plugin for Lyvi.""" 7 | 8 | 9 | import os 10 | from urllib.parse import unquote_plus 11 | 12 | from lyvi.players import Player 13 | from lyvi.utils import running, check_output 14 | 15 | 16 | class Player(Player): 17 | @classmethod 18 | def running(self): 19 | return running('xmms2d') 20 | 21 | def get_status(self): 22 | data = {'artist': None, 'album': None, 'title': None, 'file': None, 'state': 'play', 'length': None} 23 | try: 24 | data['state'], data['artist'], data['album'], data['title'], data['file'], data['length'] = \ 25 | check_output('xmms2 current -f \'${playback_status}|${artist}|${album}|${title}|${url}|${duration}\'').split('|') 26 | except ValueError: 27 | return 28 | 29 | for x, y in (('Playing', 'play'), ('Paused', 'pause'), ('Stopped', 'stop')): 30 | data['state'] = data['state'].replace(x, y) 31 | 32 | # unquote_plus replaces % not as plus signs but as spaces (url decode) 33 | data['file'] = unquote_plus(data['file']).strip() 34 | for x, y in (('\'', ''), ('file://', '')): 35 | data['file'] = data['file'].replace(x, y) 36 | 37 | try: 38 | data['length'] = int(data['length'].split(':')[0]) * 60 + int(data['length'].split(':')[1]) 39 | except ValueError: 40 | data['length'] = None 41 | 42 | for k in data: 43 | setattr(self, k, data[k]) 44 | 45 | def send_command(self, command): 46 | cmd = { 47 | 'play': 'xmms2 play', 48 | 'pause': 'xmms2 pause', 49 | 'next': 'xmms2 jump +1', 50 | 'prev': 'xmms2 jump -1', 51 | 'stop': 'xmms2 stop', 52 | 'volup': 'xmms2 server volume +5', 53 | 'voldn': 'xmms2 server volume -5', 54 | }.get(command) 55 | 56 | if cmd: 57 | os.system(cmd) 58 | return True 59 | -------------------------------------------------------------------------------- /lyvi/tui.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2013 Ondrej Kipila <ok100 at openmailbox dot org> 2 | # This work is free. You can redistribute it and/or modify it under the 3 | # terms of the Do What The Fuck You Want To Public License, Version 2, 4 | # as published by Sam Hocevar. See the COPYING file for more details. 5 | 6 | """Curses user interface.""" 7 | 8 | import sys 9 | from math import ceil 10 | from time import sleep 11 | from threading import Thread, Event 12 | 13 | import urwid 14 | 15 | import lyvi 16 | 17 | 18 | class VimListBox(urwid.ListBox): 19 | """A ListBox subclass which provides vim-like and mouse scrolling. 20 | 21 | Additional properties: 22 | size -- a tuple (width, height) of the listbox dimensions 23 | total_lines -- total number of lines 24 | pos -- a string containing vim-like scroll position indicator 25 | 26 | Additional signals: 27 | changed -- emited when the listbox content changes 28 | """ 29 | signals = ['changed'] 30 | 31 | def mouse_event(self, size, event, button, col, row, focus): 32 | """Overrides ListBox.mouse_event method. 33 | 34 | Implements mouse scrolling. 35 | """ 36 | if event == 'mouse press': 37 | if button == 4: 38 | for _ in range(3): 39 | self.keypress(size, 'up') 40 | return True 41 | if button == 5: 42 | for _ in range(3): 43 | self.keypress(size, 'down') 44 | return True 45 | return self.__super.mouse_event(size, event, button, col, row, focus) 46 | 47 | def keypress(self, size, key): 48 | """Overrides ListBox.keypress method. 49 | 50 | Implements vim-like scrolling. 51 | """ 52 | if key == 'j': 53 | self.keypress(size, 'down') 54 | return True 55 | if key == 'k': 56 | self.keypress(size, 'up') 57 | return True 58 | if key == 'g': 59 | self.set_focus(0) 60 | return True 61 | if key == 'G': 62 | self.set_focus(len(self.body) - 1) 63 | self.set_focus_valign('bottom') 64 | return True 65 | return self.__super.keypress(size, key) 66 | 67 | def calculate_visible(self, size, focus=False): 68 | """Overrides ListBox.calculate_visible method. 69 | 70 | Calculates the scroll position (like in vim). 71 | """ 72 | self.size = size 73 | width, height = size 74 | middle, top, bottom = self.__super.calculate_visible(self.size, focus) 75 | fpos = self.body.index(top[1][-1][0]) if top[1] else self.focus_position 76 | top_line = sum([self.body[n].rows((width,)) for n in range(0, fpos)]) + top[0] 77 | self.total_lines = sum([widget.rows((width,)) for widget in self.body]) 78 | if self.total_lines <= height: 79 | self.pos = 'All' 80 | elif top_line == 0: 81 | self.pos = 'Top' 82 | elif top_line + height == self.total_lines: 83 | self.pos = 'Bot' 84 | else: 85 | self.pos = '%d%%' % round(top_line * 100 / (self.total_lines - height)) 86 | self._emit('changed') 87 | return middle, top, bottom 88 | 89 | 90 | class Autoscroll(Thread): 91 | """A Thread subclass that implements autoscroll timer.""" 92 | def __init__(self, widget): 93 | """Initialize the class.""" 94 | super().__init__() 95 | self.daemon = True 96 | self.widget = widget 97 | self.event = Event() 98 | 99 | def _can_scroll(self): 100 | """Return True if we can autoscroll.""" 101 | return (lyvi.player.length and lyvi.player.state == 'play' and lyvi.ui.view == 'lyrics' 102 | and not lyvi.ui.hidden and self.widget.pos not in ('All', 'Bot')) 103 | 104 | def run(self): 105 | """Start the timer.""" 106 | while True: 107 | if self._can_scroll(): 108 | time = ceil(lyvi.player.length / (self.widget.total_lines - self.widget.size[1])) 109 | reset = False 110 | for _ in range(time): 111 | if self.event.wait(1): 112 | reset = True 113 | self.event.clear() 114 | break 115 | if not reset and self._can_scroll(): 116 | self.widget.keypress(self.widget.size, 'down') 117 | else: 118 | sleep(1) 119 | 120 | def reset(self): 121 | """Reset the timer.""" 122 | self.event.set() 123 | 124 | 125 | class Ui: 126 | """Main UI class. 127 | 128 | Attributes: 129 | view -- current view 130 | hidden -- whether the UI is hidden 131 | quit -- stop the mainloop if this flag is set to True 132 | """ 133 | view = lyvi.config['default_view'] 134 | hidden = lyvi.config['ui_hidden'] 135 | _header = '' 136 | _text = '' 137 | quit = False 138 | 139 | @property 140 | def header(self): 141 | """Header text.""" 142 | return self._header 143 | 144 | @header.setter 145 | def header(self, value): 146 | self._header = value 147 | self.head.set_text(('header', self.header if not self.hidden else '')) 148 | self._refresh() 149 | 150 | @property 151 | def text(self): 152 | """The main text.""" 153 | return self._text 154 | 155 | @text.setter 156 | def text(self, value): 157 | self._text = value 158 | lines = [] 159 | for line in self.text.splitlines(): 160 | lines.append(urwid.Text(('content', line if not self.hidden else ''))) 161 | self.content[:] = [self.head, urwid.Divider()] + lines 162 | self._refresh() 163 | 164 | def init(self): 165 | """Initialize the class.""" 166 | palette = [ 167 | ('header', lyvi.config['header_fg'], lyvi.config['header_bg']), 168 | ('content', lyvi.config['text_fg'], lyvi.config['text_bg']), 169 | ('statusbar', lyvi.config['statusbar_fg'], lyvi.config['statusbar_bg']), 170 | ] 171 | 172 | self.head = urwid.Text(('header', '')) 173 | self.statusbar = urwid.AttrMap(urwid.Text('', align='right'), 'statusbar') 174 | self.content = urwid.SimpleListWalker([urwid.Text(('content', ''))]) 175 | self.listbox = VimListBox(self.content) 176 | self.frame = urwid.Frame(urwid.Padding(self.listbox, left=1, right=1), footer=self.statusbar) 177 | self.loop = urwid.MainLoop(self.frame, palette, unhandled_input=self.input) 178 | self.autoscroll = Autoscroll(self.listbox) if lyvi.config['autoscroll'] else None 179 | 180 | if self.autoscroll: 181 | self.autoscroll.start() 182 | urwid.connect_signal(self.listbox, 'changed', self.update_statusbar) 183 | self._set_alarm() 184 | 185 | def update(self): 186 | """Update the listbox content.""" 187 | if lyvi.player.state == 'stop': 188 | self.header = 'N/A' if self.view == 'artistbio' else 'N/A - N/A' 189 | self.text = 'Not playing' 190 | elif self.view == 'lyrics': 191 | self.header = '%s - %s' % (lyvi.md.artist or 'N/A', lyvi.md.title or 'N/A') 192 | self.text = lyvi.md.lyrics or 'No lyrics found' 193 | elif self.view == 'artistbio': 194 | self.header = lyvi.md.artist or 'N/A' 195 | self.text = lyvi.md.artistbio or 'No artist info found' 196 | elif self.view == 'guitartabs': 197 | self.header = '%s - %s' % (lyvi.md.artist or 'N/A', lyvi.md.title or 'N/A') 198 | self.text = lyvi.md.guitartabs or 'No guitar tabs found' 199 | sys.stdout.write('\x1b]2;{}\x07'.format(self.header)) 200 | 201 | def home(self): 202 | """Scroll to the top of the current view.""" 203 | self.listbox.set_focus(0) 204 | self._refresh() 205 | 206 | def update_statusbar(self, _=None): 207 | """Update the statusbar. 208 | 209 | Arguments are ignored, but enable support for urwid signal callback. 210 | """ 211 | if not self.hidden: 212 | text = urwid.Text(self.view + self.listbox.pos.rjust(10), align='right') 213 | wrap = urwid.AttrWrap(text, 'statusbar') 214 | self.frame.set_footer(wrap) 215 | 216 | def toggle_views(self): 217 | """Toggle between views.""" 218 | if not self.hidden: 219 | views = ['lyrics', 'artistbio', 'guitartabs'] 220 | n = views.index(self.view) 221 | self.view = views[n + 1] if n < len(views) - 1 else views[0] 222 | self.home() 223 | self.update() 224 | 225 | def toggle_visibility(self): 226 | """Toggle UI visibility.""" 227 | if lyvi.bg: 228 | if not self.hidden: 229 | self.header = '' 230 | self.text = '' 231 | self.frame.set_footer(urwid.AttrWrap(urwid.Text(''), 'statusbar')) 232 | lyvi.bg.opacity = 1.0 233 | lyvi.bg.update() 234 | self.hidden = True 235 | else: 236 | self.hidden = False 237 | lyvi.bg.opacity = lyvi.config['bg_opacity'] 238 | lyvi.bg.update() 239 | self.update() 240 | 241 | def reload(self, type): 242 | """Reload metadata for current view.""" 243 | from lyvi.utils import thread 244 | import lyvi.metadata 245 | lyvi.md.delete(type, lyvi.md.artist, lyvi.md.title, lyvi.md.album) 246 | thread(lyvi.md.get, (type,)) 247 | 248 | def input(self, key): 249 | """Process input not handled by any widget.""" 250 | if key == lyvi.config['key_quit']: 251 | lyvi.exit() 252 | elif key == lyvi.config['key_toggle_views']: 253 | self.toggle_views() 254 | elif key == lyvi.config['key_reload_view']: 255 | self.reload(self.view) 256 | elif key == lyvi.config['key_reload_bg'] and lyvi.bg: 257 | self.reload(lyvi.bg.type) 258 | elif key == lyvi.config['key_toggle_bg_type'] and lyvi.bg: 259 | lyvi.bg.toggle_type() 260 | self.update() 261 | elif key == lyvi.config['key_toggle_ui']: 262 | self.toggle_visibility() 263 | 264 | def mainloop(self): 265 | """Start the mainloop.""" 266 | self.loop.run() 267 | 268 | def _set_alarm(self): 269 | """Set the alarm for _check_exit.""" 270 | self.loop.event_loop.alarm(0.5, self._check_exit) 271 | 272 | def _check_exit(self): 273 | """Stop the mainloop if the quit property is True.""" 274 | self._set_alarm() 275 | if self.quit: 276 | raise urwid.ExitMainLoop() 277 | 278 | def _refresh(self): 279 | """Redraw the screen.""" 280 | self.loop.draw_screen() 281 | -------------------------------------------------------------------------------- /lyvi/utils.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2013 Ondrej Kipila <ok100 at openmailbox dot org> 2 | # This work is free. You can redistribute it and/or modify it under the 3 | # terms of the Do What The Fuck You Want To Public License, Version 2, 4 | # as published by Sam Hocevar. See the COPYING file for more details. 5 | 6 | """Common functions used across the whole package.""" 7 | 8 | 9 | import socket 10 | import subprocess as sp 11 | from threading import Thread 12 | 13 | from psutil import process_iter 14 | 15 | 16 | def check_output(command): 17 | """Return an output of the given command.""" 18 | try: 19 | return sp.check_output(command, shell=True, stderr=sp.DEVNULL).decode() 20 | except sp.CalledProcessError: 21 | return '' 22 | 23 | 24 | def process_fifo(file, command): 25 | """Send a command to the given fifo. 26 | 27 | Keyword arguments: 28 | file -- the path to the fifo file 29 | command -- the command without newline character at the end 30 | """ 31 | with open(file, 'w') as f: 32 | f.write(command + '\n') 33 | 34 | 35 | def process_socket(sock, command): 36 | """Send a command to the given socket. 37 | 38 | Keyword arguments: 39 | file -- the path to the socket 40 | command -- the command without newline character at the end 41 | """ 42 | with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s: 43 | s.connect(sock) 44 | s.send((command + '\n').encode()) 45 | 46 | 47 | def running(process_name): 48 | """Return True if the given process is running, otherwise return False. 49 | 50 | Keyword arguments: 51 | process_name -- the name of the process 52 | """ 53 | for p in process_iter(): 54 | if p.name() == process_name: 55 | return True 56 | return False 57 | 58 | 59 | def thread(target, args=()): 60 | """Run the given callable object in a new daemon thread. 61 | 62 | Keyword arguments: 63 | target -- the target object 64 | args -- a tuple of arguments to be passed to the target object 65 | """ 66 | w = Thread(target=target, args=args) 67 | w.daemon = True 68 | w.start() 69 | -------------------------------------------------------------------------------- /pip_requirements.txt: -------------------------------------------------------------------------------- 1 | # PIP requirements file. 2 | # See here for exact syntax and possible options: 3 | # http://www.pip-installer.org/en/latest/cookbook.html 4 | Pillow 5 | plyr 6 | urwid 7 | psutil 8 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from setuptools import setup 3 | try: # For pip >= 10 4 | from pip._internal.req import parse_requirements 5 | except ImportError: # For pip < 10 6 | from pip.req import parse_requirements 7 | 8 | 9 | # parse_requirements() returns generator of pip.req.InstallRequirement objects 10 | install_reqs = parse_requirements('pip_requirements.txt', session=uuid.uuid1()) 11 | 12 | 13 | setup( 14 | name='Lyvi', 15 | version='2.0-git', 16 | description='Command-line lyrics (and more!) viewer', 17 | long_description=open('README.rst').read(), 18 | url='http://ok100.github.io/lyvi/', 19 | author='Ondrej Kipila', 20 | author_email='ok100@openmailbox.org', 21 | license='WTFPL', 22 | packages=['lyvi', 'lyvi.players'], 23 | entry_points={ 24 | 'console_scripts': [ 25 | 'lyvi = lyvi:main' 26 | ] 27 | }, 28 | install_requires=[str(ir.req) for ir in install_reqs], 29 | package_data={'lyvi': ['data/pianobar/*']}, 30 | data_files=[('share/man/man1', ['doc/lyvi.1'])] 31 | ) 32 | 33 | print('To enable MPRIS support, please make sure to have python-dbus and python-gobject modules installed.') 34 | --------------------------------------------------------------------------------