├── .gitignore ├── LICENSE ├── Pipfile ├── Pipfile.lock ├── README.md ├── no_cover.jpg ├── notifications.py ├── pSub.py └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Python template 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | env/ 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | wheels/ 26 | *.egg-info/ 27 | .installed.cfg 28 | *.egg 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *,cover 49 | .hypothesis/ 50 | 51 | # Translations 52 | *.mo 53 | *.pot 54 | 55 | # Django stuff: 56 | *.log 57 | local_settings.py 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # dotenv 85 | .env 86 | 87 | # virtualenv 88 | .venv 89 | venv/ 90 | ENV/ 91 | ve/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | 96 | # Rope project settings 97 | .ropeproject 98 | .idea/ 99 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 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 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.python.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [dev-packages] 7 | 8 | [requires] 9 | python_version = "3.8" 10 | 11 | [pipenv] 12 | allow_prereleases = true 13 | 14 | [packages.pSub] 15 | editable = true 16 | path = "." 17 | 18 | [packages.e1839a8] 19 | path = "." 20 | editable = true 21 | -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "077f4059c825866d3e301b3bf30c3da98b2717dbb994b03100eb110757e4dbb1" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": { 8 | "python_version": "3.8" 9 | }, 10 | "sources": [ 11 | { 12 | "name": "pypi", 13 | "url": "https://pypi.python.org/simple", 14 | "verify_ssl": true 15 | } 16 | ] 17 | }, 18 | "default": { 19 | "certifi": { 20 | "hashes": [ 21 | "sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872", 22 | "sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569" 23 | ], 24 | "version": "==2021.10.8" 25 | }, 26 | "charset-normalizer": { 27 | "hashes": [ 28 | "sha256:876d180e9d7432c5d1dfd4c5d26b72f099d503e8fcc0feb7532c9289be60fcbd", 29 | "sha256:cb957888737fc0bbcd78e3df769addb41fd1ff8cf950dc9e7ad7793f1bf44455" 30 | ], 31 | "markers": "python_version >= '3'", 32 | "version": "==2.0.10" 33 | }, 34 | "click": { 35 | "hashes": [ 36 | "sha256:353f466495adaeb40b6b5f592f9f91cb22372351c84caeb068132442a4518ef3", 37 | "sha256:410e932b050f5eed773c4cda94de75971c89cdb3155a72a0831139a79e5ecb5b" 38 | ], 39 | "version": "==8.0.3" 40 | }, 41 | "colorama": { 42 | "hashes": [ 43 | "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b", 44 | "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2" 45 | ], 46 | "version": "==0.4.4" 47 | }, 48 | "e1839a8": { 49 | "editable": true, 50 | "path": "." 51 | }, 52 | "idna": { 53 | "hashes": [ 54 | "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff", 55 | "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d" 56 | ], 57 | "markers": "python_version >= '3'", 58 | "version": "==3.3" 59 | }, 60 | "packaging": { 61 | "hashes": [ 62 | "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb", 63 | "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522" 64 | ], 65 | "version": "==21.3" 66 | }, 67 | "prompt-toolkit": { 68 | "hashes": [ 69 | "sha256:1bb05628c7d87b645974a1bad3f17612be0c29fa39af9f7688030163f680bad6", 70 | "sha256:e56f2ff799bacecd3e88165b1e2f5ebf9bcd59e80e06d395fa0cc4b8bd7bb506" 71 | ], 72 | "version": "==3.0.24" 73 | }, 74 | "psub": { 75 | "editable": true, 76 | "path": "." 77 | }, 78 | "pycairo": { 79 | "hashes": [ 80 | "sha256:0d7a6754d410d911a46f00396bee4be96500ccd3d178e7e98aef1140e3dd67ae", 81 | "sha256:1ee72b035b21a475e1ed648e26541b04e5d7e753d75ca79de8c583b25785531b", 82 | "sha256:261c69850d4b2ec03346c9745bad2a835bb8124e4c6961b8ceac503d744eb3b3", 83 | "sha256:5525da2d8de912750dd157752aa96f1f0a42a437c5625e85b14c936b5c6305ae", 84 | "sha256:6db823a18e7be1eb2a29c28961f2f01e84d3b449f06be7338d05ac8f90592cd5", 85 | "sha256:736ffc618e851601e861a630293e5c910ef016b83b2d035a336f83a367bf56ab", 86 | "sha256:9a32e4a3574a104aa876c35d5e71485dfd6986b18d045534c6ec510c44d5d6a7", 87 | "sha256:b605151cdd23cedb31855b8666371b6e26b80f02753a52c8b8023a916b1df812", 88 | "sha256:c8c2bb933974d91c5d19e54b846d964de177e7bf33433bf34ac34c85f9b30e94", 89 | "sha256:e800486b51fffeb11ed867b4f2220d446e2a60a81a73b7c377123e0cbb72f49d", 90 | "sha256:f123d3818e30b77b7209d70a6dcfd5b4e34885f9fa539d92dd7ff3e4e2037213" 91 | ], 92 | "version": "==1.20.1" 93 | }, 94 | "pygobject": { 95 | "hashes": [ 96 | "sha256:b9803991ec0b0b4175e81fee0ad46090fa7af438fe169348a9b18ae53447afcd" 97 | ], 98 | "version": "==3.42.0" 99 | }, 100 | "pyparsing": { 101 | "hashes": [ 102 | "sha256:04ff808a5b90911829c55c4e26f75fa5ca8a2f5f36aa3a51f68e27033341d3e4", 103 | "sha256:d9bdec0013ef1eb5a84ab39a3b3868911598afa494f5faa038647101504e2b81" 104 | ], 105 | "version": "==3.0.6" 106 | }, 107 | "pyyaml": { 108 | "hashes": [ 109 | "sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293", 110 | "sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b", 111 | "sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57", 112 | "sha256:0b4624f379dab24d3725ffde76559cff63d9ec94e1736b556dacdfebe5ab6d4b", 113 | "sha256:0ce82d761c532fe4ec3f87fc45688bdd3a4c1dc5e0b4a19814b9009a29baefd4", 114 | "sha256:1e4747bc279b4f613a09eb64bba2ba602d8a6664c6ce6396a4d0cd413a50ce07", 115 | "sha256:213c60cd50106436cc818accf5baa1aba61c0189ff610f64f4a3e8c6726218ba", 116 | "sha256:231710d57adfd809ef5d34183b8ed1eeae3f76459c18fb4a0b373ad56bedcdd9", 117 | "sha256:277a0ef2981ca40581a47093e9e2d13b3f1fbbeffae064c1d21bfceba2030287", 118 | "sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513", 119 | "sha256:40527857252b61eacd1d9af500c3337ba8deb8fc298940291486c465c8b46ec0", 120 | "sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0", 121 | "sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92", 122 | "sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f", 123 | "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2", 124 | "sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc", 125 | "sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c", 126 | "sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86", 127 | "sha256:98c4d36e99714e55cfbaaee6dd5badbc9a1ec339ebfc3b1f52e293aee6bb71a4", 128 | "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c", 129 | "sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34", 130 | "sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b", 131 | "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c", 132 | "sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb", 133 | "sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737", 134 | "sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3", 135 | "sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d", 136 | "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53", 137 | "sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78", 138 | "sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803", 139 | "sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a", 140 | "sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174", 141 | "sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5" 142 | ], 143 | "version": "==6.0" 144 | }, 145 | "questionary": { 146 | "hashes": [ 147 | "sha256:600d3aefecce26d48d97eee936fdb66e4bc27f934c3ab6dd1e292c4f43946d90", 148 | "sha256:fecfcc8cca110fda9d561cb83f1e97ecbb93c613ff857f655818839dac74ce90" 149 | ], 150 | "version": "==1.10.0" 151 | }, 152 | "requests": { 153 | "hashes": [ 154 | "sha256:68d7c56fd5a8999887728ef304a6d12edc7be74f1cfa47714fc8b414525c9a61", 155 | "sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d" 156 | ], 157 | "version": "==2.27.1" 158 | }, 159 | "urllib3": { 160 | "hashes": [ 161 | "sha256:000ca7f471a233c2251c6c7023ee85305721bfdf18621ebff4fd17a8653427ed", 162 | "sha256:0e7c33d9a63e7ddfcb86780aac87befc2fbddf46c58dbb487e0855f7ceec283c" 163 | ], 164 | "version": "==1.26.8" 165 | }, 166 | "wcwidth": { 167 | "hashes": [ 168 | "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784", 169 | "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83" 170 | ], 171 | "version": "==0.2.5" 172 | } 173 | }, 174 | "develop": {} 175 | } 176 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ``` 2 | _________ ___. 3 | ______ / _____/__ _\_ |__ 4 | \____ \\_____ \| | \ __ \ 5 | | |_> > \ | / \_\ \ 6 | | __/_______ /____/|___ / 7 | |__| \/ \/ 8 | 9 | ``` 10 | ## CLI Subsonic Client 11 | 12 | I was looking for a way to play music from my [Subsonic](https://subsonic.org) server without needing a whole browser open and thought that a CLI tool might be fun. 13 | After a quick search I didn't find what I was after so I decided to build something. 14 | 15 | pSub is intended to be very simple and focus just on playing music easily. Don't expect to be able to access advanced configuration of a Subsonic server or playlist management. 16 | 17 | pSub is written in Python (written with 3.5 but 2.7 should work) using [Click](http://click.pocoo.org/6/) to build the CLI and [Requests](http://docs.python-requests.org) to handle the communication with the Subsonic API. 18 | It should run on most operating systems too but this hasn't been tested. 19 | 20 | 21 | #### Installation 22 | ##### Dependencies 23 | pSub uses [ffplay](https://ffmpeg.org/ffplay.html) to handle the streaming of music so that needs to be installed and available as a command line executable before using pSub. (you'll be prompted to download ffplay if pSub can't launch it correctly) 24 | 25 | Python3.8 and pipenv need to be installed 26 | 27 | For compiling, some additional dependencies are required (install them with your system package manager; atpt, yum, pacman etc.): 28 | The package names vary by distribution: 29 | 30 | * Fedora, CentOS, RHEL, etc.: gobject-introspection-devel cairo-devel pkg-config python3-devel 31 | * Debian, Ubuntu, Mint, etc.: libgirepository1.0-dev libcairo2-dev pkg-config python3-dev 32 | * Arch: gobject-introspection cairo pkgconf 33 | 34 | The dependencies are [PyGObject](https://pygobject.readthedocs.io/) and [Pycairo](https://pycairo.readthedocs.io/). 35 | See their websites for more info if the instruction above are out of date. 36 | 37 | 38 | ##### Instructions 39 | - Clone this repo 40 | `git clone github.com/inuitwallet/psub.git` 41 | - Enter the pSub directory 42 | `cd psub` 43 | - Sync the dependencies 44 | `pipenv sync` 45 | - Copy the psub binary to `/usr/bin` to allow for running pSub from any other directory 46 | `sudo cp $(pipenv --venv)/bin/pSub /usr/bin/psub` 47 | - Run pSub 48 | `psub` 49 | 50 | #### Usage 51 | On first run you will be prompted to edit your config file. pSub will install a default config file and then open it for editing in your default text editor. You need to specify the url, username and password of your Subsonic server at a minimum. 52 | There are also some settings for adjusting your playback options. The settings are all described in detail in the config file itself. 53 | pSub will run a connection test once your config been saved to make sure it can communicate correctly with Subsonic. 54 | You can edit your config or run the connection test at any time with the -c and -t command line flags. 55 | 56 | Once pSub is properly configured, you can start playing music by running any of the commands shown below. 57 | ``` 58 | Usage: pSub [OPTIONS] COMMAND [ARGS]... 59 | 60 | Options: 61 | -c, --config Edit the config file 62 | -t, --test Test the server configuration 63 | -h, --help Show this message and exit. 64 | 65 | Commands: 66 | album Play songs from chosen Album 67 | artist Play songs from chosen Artist 68 | playlist Play a chosen playlist 69 | radio Play endless Radio based on a search 70 | random Play random tracks 71 | ``` 72 | 73 | Here are some animations of the commands in action: 74 | `psub album` (functions involving a search will accept `*` as a wildcard) 75 | ![](https://github.com/inuitwallet/psub/blob/images/album.gif) 76 | `psub artist` (the `-r` flag indicates that tracks should be played back in a random order) 77 | ![](https://github.com/inuitwallet/psub/blob/images/artist.gif) 78 | `psub playlist` (playlist must exist on the Subsonic server first) 79 | ![](https://github.com/inuitwallet/psub/blob/images/playlist.gif) 80 | `psub radio` 81 | ![](https://github.com/inuitwallet/psub/blob/images/radio.gif) 82 | `psub random` 83 | ![](https://github.com/inuitwallet/psub/blob/images/random.gif) 84 | -------------------------------------------------------------------------------- /no_cover.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mj2p/psub/03f107aebcdbfc6cfb493b16148fbf079e39d31e/no_cover.jpg -------------------------------------------------------------------------------- /notifications.py: -------------------------------------------------------------------------------- 1 | import gi 2 | import requests 3 | import os 4 | gi.require_version('Notify', '0.7') 5 | from gi.repository import Notify, GdkPixbuf 6 | 7 | 8 | class Notifications(object): 9 | def __init__(self, psub): 10 | Notify.init("pSub") 11 | self.psub = psub 12 | 13 | def get_cover_art(self, track_data): 14 | cover_url = self.psub.create_url('getCoverArt') 15 | if track_data.get('coverArt') is not None: 16 | r = requests.get( 17 | '{}&id={}&size=128'.format(cover_url, track_data.get('coverArt')), 18 | verify=self.psub.verify_ssl 19 | ) 20 | cover = r.content 21 | else: 22 | c = open(os.path.join(os.path.dirname(os.path.realpath(__file__)), 'no_cover.jpg'), 'rb') 23 | cover = c.read() 24 | 25 | open('/tmp/art.jpg', 'wb').write(cover) 26 | 27 | @staticmethod 28 | def show_notification(track_data): 29 | GdkPixbuf.Pixbuf.new_from_file('/tmp/art.jpg') 30 | notification = Notify.Notification.new(track_data.get('artist'), track_data.get('title')) 31 | cover_art = GdkPixbuf.Pixbuf.new_from_file("/tmp/art.jpg") 32 | notification.set_image_from_pixbuf(cover_art) 33 | notification.show() 34 | -------------------------------------------------------------------------------- /pSub.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import os 3 | import string 4 | import sys 5 | import time 6 | from random import SystemRandom, shuffle 7 | from subprocess import CalledProcessError, Popen 8 | from threading import Thread 9 | from typing import Dict, List, Union 10 | 11 | import questionary 12 | import requests 13 | from click import UsageError 14 | from packaging import version 15 | 16 | from queue import LifoQueue 17 | 18 | import click 19 | import yaml 20 | import urllib3 21 | urllib3.disable_warnings() 22 | 23 | 24 | class pSub(object): 25 | """ 26 | pSub Object interfaces with the Subsonic server and handles streaming media 27 | """ 28 | def __init__(self, config): 29 | """ 30 | Load the config, creating it if it doesn't exist. 31 | Test server connection 32 | Start background thread for getting user input during streaming 33 | :param config: path to config yaml file 34 | """ 35 | # If no config file exists we should create one and 36 | if not os.path.isfile(config): 37 | self.set_default_config(config) 38 | click.secho('Welcome to pSub', fg='green') 39 | click.secho('To get set up, please edit your config file', fg='red') 40 | click.pause() 41 | click.edit(filename=config) 42 | 43 | # load the config file 44 | with open(config) as config_file: 45 | config = yaml.safe_load(config_file) 46 | 47 | # Get the Server Config 48 | server_config = config.get('server', {}) 49 | self.host = server_config.get('host') 50 | self.username = server_config.get('username', '') 51 | self.password = server_config.get('password', '') 52 | self.api = server_config.get('api', '1.16.0') 53 | self.ssl = server_config.get('ssl', False) 54 | self.verify_ssl = server_config.get('verify_ssl', True) 55 | 56 | # internal variables 57 | self.search_results = [] 58 | 59 | # get the streaming config 60 | streaming_config = config.get('streaming', {}) 61 | self.format = streaming_config.get('format', 'raw') 62 | self.display = streaming_config.get('display', False) 63 | self.show_mode = streaming_config.get('show_mode', 0) 64 | self.invert_random = streaming_config.get('invert_random', False) 65 | self.notify = streaming_config.get('notify', True) 66 | 67 | if self.notify: 68 | import notifications 69 | self.notifications = notifications.Notifications(self) 70 | 71 | # use a Queue to handle command input while a file is playing. 72 | # set the thread going now 73 | self.input_queue = LifoQueue() 74 | input_thread = Thread(target=self.add_input) 75 | input_thread.daemon = True 76 | input_thread.start() 77 | 78 | # remove the lock file if one exists 79 | if os.path.isfile(os.path.join(click.get_app_dir('pSub'), 'play.lock')): 80 | os.remove(os.path.join(click.get_app_dir('pSub'), 'play.lock')) 81 | client_config = config.get('client', {}) 82 | self.pre_exe = client_config.get('pre_exe', '') 83 | self.pre_exe = self.pre_exe.split(' ') if self.pre_exe != '' else [] 84 | 85 | def test_config(self): 86 | """ 87 | Ping the server specified in the config to ensure we can communicate 88 | """ 89 | click.secho('Testing Server Connection', fg='green') 90 | click.secho( 91 | '{}://{}@{}'.format( 92 | 'https' if self.ssl else 'http', 93 | self.username, 94 | self.host, 95 | ), 96 | fg='blue' 97 | ) 98 | ping = self.make_request(url=self.create_url('ping')) 99 | if ping: 100 | click.secho('Test Passed', fg='green') 101 | return True 102 | else: 103 | click.secho('Test Failed! Please check your config', fg='black', bg='red') 104 | return False 105 | 106 | def hash_password(self): 107 | """ 108 | return random salted md5 hash of password 109 | """ 110 | characters = string.ascii_uppercase + string.ascii_lowercase + string.digits 111 | salt = ''.join(SystemRandom().choice(characters) for i in range(9)) # noqa 112 | salted_password = self.password + salt 113 | token = hashlib.md5(salted_password.encode('utf-8')).hexdigest() 114 | return token, salt 115 | 116 | def create_url(self, endpoint): 117 | """ 118 | build the standard url for interfacing with the Subsonic REST API 119 | :param endpoint: REST endpoint to incorporate in the url 120 | """ 121 | token, salt = self.hash_password() 122 | if version.parse(self.api) < version.parse("1.13.0"): 123 | url = '{}://{}/rest/{}.view?u={}&p={}&v={}&c=pSub&f=json'.format( 124 | 'https' if self.ssl else 'http', 125 | self.host, 126 | endpoint, 127 | self.username, 128 | self.password, 129 | self.api 130 | ) 131 | else: 132 | url = '{}://{}/rest/{}?u={}&t={}&s={}&v={}&c=pSub&f=json'.format( 133 | 'https' if self.ssl else 'http', 134 | self.host, 135 | endpoint, 136 | self.username, 137 | token, 138 | salt, 139 | self.api 140 | ) 141 | 142 | return url 143 | 144 | def make_request(self, url): 145 | """ 146 | GET the supplied url and resturn the response as json. 147 | Handle any errors present. 148 | :param url: full url. see create_url method for details 149 | :return: Subsonic response or None on failure 150 | """ 151 | try: 152 | r = requests.get(url=url, verify=self.verify_ssl) 153 | except requests.exceptions.ConnectionError as e: 154 | click.secho('{}'.format(e), fg='red') 155 | sys.exit(1) 156 | 157 | try: 158 | response = r.json() 159 | except ValueError: 160 | response = { 161 | 'subsonic-response': { 162 | 'error': { 163 | 'code': 100, 164 | 'message': r.text 165 | }, 166 | 'status': 'failed' 167 | } 168 | } 169 | 170 | subsonic_response = response.get('subsonic-response', {}) 171 | status = subsonic_response.get('status', 'failed') 172 | 173 | if status == 'failed': 174 | error = subsonic_response.get('error', {}) 175 | click.secho( 176 | 'Command Failed! {}: {}'.format( 177 | error.get('code', ''), 178 | error.get('message', '') 179 | ), 180 | fg='red' 181 | ) 182 | return None 183 | 184 | return response 185 | 186 | def scrobble(self, song_id): 187 | """ 188 | notify the Subsonic server that a track is being played within pSub 189 | :param song_id: 190 | :return: 191 | """ 192 | self.make_request( 193 | url='{}&id={}'.format( 194 | self.create_url('scrobble'), 195 | song_id 196 | ) 197 | ) 198 | 199 | def search(self, query): 200 | """ 201 | search using query and return the result 202 | :return: 203 | :param query: search term string 204 | """ 205 | results = self.make_request( 206 | url='{}&query={}'.format(self.create_url('search3'), query) 207 | ) 208 | if results: 209 | return results['subsonic-response'].get('searchResult3', []) 210 | return [] 211 | 212 | def get_artists(self): 213 | """ 214 | Gather list of Artists from the Subsonic server 215 | :return: list 216 | """ 217 | artists = self.make_request(url=self.create_url('getArtists')) 218 | if artists: 219 | return artists['subsonic-response']['artists'].get('index', []) 220 | return [] 221 | 222 | def get_playlists(self): 223 | """ 224 | Get a list of available playlists from the server 225 | :return: 226 | """ 227 | playlists = self.make_request(url=self.create_url('getPlaylists')) 228 | if playlists: 229 | return playlists['subsonic-response']['playlists'].get('playlist', []) 230 | return [] 231 | 232 | def get_music_folders(self): 233 | """ 234 | Gather list of Music Folders from the Subsonic server 235 | :return: list 236 | """ 237 | music_folders = self.make_request(url=self.create_url('getMusicFolders')) 238 | if music_folders: 239 | return music_folders['subsonic-response']['musicFolders'].get('musicFolder', []) 240 | return [] 241 | 242 | def get_album_tracks(self, album_id): 243 | """ 244 | return a list of album track ids for the given album id 245 | :param album_id: id of the album 246 | :return: list 247 | """ 248 | album_info = self.make_request('{}&id={}'.format(self.create_url('getAlbum'), album_id)) 249 | songs = [] 250 | 251 | for song in album_info['subsonic-response']['album'].get('song', []): 252 | songs.append(song) 253 | 254 | return songs 255 | 256 | def play_random_songs(self, music_folder): 257 | """ 258 | Gather random tracks from the Subsonic server and play them endlessly 259 | :param music_folder: integer denoting music folder to filter tracks 260 | """ 261 | url = self.create_url('getRandomSongs') 262 | 263 | if music_folder is not None: 264 | url = '{}&musicFolderId={}'.format(url, music_folder) 265 | 266 | playing = True 267 | 268 | while playing: 269 | random_songs = self.make_request(url) 270 | 271 | if not random_songs: 272 | return 273 | 274 | for random_song in random_songs['subsonic-response']['randomSongs'].get('song', []): 275 | if not playing: 276 | return 277 | playing = self.play_stream(dict(random_song)) 278 | 279 | def play_radio(self, radio_id): 280 | """ 281 | Get songs similar to the supplied id and play them endlessly 282 | :param radio_id: id of Artist 283 | """ 284 | playing = True 285 | while playing: 286 | similar_songs = self.make_request( 287 | '{}&id={}'.format(self.create_url('getSimilarSongs2'), radio_id) 288 | ) 289 | 290 | if not similar_songs: 291 | return 292 | 293 | for radio_track in similar_songs['subsonic-response']['similarSongs2'].get('song', []): 294 | if not playing: 295 | return 296 | playing = self.play_stream(dict(radio_track)) 297 | 298 | def play_artist(self, artist_id, randomise): 299 | """ 300 | Get the songs by the given artist_id and play them 301 | :param artist_id: id of the artist to play 302 | :param randomise: if True, randomise the playback order 303 | """ 304 | artist_info = self.make_request('{}&id={}'.format(self.create_url('getArtist'), artist_id)) 305 | songs = [] 306 | 307 | for album in artist_info['subsonic-response']['artist']['album']: 308 | songs += self.get_album_tracks(album.get('id')) 309 | 310 | if self.invert_random: 311 | randomise = not randomise 312 | 313 | if randomise: 314 | shuffle(songs) 315 | 316 | playing = True 317 | 318 | while playing: 319 | for song in songs: 320 | if not playing: 321 | return 322 | playing = self.play_stream(dict(song)) 323 | 324 | def play_album(self, album_id, randomise): 325 | """ 326 | Get the songs for the given album id and play them 327 | :param album_id: 328 | :param randomise: 329 | :return: 330 | """ 331 | songs = self.get_album_tracks(album_id) 332 | 333 | if self.invert_random: 334 | randomise = not randomise 335 | 336 | if randomise: 337 | shuffle(songs) 338 | 339 | playing = True 340 | 341 | while playing: 342 | for song in songs: 343 | if not playing: 344 | return 345 | playing = self.play_stream(dict(song)) 346 | 347 | def play_playlist(self, playlist_id, randomise): 348 | """ 349 | Get the tracks from the supplied playlist id and play them 350 | :param playlist_id: 351 | :param randomise: 352 | :return: 353 | """ 354 | playlist_info = self.make_request( 355 | url='{}&id={}'.format(self.create_url('getPlaylist'), playlist_id) 356 | ) 357 | songs = playlist_info['subsonic-response']['playlist']['entry'] 358 | 359 | if self.invert_random: 360 | randomise = not randomise 361 | 362 | if randomise: 363 | shuffle(songs) 364 | 365 | playing = True 366 | 367 | while playing: 368 | for song in songs: 369 | if not playing: 370 | return 371 | playing = self.play_stream(dict(song)) 372 | 373 | def play_stream(self, track_data): 374 | """ 375 | Given track data, generate the stream url and pass it to ffplay to handle. 376 | While stream is playing allow user input to control playback 377 | :param track_data: dict 378 | :return: 379 | """ 380 | stream_url = self.create_url('download') 381 | song_id = track_data.get('id') 382 | 383 | if self.notify: 384 | self.notifications.get_cover_art(track_data) 385 | 386 | if not song_id: 387 | return False 388 | 389 | click.secho( 390 | '{} by {}'.format( 391 | track_data.get('title', ''), 392 | track_data.get('artist', '') 393 | ), 394 | fg='green' 395 | ) 396 | 397 | self.scrobble(song_id) 398 | 399 | params = [ 400 | 'ffplay', 401 | '-i', 402 | '{}&id={}&format={}'.format(stream_url, song_id, self.format), 403 | '-showmode', 404 | '{}'.format(self.show_mode), 405 | '-window_title', 406 | '{} by {}'.format( 407 | track_data.get('title', ''), 408 | track_data.get('artist', '') 409 | ), 410 | '-autoexit', 411 | '-hide_banner', 412 | '-x', 413 | '500', 414 | '-y', 415 | '500', 416 | '-loglevel', 417 | 'fatal', 418 | '-infbuf', 419 | ] 420 | 421 | params = self.pre_exe + params if len(self.pre_exe) > 0 else params 422 | 423 | if not self.display: 424 | params += ['-nodisp'] 425 | 426 | try: 427 | if self.notify: 428 | self.notifications.show_notification(track_data) 429 | 430 | ffplay = Popen(params) 431 | 432 | has_finished = None 433 | open(os.path.join(click.get_app_dir('pSub'), 'play.lock'), 'w+').close() 434 | 435 | while has_finished is None: 436 | has_finished = ffplay.poll() 437 | if self.input_queue.empty(): 438 | time.sleep(1) 439 | continue 440 | 441 | command = self.input_queue.get_nowait() 442 | self.input_queue.queue.clear() 443 | 444 | if 'x' in command.lower(): 445 | click.secho('Exiting!', fg='blue') 446 | os.remove(os.path.join(click.get_app_dir('pSub'), 'play.lock')) 447 | ffplay.terminate() 448 | return False 449 | 450 | if 'b' in command.lower(): 451 | click.secho('Restarting Track....', fg='blue') 452 | os.remove(os.path.join(click.get_app_dir('pSub'), 'play.lock')) 453 | ffplay.terminate() 454 | return self.play_stream(track_data) 455 | 456 | if 'n' in command.lower(): 457 | click.secho('Skipping...', fg='blue') 458 | os.remove(os.path.join(click.get_app_dir('pSub'), 'play.lock')) 459 | ffplay.terminate() 460 | return True 461 | 462 | os.remove(os.path.join(click.get_app_dir('pSub'), 'play.lock')) 463 | return True 464 | 465 | except OSError as err: 466 | click.secho( 467 | f'Could not run ffplay. Please make sure it is installed, {str(err)}', 468 | fg='red' 469 | ) 470 | click.launch('https://ffmpeg.org/download.html') 471 | return False 472 | except CalledProcessError as e: 473 | click.secho( 474 | 'ffplay existed unexpectedly with the following error: {}'.format(e), 475 | fg='red' 476 | ) 477 | return False 478 | 479 | def add_input(self): 480 | """ 481 | This method runs in a separate thread (started in __init__). 482 | When the play.lock file exists it waits for user input and wrties it to a Queue. 483 | The play_stream method above deals with the user input when it occurs 484 | """ 485 | while True: 486 | if not os.path.isfile(os.path.join(click.get_app_dir('pSub'), 'play.lock')): 487 | continue 488 | time.sleep(1) 489 | self.input_queue.put(click.prompt('', prompt_suffix='')) 490 | 491 | @staticmethod 492 | def show_banner(message): 493 | """ 494 | Show a standardized banner with custom message and controls for playback 495 | :param message: 496 | """ 497 | click.clear() 498 | click.echo('') 499 | click.secho(' {} '.format(message), bg='blue', fg='black') 500 | click.echo('') 501 | click.secho('n = Next\nb = Beginning\nx = Exit', bg='yellow', fg='black') 502 | click.echo('') 503 | 504 | @staticmethod 505 | def set_default_config(config): 506 | """ 507 | When no config file is detected, this method is run to write the default config 508 | :param config: path to config file 509 | """ 510 | with open(config, 'w+') as config_file: 511 | config_file.write( 512 | """# 513 | # _________ ___. 514 | # ______ / _____/__ _\_ |__ 515 | # \____ \\\_____ \| | \ __ \ 516 | # | |_> > \ | / \_\ \\ 517 | # | __/_______ /____/|___ / 518 | # |__| \/ \/ 519 | # 520 | # 521 | 522 | # This section defines the connection to your Subsonic server 523 | 524 | server: 525 | # This is the url you would use to access your Subsonic server without the protocol 526 | # (http:// or https://) 527 | 528 | host: demo.subsonic.org 529 | 530 | # Username and Password next 531 | 532 | username: username 533 | password: password 534 | 535 | # If your Subsonic server is accessed over https:// set this to 'true' 536 | 537 | ssl: false 538 | 539 | # If you use a server with a specific API version set it here 540 | 541 | api: 1.16.0 542 | 543 | # This section defines the playback of music by pSub 544 | 545 | streaming: 546 | 547 | # The default format is 'raw' 548 | # this means the original file is streamed from your server 549 | # and no transcoding takes place. 550 | # set this to mp3 or wav etc. 551 | # depending on the transcoders available to your user on the server 552 | 553 | format: raw 554 | 555 | # pSub utilises ffplay (https://ffmpeg.org/ffplay.html) to play the streamed media 556 | # by default the player window is hidden and control takes place through the cli 557 | # set this to true to enable the player window. 558 | # It allows for more controls (volume mainly) but will grab the focus of your 559 | # keyboard when tracks change which can be annoying if you are typing 560 | 561 | display: false 562 | 563 | # When the player window is shown, choose the default show mode 564 | # Options are: 565 | # 0: show video or album art 566 | # 1: show audio waves 567 | # 2: show audio frequency band using RDFT ((Inverse) Real Discrete Fourier Transform) 568 | 569 | show_mode: 0 570 | 571 | # Artist, Album and Playlist playback can accept a -r/--random flag. 572 | # by default, setting the flag on the command line means "randomise playback". 573 | # Setting the following to true will invert that behaviour so that playback is randomised by default 574 | # and passing the -r flag skips the random shuffle 575 | 576 | invert_random: false 577 | 578 | # pSub can use system notifications to alert you to a song change. 579 | # it will show you the details of the currently playing song. 580 | # to disable notification, set this to false 581 | 582 | notify: true 583 | 584 | client: 585 | # Added extra client config for pre-exe commands, like using it in flatpak-spawn 586 | pre_exe: '' 587 | """ 588 | ) 589 | 590 | 591 | # _________ .____ .___ 592 | # \_ ___ \| | | | 593 | # / \ \/| | | | 594 | # \ \___| |___| | 595 | # \______ /_______ \___| 596 | # \/ \/ 597 | # Below are the CLI methods 598 | 599 | CONTEXT_SETTINGS = dict(help_option_names=['-h', '--help']) 600 | 601 | 602 | @click.group( 603 | invoke_without_command=True, 604 | context_settings=CONTEXT_SETTINGS 605 | ) 606 | @click.option( 607 | '--config', 608 | '-c', 609 | is_flag=True, 610 | help='Edit the config file' 611 | ) 612 | @click.option( 613 | '--test', 614 | '-t', 615 | is_flag=True, 616 | help='Test the server configuration' 617 | ) 618 | @click.pass_context 619 | def cli(ctx, config, test): 620 | if not os.path.exists(click.get_app_dir('pSub')): 621 | os.mkdir(click.get_app_dir('pSub')) 622 | 623 | config_file = os.path.join(click.get_app_dir('pSub'), 'config.yaml') 624 | 625 | if config: 626 | test = True 627 | 628 | try: 629 | click.edit(filename=config_file, extension='yaml') 630 | except UsageError: 631 | click.secho('pSub was unable to open your config file for editing.', bg='red', fg='black') 632 | click.secho('please open {} manually to edit your config file'.format(config_file), fg='yellow') 633 | return 634 | 635 | ctx.obj = pSub(config_file) 636 | 637 | if test: 638 | # Ping the server to check server config 639 | test_ok = ctx.obj.test_config() 640 | if not test_ok: 641 | return 642 | 643 | if ctx.invoked_subcommand is None: 644 | click.echo(ctx.get_help()) 645 | 646 | 647 | pass_pSub = click.make_pass_decorator(pSub) 648 | 649 | 650 | @cli.command(help='Play random tracks') 651 | @click.option( 652 | '--music_folder', 653 | '-f', 654 | type=int, 655 | help='Specify the music folder to play random tracks from.', 656 | ) 657 | @pass_pSub 658 | def random(psub, music_folder): 659 | if not music_folder: 660 | music_folders = get_as_list(psub.get_music_folders()) + [{'name': 'All', 'id': None}] 661 | 662 | chosen_folder = questionary.select( 663 | "Choose a music folder to play random tracks from", 664 | choices=[folder.get('name') for folder in music_folders] 665 | ).ask() 666 | music_folder = next(folder.get('id') for folder in music_folders if folder.get('name') == chosen_folder) 667 | 668 | psub.show_banner('Playing Random Tracks') 669 | psub.play_random_songs(music_folder) 670 | 671 | 672 | def get_as_list(list_inst: Union[List, Dict]) -> List[Dict]: 673 | if isinstance(list_inst,dict): 674 | list_inst = [list_inst] 675 | return list_inst 676 | 677 | 678 | @cli.command(help='Play endless Radio based on a search') 679 | @click.argument('search_term') 680 | @pass_pSub 681 | @click.pass_context 682 | def radio(ctx, psub, search_term): 683 | results = get_as_list(psub.search(search_term).get('artist', [])) 684 | 685 | if len(results) > 0: 686 | chosen_artist = questionary.select( 687 | "Choose an Artist to start a Radio play, or 'Search Again' to search again", 688 | choices=[artist.get('name') for artist in results] + ['Search Again'] 689 | ).ask() 690 | else: 691 | click.secho('No Artists found matching {}'.format(search_term), fg='red', color=True) 692 | chosen_artist = 'Search Again' 693 | 694 | if chosen_artist == 'Search Again': 695 | search_term = questionary.text("Enter a new search term").ask() 696 | 697 | if not search_term: 698 | sys.exit(0) 699 | 700 | ctx.invoke(radio, search_term=search_term) 701 | else: 702 | radio_artist = next((art for art in results if art.get('name') == chosen_artist), None) 703 | 704 | if radio_artist is None: 705 | sys.exit(0) 706 | 707 | psub.show_banner('Playing Radio based on {}'.format(radio_artist.get('name'))) 708 | psub.play_radio(radio_artist.get('id')) 709 | 710 | 711 | @cli.command(help='Play songs from chosen Artist') 712 | @click.argument('search_term') 713 | @click.option( 714 | '--randomise', 715 | '-r', 716 | is_flag=True, 717 | help='Randomise the order of track playback', 718 | ) 719 | @pass_pSub 720 | @click.pass_context 721 | def artist(ctx, psub, search_term, randomise): 722 | results = get_as_list(psub.search(search_term).get('artist', [])) 723 | 724 | if len(results) > 0: 725 | chosen_artist = questionary.select( 726 | "Choose an Artist, or 'Search Again' to search again", 727 | choices=[artist.get('name') for artist in results] + ['Search Again'] 728 | ).ask() 729 | else: 730 | click.secho('No artists found matching "{}"'.format(search_term), fg='red', color=True) 731 | chosen_artist = 'Search Again' 732 | 733 | if chosen_artist == 'Search Again': 734 | search_term = questionary.text("Enter a new search term").ask() 735 | 736 | if not search_term: 737 | sys.exit(0) 738 | 739 | ctx.invoke(artist, search_term=search_term, randomise=randomise) 740 | else: 741 | play_artist = next((art for art in results if art.get('name') == chosen_artist), None) 742 | 743 | if play_artist is None: 744 | sys.exit(0) 745 | 746 | psub.show_banner( 747 | 'Playing {}tracks by {}'.format( 748 | 'randomised ' if randomise else '', 749 | play_artist.get('name') 750 | ) 751 | ) 752 | psub.play_artist(play_artist.get('id'), randomise) 753 | 754 | 755 | @cli.command(help='Play songs from chosen Album') 756 | @click.argument('search_term') 757 | @click.option( 758 | '--randomise', 759 | '-r', 760 | is_flag=True, 761 | help='Randomise the order of track playback', 762 | ) 763 | @pass_pSub 764 | @click.pass_context 765 | def album(ctx, psub, search_term, randomise): 766 | results = get_as_list(psub.search(search_term).get('album', [])) 767 | 768 | if len(results) > 0: 769 | chosen_album = questionary.select( 770 | "Choose an Album, or 'Search Again' to search again", 771 | choices=[album.get('name') for album in results] + ['Search Again'] 772 | ).ask() 773 | else: 774 | click.secho('No albums found matching "{}"'.format(search_term), fg='red', color=True) 775 | chosen_album = 'Search Again' 776 | 777 | if chosen_album == 'Search Again': 778 | search_term = questionary.text("Enter a new search term").ask() 779 | 780 | if not search_term: 781 | sys.exit(0) 782 | 783 | ctx.invoke(album, search_term=search_term, randomise=randomise) 784 | else: 785 | play_album = next((alb for alb in results if alb.get('name') == chosen_album), None) 786 | 787 | if play_album is None: 788 | sys.exit(0) 789 | 790 | psub.show_banner( 791 | 'Playing {}tracks from {} '.format( 792 | 'randomised ' if randomise else '', 793 | play_album.get('name') 794 | ) 795 | ) 796 | psub.play_album(play_album.get('id'), randomise) 797 | 798 | 799 | @cli.command(help='Play a chosen playlist') 800 | @click.option( 801 | '--randomise', 802 | '-r', 803 | is_flag=True, 804 | help='Randomise the order of track playback', 805 | ) 806 | @pass_pSub 807 | def playlist(psub, randomise): 808 | playlists = get_as_list(psub.get_playlists()) 809 | 810 | if len(playlists) > 0: 811 | chosen_playlist = questionary.select( 812 | "Choose a Playlist, or 'Search Again' to search again", 813 | choices=[plist.get('name') for plist in playlists] + ['Search Again'] 814 | ).ask() 815 | else: 816 | click.secho('No playlists found', fg='red', color=True) 817 | sys.exit(0) 818 | 819 | play_list = next((plist for plist in playlists if plist.get('name') == chosen_playlist), None) 820 | 821 | if play_list is None: 822 | sys.exit(0) 823 | 824 | psub.show_banner( 825 | 'Playing {} tracks from the "{}" playlist'.format( 826 | 'randomised' if randomise else '', 827 | play_list.get('name') 828 | ) 829 | ) 830 | 831 | psub.play_playlist(play_list.get('id'), randomise) 832 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup( 4 | name='pSub', 5 | version='0.1', 6 | py_modules=['pSub'], 7 | install_requires=[ 8 | 'click', 9 | 'colorama', 10 | 'pyyaml', 11 | 'packaging', 12 | 'requests[security]', 13 | 'questionary', 14 | "pygobject", 15 | "pycairo" 16 | ], 17 | entry_points=''' 18 | [console_scripts] 19 | pSub=pSub:cli 20 | ''', 21 | ) 22 | --------------------------------------------------------------------------------