├── .gitattributes ├── .github └── workflows │ ├── codeql-analysis.yml │ └── pythonapp.yml ├── .gitignore ├── LICENSE ├── README.md ├── ac2 ├── __init__.py ├── alsavolume.py ├── audiocontrol2.py ├── constants.py ├── controller.py ├── data │ ├── __init__.py │ ├── coverartarchive.py │ ├── coverarthandler.py │ ├── fanarttv.py │ ├── guess.py │ ├── hifiberry.py │ ├── identities.py │ ├── lastfm.py │ ├── mpd.py │ ├── musicbrainz.py │ ├── test_coverartarchive.py │ ├── test_guess.py │ ├── test_hifiberry.py │ └── test_lastfm.py ├── dev │ ├── __init__.py │ └── dummydata.py ├── helpers.py ├── metadata.py ├── ostools.py ├── players │ ├── __init__.py │ ├── mpdcontrol.py │ ├── mpris.py │ ├── plexamp.py │ └── vollibrespot.py ├── plugins │ ├── __init__.py │ ├── control │ │ ├── __init__.py │ │ ├── controller.py │ │ ├── keyboard.py │ │ ├── powercontroller.py │ │ └── rotary.py │ ├── metadata │ │ ├── __init__.py │ │ ├── console.py │ │ ├── http_post.py │ │ ├── lametric.py │ │ ├── lastfm.py │ │ └── postgresql.py │ └── volume │ │ ├── __init__.py │ │ └── http.py ├── processmapper.py ├── simple_http.py ├── socketio.py ├── test_metadata.py ├── test_simple_http.py ├── version.py ├── watchdog.py └── webserver.py ├── audiocontrol2.conf.default ├── doc ├── api.md ├── extensions.md ├── ky040.jpg ├── lametric.md ├── lm1.PNG ├── lm2.PNG ├── lm3.PNG ├── lm4.PNG ├── readme.md ├── rotary-controller-plugin.md ├── rotary-soldered.jpg ├── rpigpio.png └── socketio_api.md ├── links.txt ├── requirements.txt ├── setup.py ├── test ├── __init__.py └── keyboard_test.py └── tpl └── index.html /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [master, ] 6 | pull_request: 7 | # The branches below must be a subset of the branches above 8 | branches: [master] 9 | schedule: 10 | - cron: '0 5 * * 4' 11 | 12 | jobs: 13 | analyse: 14 | name: Analyse 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - name: Checkout repository 19 | uses: actions/checkout@v2 20 | with: 21 | # We must fetch at least the immediate parents so that if this is 22 | # a pull request then we can checkout the head. 23 | fetch-depth: 2 24 | 25 | # If this run was triggered by a pull request event, then checkout 26 | # the head of the pull request instead of the merge commit. 27 | - run: git checkout HEAD^2 28 | if: ${{ github.event_name == 'pull_request' }} 29 | 30 | # Initializes the CodeQL tools for scanning. 31 | - name: Initialize CodeQL 32 | uses: github/codeql-action/init@v1 33 | # Override language selection by uncommenting this and choosing your languages 34 | # with: 35 | # languages: go, javascript, csharp, python, cpp, java 36 | 37 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 38 | # If this step fails, then you should remove it and run the build manually (see below) 39 | - name: Autobuild 40 | uses: github/codeql-action/autobuild@v1 41 | 42 | # ℹ️ Command-line programs to run using the OS shell. 43 | # 📚 https://git.io/JvXDl 44 | 45 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 46 | # and modify them (or add more) to build your code if your project 47 | # uses a compiled language 48 | 49 | #- run: | 50 | # make bootstrap 51 | # make release 52 | 53 | - name: Perform CodeQL Analysis 54 | uses: github/codeql-action/analyze@v1 55 | -------------------------------------------------------------------------------- /.github/workflows/pythonapp.yml: -------------------------------------------------------------------------------- 1 | name: Python application 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - uses: actions/checkout@v1 12 | - name: Set up Python 3.7 13 | uses: actions/setup-python@v1 14 | with: 15 | python-version: 3.7 16 | - name: Install dependencies 17 | run: | 18 | sudo apt-get install gcc libasound-dev 19 | python -m pip install --upgrade pip 20 | pip install -r requirements.txt 21 | - name: Lint with flake8 22 | run: | 23 | pip install flake8 24 | # stop the build if there are Python syntax errors or undefined names 25 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 26 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 27 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 28 | # - name: Test with pytest 29 | # run: | 30 | # pip install pytest 31 | # pytest 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .project 3 | .pydevproject 4 | Pipfile 5 | # Byte-compiled / optimized / DLL files 6 | __pycache__/ 7 | *.py[cod] 8 | *$py.class 9 | 10 | # C extensions 11 | *.so 12 | 13 | # Distribution / packaging 14 | .Python 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | downloads/ 19 | eggs/ 20 | .eggs/ 21 | lib/ 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | var/ 26 | wheels/ 27 | *.egg-info/ 28 | .installed.cfg 29 | *.egg 30 | MANIFEST 31 | 32 | # PyInstaller 33 | # Usually these files are written by a python script from a template 34 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 35 | *.manifest 36 | *.spec 37 | 38 | # Installer logs 39 | pip-log.txt 40 | pip-delete-this-directory.txt 41 | 42 | # Unit test / coverage reports 43 | htmlcov/ 44 | .tox/ 45 | .coverage 46 | .coverage.* 47 | .cache 48 | nosetests.xml 49 | coverage.xml 50 | *.cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | 63 | # Flask stuff: 64 | instance/ 65 | .webassets-cache 66 | 67 | # Scrapy stuff: 68 | .scrapy 69 | 70 | # Sphinx documentation 71 | docs/_build/ 72 | 73 | # PyBuilder 74 | target/ 75 | 76 | # Jupyter Notebook 77 | .ipynb_checkpoints 78 | 79 | # pyenv 80 | .python-version 81 | 82 | # celery beat schedule file 83 | celerybeat-schedule 84 | 85 | # SageMath parsed files 86 | *.sage.py 87 | 88 | # Environments 89 | .env 90 | .venv 91 | env/ 92 | venv/ 93 | ENV/ 94 | env.bak/ 95 | venv.bak/ 96 | 97 | # Spyder project settings 98 | .spyderproject 99 | .spyproject 100 | 101 | # Rope project settings 102 | .ropeproject 103 | 104 | # mkdocs documentation 105 | /site 106 | 107 | # mypy 108 | .mypy_cache/ 109 | update 110 | Pipfile.lock 111 | .vscode/settings.json 112 | /.vs 113 | 114 | audiocontrol*.tar.gz 115 | deb_dist 116 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 HiFiBerry 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Python application](https://github.com/hifiberry/audiocontrol2/workflows/Python%20application/badge.svg) 2 | [![GitHub contributors](https://img.shields.io/github/contributors/hifiberry/audiocontrol2.svg)](https://GitHub.com/hifiberry/audiocontrol2/graphs/contributors/) 3 | 4 | # Audiocontrol2 5 | 6 | This is a central part of HiFiBerryOS that handles multiple concurrent media players. 7 | This is the core controller application of [HiFiBerryOS](https://github.com/hifiberry/hifiberry-os), but feel free to use it 8 | any project of your choice. 9 | To work well, all local players should support MPRIS. 10 | 11 | The latest version now allows Non-MPRIS players. However, for these players interfaces must be developed individually. 12 | 13 | # Prerequisites 14 | * libasound2-dev 15 | * libglib2.0-dev -------------------------------------------------------------------------------- /ac2/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hifiberry/audiocontrol2/3944f96163a282ea99daa3db40f44347c69e7c76/ac2/__init__.py -------------------------------------------------------------------------------- /ac2/alsavolume.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Copyright (c) 2018 Modul 9/HiFiBerry 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. 21 | ''' 22 | 23 | import threading 24 | import time 25 | import logging 26 | import alsaaudio 27 | 28 | class ALSAVolume(threading.Thread): 29 | 30 | def __init__(self, mixer_name): 31 | 32 | super().__init__() 33 | 34 | self.listeners = [] 35 | self.volume = -1 36 | self.unmuted_volume = 0 37 | self.pollinterval = 0.2 38 | if self.pollinterval < 0.1: 39 | self.pollinterval = 0.1 40 | 41 | try: 42 | alsaaudio.Mixer(mixer_name) 43 | self.mixer_name = mixer_name 44 | except: 45 | logging.error("ALSA mixer device %s not found, aborting", 46 | mixer_name) 47 | self.mixer_name = None 48 | 49 | def set_volume(self, vol): 50 | # Check if this was a "mute" operation and store unmuted volume 51 | if vol == 0 and self.volume != 0: 52 | self.unmuted_volume = self.volume 53 | 54 | if vol != self.volume: 55 | alsaaudio.Mixer(self.mixer_name).setvolume(int(vol), 56 | alsaaudio.MIXER_CHANNEL_ALL) 57 | 58 | def change_volume_percent(self, change): 59 | vol = self.current_volume() 60 | newvol = vol + change 61 | if newvol < 0: 62 | newvol = 0 63 | elif newvol > 100: 64 | newvol = 100 65 | 66 | self.set_volume(newvol) 67 | 68 | def set_mute(self, mute): 69 | if mute: 70 | logging.debug("muting") 71 | if self.volume != 0: 72 | self.unmuted_volume = self.volume 73 | self.set_volume(0) 74 | else: 75 | logging.debug("unmuting") 76 | if self.volume == 0 and self.unmuted_volume > 0: 77 | self.set_volume(self.unmuted_volume) 78 | 79 | def toggle_mute(self): 80 | if self.volume != 0: 81 | self.unmuted_volume = self.volume 82 | self.set_volume(0) 83 | elif self.unmuted_volume > 0: 84 | self.set_volume(self.unmuted_volume) 85 | 86 | def run(self): 87 | while True: 88 | self.notify_listeners() 89 | time.sleep(self.pollinterval) 90 | 91 | def notify_listeners(self, always_notify=False): 92 | current_vol = self.current_volume() 93 | 94 | # Check if this was a "mute" operation and store unmuted volume 95 | if current_vol == 0 and self.volume != 0: 96 | self.unmuted_volume = self.volume 97 | 98 | if always_notify or (current_vol != self.volume): 99 | logging.debug("ALSA volume changed to {}".format(current_vol)) 100 | self.volume = current_vol 101 | for listener in self.listeners: 102 | try: 103 | listener.notify_volume(current_vol) 104 | except Exception as e: 105 | logging.debug("exception %s during %s.notify_volume", 106 | e, listener) 107 | 108 | def current_volume(self): 109 | volumes = alsaaudio.Mixer(self.mixer_name).getvolume() 110 | channels = 0 111 | vol = 0 112 | for i in range(len(volumes)): 113 | channels += 1 114 | vol += volumes[i] 115 | 116 | if channels > 0: 117 | vol = vol / channels 118 | 119 | return vol 120 | 121 | def add_listener(self, listener): 122 | self.listeners.append(listener) 123 | -------------------------------------------------------------------------------- /ac2/constants.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Copyright (c) 2020 Modul 9/HiFiBerry 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. 21 | ''' 22 | 23 | 24 | CMD_NEXT = "Next" 25 | CMD_PREV = "Previous" 26 | CMD_PAUSE = "Pause" 27 | CMD_PLAYPAUSE = "PlayPause" 28 | CMD_STOP = "Stop" 29 | CMD_PLAY = "Play" 30 | CMD_SEEK = "Seek" 31 | CMD_RANDOM = "Random" 32 | CMD_NORANDOM = "RandomOff" 33 | CMD_REPEAT_ONE = "RepeatOne" 34 | CMD_REPEAT_ALL = "RepeatAll" 35 | CMD_REPEAT_NONE = "RepeatOff" 36 | 37 | STATE_UNDEF = "undefined" 38 | STATE_PLAYING = "playing" 39 | STATE_PAUSED = "paused" 40 | STATE_STOPPED = "stopped" 41 | -------------------------------------------------------------------------------- /ac2/data/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hifiberry/audiocontrol2/3944f96163a282ea99daa3db40f44347c69e7c76/ac2/data/__init__.py -------------------------------------------------------------------------------- /ac2/data/coverartarchive.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Copyright (c) 2018 Modul 9/HiFiBerry 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. 21 | ''' 22 | 23 | import json 24 | import logging 25 | 26 | from ac2.simple_http import retrieve_url 27 | from ac2.data.coverarthandler import good_enough, best_picture_url 28 | 29 | 30 | def coverartarchive_cover(mbid): 31 | logging.debug("trying to find coverart for %s on coverartarchive", mbid) 32 | try: 33 | url = None 34 | covers = coverdata(mbid) 35 | if covers is not None: 36 | for img in covers["images"]: 37 | if img["front"]: 38 | url = img["image"] 39 | logging.debug("found cover from coverartarchive: %s", url) 40 | 41 | return url 42 | 43 | except Exception as e: 44 | logging.warning("can't load cover for %s: %s", mbid, e) 45 | 46 | 47 | def coverdata(mbid): 48 | url = "http://coverartarchive.org/release/{}/".format(mbid) 49 | data = retrieve_url(url) 50 | if data is not None: 51 | return json.loads(data.text) 52 | 53 | 54 | def enrich_metadata(metadata): 55 | 56 | if metadata.hifiberry_cover_found: 57 | return 58 | 59 | if metadata.is_unknown(): 60 | # Do not try to retrieve metadata for unknown songs 61 | return 62 | 63 | if metadata.albummbid is None: 64 | return 65 | key = metadata.songId() 66 | if good_enough(key): 67 | return 68 | 69 | artUrl = coverartarchive_cover(metadata.albummbid) 70 | # check if the cover is improved 71 | metadata.externalArtUrl = best_picture_url(key, artUrl) 72 | 73 | 74 | """ 75 | print(coverdata("219b202d-290e-3960-b626-bf852a63bc50")) 76 | print(coverartarchive_cover("219b202d-290e-3960-b626-bf852a63bc50")) 77 | """ 78 | -------------------------------------------------------------------------------- /ac2/data/coverarthandler.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Copyright (c) 2018 Modul 9/HiFiBerry 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. 21 | ''' 22 | 23 | import logging 24 | from expiringdict import ExpiringDict 25 | 26 | covers = ExpiringDict(max_len=1000, 27 | max_age_seconds=3600000) 28 | 29 | import io 30 | import struct 31 | import urllib.request as urllib2 32 | 33 | GOOD_ENOUGH_WIDTH = 1000 34 | GOOD_ENOUGH_HEIGHT = 1000 35 | 36 | 37 | def getImageInfo(data): 38 | data = data 39 | size = len(data) 40 | # print(size) 41 | height = -1 42 | width = -1 43 | content_type = '' 44 | 45 | # handle GIFs 46 | if (size >= 10) and data[:6] in (b'GIF87a', b'GIF89a'): 47 | # Check to see if content_type is correct 48 | content_type = 'image/gif' 49 | w, h = struct.unpack(b"= 24) and data.startswith(b'\211PNG\r\n\032\n') 57 | and (data[12:16] == b'IHDR')): 58 | content_type = 'image/png' 59 | w, h = struct.unpack(b">LL", data[16:24]) 60 | width = int(w) 61 | height = int(h) 62 | 63 | # Maybe this is for an older PNG version. 64 | elif (size >= 16) and data.startswith(b'\211PNG\r\n\032\n'): 65 | # Check to see if we have the right content type 66 | content_type = 'image/png' 67 | w, h = struct.unpack(b">LL", data[8:16]) 68 | width = int(w) 69 | height = int(h) 70 | 71 | # handle JPEGs 72 | elif (size >= 2) and data.startswith(b'\377\330'): 73 | content_type = 'image/jpeg' 74 | jpeg = io.BytesIO(data) 75 | jpeg.read(2) 76 | b = jpeg.read(1) 77 | try: 78 | while (b and ord(b) != 0xDA): 79 | while (ord(b) != 0xFF): b = jpeg.read(1) 80 | while (ord(b) == 0xFF): b = jpeg.read(1) 81 | if (ord(b) >= 0xC0 and ord(b) <= 0xC3): 82 | jpeg.read(3) 83 | h, w = struct.unpack(b">HH", jpeg.read(4)) 84 | break 85 | else: 86 | jpeg.read(int(struct.unpack(b">H", jpeg.read(2))[0]) - 2) 87 | b = jpeg.read(1) 88 | width = int(w) 89 | height = int(h) 90 | except struct.error: 91 | pass 92 | except ValueError: 93 | pass 94 | 95 | logging.debug("parsed image") 96 | 97 | return content_type, width, height 98 | 99 | 100 | class Coverart(): 101 | 102 | def __init__(self, url, width=0, height=0): 103 | self.url = url 104 | self.width = width 105 | self.height = height 106 | self.imagedata = None 107 | 108 | if self.url is not None: 109 | if self.size() == 0: 110 | self.width, self.height = self.guess_size_from_url(url) 111 | 112 | if self.size() == 0: 113 | try: 114 | req = urllib2.Request(url, headers={"Range": "5000"}) 115 | r = urllib2.urlopen(req) 116 | 117 | _type, self.width, self.height = getImageInfo(r.read()) 118 | except Exception as e: 119 | logging.warning("error while parsing image from %s: %s", 120 | url, e) 121 | 122 | logging.debug("initialized coverart %s: %sx%s", 123 | url, self.width, self.height) 124 | 125 | def guess_size_from_url(self, url): 126 | # Try to guess the size of an image based on the URL. This won't 127 | # be perfect, but it speeds up processing as no HTTP requests are 128 | # required 129 | if "/300x300/" in url: 130 | return 300, 300 131 | 132 | if "/150x150/" in url: 133 | return 300, 300 134 | 135 | return 0, 0 136 | 137 | def size(self): 138 | return self.width * self.height 139 | 140 | def __str__(self): 141 | return str(self.url) 142 | 143 | 144 | def best_picture_url(key, url, width=0, height=0): 145 | 146 | logging.debug("looking up existing pictures for %s",key) 147 | cover = Coverart(url, width, height) 148 | existing_cover = covers.get(key) 149 | if existing_cover is not None: 150 | if existing_cover.size() < cover.size(): 151 | logging.debug("%sx%s > %sx%s - using new image", 152 | cover.width, cover.height, 153 | existing_cover.width, existing_cover.height) 154 | covers[key] = cover 155 | return cover.url 156 | else: 157 | logging.debug("%sx%s < %sx%s - using old image", 158 | cover.width, cover.height, 159 | existing_cover.width, existing_cover.height) 160 | 161 | return existing_cover.url 162 | 163 | else: 164 | logging.debug("%sx%s, no existing image", 165 | cover.width, cover.height) 166 | covers[key] = cover 167 | return cover.url 168 | 169 | def best_picture_size(key): 170 | 171 | if key is None: 172 | return(0,0) 173 | 174 | existing_cover = covers.get(key) 175 | if existing_cover is not None: 176 | return (existing_cover.width, existing_cover.height) 177 | else: 178 | return (0,0) 179 | 180 | def good_enough(key): 181 | (width, height) = best_picture_size(key) 182 | if width >= GOOD_ENOUGH_WIDTH and height >= GOOD_ENOUGH_HEIGHT: 183 | return True 184 | else: 185 | return False 186 | 187 | -------------------------------------------------------------------------------- /ac2/data/fanarttv.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Copyright (c) 2020 Modul 9/HiFiBerry 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. 21 | ''' 22 | import logging 23 | import json 24 | 25 | # Use caching 26 | from ac2.simple_http import retrieve_url 27 | from ac2.data.coverarthandler import good_enough, best_picture_url 28 | 29 | APIKEY="749a8fca4f2d3b0462b287820ad6ab06" 30 | 31 | def get_fanart_cover(artistmbid, albummbid, allow_artist_picture = False): 32 | url = "http://webservice.fanart.tv/v3/music/{}?api_key={}".format(artistmbid, APIKEY) 33 | try: 34 | json_text = retrieve_url(url).text 35 | if json_text is None: 36 | logging.debug("artist does not exit on fanart.tv") 37 | return 38 | 39 | data = json.loads(json_text) 40 | 41 | # Try to find the album cover first 42 | try: 43 | coverurl = data["albums"][albummbid]["albumcover"]["url"] 44 | logging.debug("found album cover on fanart.tv") 45 | return coverurl 46 | except KeyError: 47 | logging.debug("found no album cover on fanart.tv") 48 | 49 | # If this doesn't exist, use artist cover 50 | if allow_artist_picture: 51 | try: 52 | imageurl = data["artistthumb"][1]["url"] 53 | logging.debug("found artist picture on fanart.tv") 54 | return imageurl 55 | except KeyError: 56 | logging.debug("found no artist picture on fanart.tv") 57 | 58 | 59 | except Exception as e: 60 | logging.debug("couldn't retrieve data from fanart.tv (%s)",e) 61 | 62 | 63 | def enrich_metadata(metadata, allow_artist_picture = False): 64 | 65 | if metadata.hifiberry_cover_found: 66 | return 67 | 68 | if metadata.is_unknown(): 69 | # Do not try to retrieve metadata for unknown songs 70 | return 71 | 72 | if metadata.artistmbid is None: 73 | logging.debug("artist mbid unknpown, can't use fanart.tv") 74 | return 75 | 76 | key = metadata.songId() 77 | if good_enough(key): 78 | logging.debug("existing cover is good enough, skipping fanart.tv") 79 | return 80 | 81 | url = get_fanart_cover(metadata.artistmbid, 82 | metadata.albummbid, 83 | allow_artist_picture) 84 | metadata.externalArtUrl = best_picture_url(key, url) -------------------------------------------------------------------------------- /ac2/data/guess.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Copyright (c) 2019 Modul 9/HiFiBerry 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. 21 | ''' 22 | 23 | import logging 24 | 25 | from ac2.data.musicbrainz import track_data 26 | from ac2.data.hifiberry import cloud_url 27 | from ac2.simple_http import retrieve_url, post_data 28 | 29 | ORDER_UNKNOWN = 0 30 | ORDER_TITLE_ARTIST = 1 31 | ORDER_ARTIST_TITLE = 2 32 | 33 | verbose = { 34 | ORDER_UNKNOWN: "unknown", 35 | ORDER_TITLE_ARTIST: "title/artist", 36 | ORDER_ARTIST_TITLE: "artist/title", 37 | } 38 | 39 | stream_stats = {} 40 | 41 | CACHE_PATH = "radio/stream-order" 42 | 43 | def guess_stream_order(stream, field1, field2, use_cloud=True): 44 | MIN_STAT_RATIO = 0.1 45 | MIN_STATS=10 46 | 47 | if stream.startswith("http"): 48 | caching_supported = True 49 | else: 50 | caching_supported = False 51 | logging.warning("not a web radio stream, won't use caching") 52 | 53 | stats = stream_stats.get(stream,{"ta": 0, "at": 0, "order": ORDER_UNKNOWN, "cloud": ORDER_UNKNOWN}) 54 | 55 | at = stats["at"] 56 | ta = stats["ta"] 57 | cloud = stats["cloud"] 58 | 59 | if stats["order"] != ORDER_UNKNOWN: 60 | return stats["order"] 61 | if stats["cloud"] != ORDER_UNKNOWN: 62 | return stats["cloud"] 63 | 64 | # Check hifiberry cloud if order is known for this stream 65 | if caching_supported: 66 | try: 67 | cacheinfo = retrieve_url(cloud_url(CACHE_PATH), 68 | params = { 'stream' : stream }) 69 | if cacheinfo is not None: 70 | cloud = int(cacheinfo.content) 71 | else: 72 | cloud = ORDER_UNKNOWN 73 | except Exception as e: 74 | logging.exception(e) 75 | 76 | if cloud in [ ORDER_ARTIST_TITLE, ORDER_TITLE_ARTIST]: 77 | order = cloud 78 | stream_order = cloud 79 | else: 80 | stream_order = ORDER_UNKNOWN 81 | order = guess_order(field1, field2) 82 | 83 | if order == ORDER_ARTIST_TITLE: 84 | at += 1 85 | elif order == ORDER_TITLE_ARTIST: 86 | ta += 1 87 | 88 | logging.debug("at/ta: %s/%s",at,ta) 89 | 90 | if stream_order == ORDER_UNKNOWN and at+ta > MIN_STATS: 91 | if float(at)*MIN_STAT_RATIO > ta: 92 | stream_order = ORDER_ARTIST_TITLE 93 | elif float(ta)*MIN_STAT_RATIO > at: 94 | stream_order = ORDER_TITLE_ARTIST 95 | else: 96 | stream_order = ORDER_UNKNOWN 97 | 98 | logging.info("guess stream %s is using %s encoding (%s/%s)", 99 | stream, verbose[stream_order], at, ta) 100 | 101 | if use_cloud and caching_supported and stream_order != ORDER_UNKNOWN: 102 | post_data(cloud_url(CACHE_PATH), 103 | { "stream": stream, 104 | "order": stream_order}) 105 | else: 106 | stream_order = ORDER_UNKNOWN 107 | 108 | stream_stats[stream]={"order": stream_order, "ta": ta, "at": at, "cloud": cloud} 109 | return order 110 | 111 | 112 | def guess_order(field1, field2): 113 | 114 | import Levenshtein 115 | 116 | ''' 117 | Try to guess which field is artist and which is title 118 | Uses musicbrainz 119 | ''' 120 | 121 | data_at = track_data(field2, field1) 122 | o_at = "{} / {}".format(field1, field2) 123 | v_at = "{} / {}".format(_artist(data_at),_title(data_at)) 124 | d_at = Levenshtein.distance(o_at.lower(),v_at.lower()) 125 | 126 | data_ta = track_data(field1, field2) 127 | o_ta = "{} / {}".format(field2, field1) 128 | v_ta = "{} / {}".format(_artist(data_ta),_title(data_ta)) 129 | d_ta = Levenshtein.distance(o_ta.lower(),v_ta.lower()) 130 | 131 | if (d_at == 0) and (d_ta > 0): 132 | return ORDER_ARTIST_TITLE 133 | elif (d_ta == 0) and (d_at > 0): 134 | return ORDER_TITLE_ARTIST 135 | elif (d_at+len(o_at)/4 < d_ta ): 136 | return ORDER_ARTIST_TITLE 137 | elif (d_ta+len(o_ta)/4 < d_at ): 138 | return ORDER_TITLE_ARTIST 139 | else: 140 | return ORDER_UNKNOWN 141 | 142 | def _title(data): 143 | try: 144 | return data["title"] 145 | except: 146 | return "" 147 | 148 | def _artist(data): 149 | try: 150 | return data["artist-credit"][0]["artist"]["name"] 151 | except: 152 | return "" 153 | -------------------------------------------------------------------------------- /ac2/data/hifiberry.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Copyright (c) 2019 Modul 9/HiFiBerry 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. 21 | ''' 22 | 23 | import logging 24 | 25 | from ac2.data.coverarthandler import best_picture_url, best_picture_size 26 | from ac2.simple_http import retrieve_url, post_data 27 | 28 | BASE_URL="https://musicdb.hifiberry.com" 29 | 30 | def cloud_url(path): 31 | return BASE_URL + "/"+path 32 | 33 | def hifiberry_cover(song_mbid, album_mbid, artist_mbid, player="unknown"): 34 | logging.debug("trying to find coverart for %s from hifiberry", song_mbid) 35 | 36 | try: 37 | url = "{}/cover/{}/{}/{}/{}".format(BASE_URL, song_mbid, album_mbid, artist_mbid, player) 38 | cover_data = retrieve_url(url) 39 | if cover_data is None: 40 | return (None, 0, 0) 41 | else: 42 | cover_data = cover_data.text 43 | 44 | if cover_data is not None and len(cover_data)>0: 45 | try: 46 | (cover_url, width, height) = cover_data.split("|") 47 | except: 48 | cover_url = None 49 | width = 0 50 | height = 0 51 | if cover_url=="": 52 | cover_url = None 53 | 54 | if cover_url is None: 55 | logging.info("no cover found on hifiberry musicdb") 56 | return (cover_url, int(width), int(height)) 57 | 58 | else: 59 | logging.info("did not receive cover data from %s", url) 60 | return (None, 0, 0) 61 | 62 | except Exception as e: 63 | logging.warning("can't load cover for %s: %s", song_mbid, e) 64 | logging.exception(e) 65 | return (None, 0, 0) 66 | 67 | 68 | def send_update(metadata): 69 | if metadata.hifiberry_cover_found: 70 | return 71 | 72 | if metadata.mbid is None: 73 | return 74 | 75 | key="update"+metadata.songId() 76 | 77 | best_picture_url(key, metadata.externalArtUrl) 78 | artUrl = best_picture_url(key, metadata.artUrl) 79 | 80 | if artUrl is not None: 81 | (width, height) = best_picture_size(key) 82 | else: 83 | return 84 | 85 | if metadata.albummbid is not None: 86 | mbid = metadata.albummbid 87 | else: 88 | mbid = metadata.mbid 89 | 90 | data = { 91 | "mbid": mbid, 92 | "url": artUrl, 93 | "width": width, 94 | "height": height 95 | } 96 | 97 | try: 98 | logging.info("sending cover update to hifiberry musicdb") 99 | url = "{}/cover-update".format(BASE_URL) 100 | post_data(url,data) 101 | except Exception as e: 102 | logging.exception(e) 103 | 104 | 105 | 106 | def enrich_metadata(metadata): 107 | 108 | if metadata.mbid is None: 109 | return 110 | 111 | (artUrl, width, height) = hifiberry_cover( 112 | metadata.mbid, 113 | metadata.albummbid, 114 | metadata.artistmbid, 115 | metadata.playerName) 116 | 117 | # check if the cover is improved 118 | key=metadata.songId() 119 | metadata.externalArtUrl = best_picture_url(key, artUrl, width, height) 120 | 121 | if metadata.externalArtUrl == artUrl and artUrl is not None: 122 | metadata.hifiberry_cover_found = True 123 | 124 | 125 | -------------------------------------------------------------------------------- /ac2/data/identities.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Copyright (c) 2019 Modul 9/HiFiBerry 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. 21 | ''' 22 | 23 | import logging 24 | 25 | my_uuid = None 26 | my_release = None 27 | 28 | 29 | def host_uuid(): 30 | global my_uuid 31 | 32 | if my_uuid is not None: 33 | return my_uuid 34 | 35 | try: 36 | with open('/etc/uuid', 'r') as file: 37 | my_uuid = file.readline().strip() 38 | except IOError: 39 | logging.warning("can't read /etc/uuid, using empty UUID") 40 | my_uuid = "unknown" 41 | 42 | return my_uuid 43 | 44 | 45 | def release(): 46 | global my_release 47 | 48 | if my_release is not None: 49 | return my_release 50 | 51 | try: 52 | with open('/etc/hifiberry.version', 'r') as file: 53 | my_release = file.readline().strip() 54 | except IOError: 55 | logging.warning("can't read /etc/hifiberry.version, using empty release id") 56 | my_release = "unknown" 57 | 58 | return my_release 59 | -------------------------------------------------------------------------------- /ac2/data/lastfm.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Copyright (c) 2019 Modul 9/HiFiBerry 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. 21 | ''' 22 | 23 | import logging 24 | import json 25 | from urllib.parse import quote 26 | 27 | from ac2.data.coverarthandler import best_picture_url 28 | from ac2.simple_http import retrieve_url 29 | 30 | lastfmuser = None 31 | 32 | track_template = "http://ws.audioscrobbler.com/2.0/?" \ 33 | "method=track.getInfo&api_key=7d2431d8bb5608574b59ea9c7cfe5cbd" \ 34 | "&artist={}&track={}&format=json{}" 35 | 36 | track_mbid_template = "http://ws.audioscrobbler.com/2.0/?" \ 37 | "method=track.getInfo&api_key=7d2431d8bb5608574b59ea9c7cfe5cbd" \ 38 | "&mbid={}&format=json{}" 39 | 40 | artist_template = "http://ws.audioscrobbler.com/2.0/?" \ 41 | "method=artist.getInfo&api_key=7d2431d8bb5608574b59ea9c7cfe5cbd" \ 42 | "&artist={}&format=json" 43 | 44 | album_template = "http://ws.audioscrobbler.com/2.0/?" \ 45 | "method=album.getInfo&api_key=7d2431d8bb5608574b59ea9c7cfe5cbd" \ 46 | "&artist={}&album={}&format=json" 47 | 48 | album_mbid_template = "http://ws.audioscrobbler.com/2.0/?" \ 49 | "method=album.getInfo&api_key=7d2431d8bb5608574b59ea9c7cfe5cbd" \ 50 | "&artist={}&album={}&format=json" 51 | 52 | 53 | def set_lastfmuser(username): 54 | global lastfmuser 55 | lastfmuser = username 56 | 57 | 58 | def enrich_metadata(metadata): 59 | logging.debug("enriching metadata") 60 | 61 | if metadata.artist is None or metadata.title is None: 62 | logging.debug("artist and/or title undefined, can't enrich metadata") 63 | 64 | userparam = "" 65 | if lastfmuser is not None: 66 | userparam = "&user={}".format(quote(lastfmuser)) 67 | metadata.loveSupported = True 68 | logging.debug("Love supported") 69 | else: 70 | logging.debug("Love unsupported") 71 | 72 | trackdata = None 73 | albumdata = None 74 | 75 | key = metadata.songId() 76 | 77 | if metadata.externalArtUrl is not None: 78 | best_picture_url(key, metadata.externalArtUrl) 79 | 80 | # Get album data if album is set 81 | if metadata.artist is not None and \ 82 | metadata.albumTitle is not None: 83 | albumdata = albumInfo(metadata.artist, metadata.albumTitle) 84 | 85 | found_album_cover = False 86 | if albumdata is not None: 87 | url = bestImage(albumdata) 88 | if url is not None: 89 | metadata.externalArtUrl = best_picture_url(key, url) 90 | logging.info("Got album cover for %s/%s from Last.FM: %s", 91 | metadata.artist, metadata.albumTitle, 92 | metadata.externalArtUrl) 93 | found_album_cover = True 94 | 95 | if metadata.albummbid is None: 96 | try: 97 | metadata.albummbid = albumdata["album"]["mbid"] 98 | logging.debug("added albummbid from Last.FM") 99 | except KeyError: 100 | # mbid might not be available 101 | pass 102 | 103 | if metadata.albumArtist is None: 104 | try: 105 | metadata.albumartist = albumdata["album"]["artist"] 106 | logging.debug("added album artist from Last.FM") 107 | except KeyError: 108 | # mbid might not be available 109 | pass 110 | 111 | # get track data 112 | if (metadata.artist is not None and metadata.title is not None) or \ 113 | metadata.mbid is not None: 114 | 115 | trackdata = trackInfo(metadata.artist, metadata.title, 116 | metadata.mbid, userparam) 117 | 118 | # Update track with more information 119 | if trackdata is not None and "track" in trackdata: 120 | 121 | trackdata = trackdata["track"] 122 | 123 | if metadata.artistmbid is None: 124 | if "artist" in trackdata and "mbid" in trackdata["artist"]: 125 | metadata.artistmbid = trackdata["artist"]["mbid"] 126 | logging.debug("artistmbid=%s", metadata.artistmbid) 127 | 128 | if metadata.albummbid is None: 129 | if "album" in trackdata and "mbid" in trackdata["album"]: 130 | metadata.albummbid = trackdata["album"]["mbid"] 131 | logging.debug("albummbid=%s", metadata.albummbid) 132 | 133 | if not(found_album_cover): 134 | url = bestImage(trackdata) 135 | if url is not None: 136 | metadata.externalArtUrl = best_picture_url(key, url) 137 | logging.info("Got track cover for %s/%s/%s from Last.FM: %s", 138 | metadata.artist, 139 | metadata.title, 140 | metadata.albumTitle, 141 | metadata.externalArtUrl) 142 | 143 | if metadata.playCount is None and "userplaycount" in trackdata: 144 | metadata.playCount = trackdata["userplaycount"] 145 | 146 | if metadata.mbid is None and "mbid" in trackdata: 147 | metadata.mbid = trackdata["mbid"] 148 | logging.debug("mbid=%s", metadata.mbid) 149 | 150 | if metadata.loved is None and "userloved" in trackdata: 151 | metadata.loved = (int(trackdata["userloved"]) > 0) 152 | 153 | # Workaround for "missing attribute wiki" bug 154 | try: 155 | _ = metadata.wiki 156 | except AttributeError: 157 | metadata.wiki = None 158 | 159 | if metadata.wiki is None and "wiki" in trackdata: 160 | metadata.wiki = trackdata["wiki"] 161 | logging.debug("found Wiki entry") 162 | 163 | if "toptags" in trackdata and "tag" in trackdata["toptags"]: 164 | for tag in trackdata["toptags"]["tag"]: 165 | metadata.add_tag(tag["name"]) 166 | logging.debug("adding tag from Last.FM: %s", tag["name"]) 167 | 168 | else: 169 | logging.info("no track data for %s/%s on Last.FM", 170 | metadata.artist, 171 | metadata.title) 172 | 173 | if metadata.artistmbid is None and metadata.artist is not None: 174 | artistdata = artistInfo(metadata.artist) 175 | if artistdata is not None: 176 | try: 177 | metadata.artistmbid = artistdata["artist"]["mbid"] 178 | logging.debug("added artistmbid from Last.FM") 179 | except KeyError: 180 | # mbid might not be available 181 | pass 182 | 183 | 184 | def trackInfo(artist, title, mbid, userparam): 185 | 186 | if mbid is not None: 187 | url = track_mbid_template.format(mbid, userparam) 188 | else: 189 | url = track_template.format(quote(artist), 190 | quote(title), 191 | userparam) 192 | 193 | trackdata = None 194 | data = retrieve_url(url) 195 | if data is not None: 196 | trackdata = json.loads(data.text) 197 | 198 | if mbid is not None and (trackdata is None or "error" in trackdata): 199 | logging.debug("track not found via mbid, retrying with name/title") 200 | trackdata = trackInfo(artist, title, None, userparam) 201 | 202 | return trackdata 203 | 204 | 205 | def artistInfo(artist_name): 206 | 207 | url = artist_template.format(quote(artist_name)) 208 | data = retrieve_url(url) 209 | if data is not None: 210 | return json.loads(data.text) 211 | 212 | 213 | def albumInfo(artist_name, album_name, albummbid=None): 214 | 215 | if albummbid is not None: 216 | url = album_mbid_template.format(quote(albummbid)) 217 | else: 218 | url = album_template.format(quote(artist_name), 219 | quote(album_name)) 220 | 221 | albumdata = None 222 | data = retrieve_url(url) 223 | if data is not None: 224 | albumdata = json.loads(data.text) 225 | 226 | if albummbid is not None and (albumdata is None or "error" in albumdata): 227 | logging.debug("album not found via mbid, retrying with name/title") 228 | albumdata = albumInfo(artist_name, album_name, None) 229 | 230 | return albumdata 231 | 232 | 233 | def bestImage(lastfmdata): 234 | if "album" in lastfmdata: 235 | key = "album" 236 | elif "artist" in lastfmdata: 237 | key = "artist" 238 | else: 239 | logging.error("can't parse lastfmdata") 240 | return 241 | 242 | try: 243 | urls = lastfmdata[key]["image"] 244 | res = {} 245 | for u in urls: 246 | res[u["size"]] = u["#text"] 247 | 248 | for size in ["extralarge", "large", "medium", "small"]: 249 | if size in res and len(res[size]) > 10: 250 | logging.debug("found image size %s: %s", size, res[size]) 251 | return res[size] 252 | 253 | return None 254 | 255 | except KeyError: 256 | logging.info("couldn't find any images") 257 | pass 258 | -------------------------------------------------------------------------------- /ac2/data/mpd.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Copyright (c) 2020 Modul 9/HiFiBerry 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. 21 | ''' 22 | 23 | ''' 24 | Get metadata from MPD 25 | ''' 26 | 27 | import logging 28 | from pathlib import Path 29 | 30 | 31 | class MpdMetadataProcessor(): 32 | 33 | def __init__(self, basedir="/"): 34 | self.base = Path(basedir) 35 | self.currentCover = None 36 | self.currentUrl = None 37 | 38 | def process_metadata(self, metadata): 39 | if metadata.playerName == "mpd": 40 | url = metadata.streamUrl 41 | if metadata.artUrl is None and url is not None: 42 | if url == self.currentUrl: 43 | metadata.artUrl = self.currentCover 44 | else: 45 | musicfile = Path(self.base, metadata.streamUrl) 46 | self.currentCover = self.coverart(musicfile) 47 | self.currentUrl = url 48 | metadata.artUrl = "file://" + str(self.currentCover) 49 | logging.error("artURL " + metadata.artUrl) 50 | 51 | def coverart(self, musicfile): 52 | musicdir = musicfile.parents[0] 53 | for f in Path(musicdir).glob("*.???*"): 54 | for b in ["cover", "front", "folder"]: 55 | for ext in [".jpg", ".jpeg", ".png", ".gif"]: 56 | if str(f.name).lower() == b + ext: 57 | return str(f) 58 | 59 | -------------------------------------------------------------------------------- /ac2/data/musicbrainz.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Copyright (c) 2018 Modul 9/HiFiBerry 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. 21 | ''' 22 | 23 | from datetime import date 24 | import logging 25 | 26 | import musicbrainzngs 27 | from ac2.data.identities import host_uuid, release 28 | 29 | musicbrainzngs.set_useragent( 30 | "audiocontrol2", 31 | host_uuid(), 32 | "https://github.com/hifiberry/audiocontrol2/" + release(), 33 | ) 34 | 35 | 36 | def artist_data(artistname): 37 | try: 38 | data = musicbrainzngs.search_artists(query=artistname, limit=1, strict=False) 39 | if len(data["artist-list"]) >= 1: 40 | return data["artist-list"][0] 41 | except Exception as e: 42 | logging.warning("error while loading musicbrainz data for %s: %s", 43 | artistname, e) 44 | 45 | 46 | def album_data(albumname, artistname=None): 47 | try: 48 | if artistname is None: 49 | data = musicbrainzngs.search_releases(query=albumname, 50 | limit=1, strict=False) 51 | else: 52 | data = musicbrainzngs.search_releases(query=albumname, 53 | artistname=artistname, 54 | limit=1, strict=False) 55 | if len(data["release-list"]) >= 1: 56 | return data["release-list"][0] 57 | except Exception as e: 58 | logging.warning("error while loading musicbrainz data for %s: %s", 59 | albumname, e) 60 | 61 | 62 | def track_data(trackname, artistname=None, releaseid=None): 63 | try: 64 | query = "recording:\"{}\"".format(trackname) 65 | 66 | if releaseid is not None: 67 | data = musicbrainzngs.search_recordings(query=query, 68 | reid=releaseid, 69 | limit=1, strict=False) 70 | elif artistname is not None: 71 | data = musicbrainzngs.search_recordings(query=query, 72 | artistname=artistname, 73 | limit=1, strict=False) 74 | else: 75 | data = musicbrainzngs.search_recordings(query=query, 76 | limit=1, strict=False) 77 | 78 | if len(data["recording-list"]) >= 1: 79 | return data["recording-list"][0] 80 | except Exception as e: 81 | logging.warning("error while loading musicbrainz data for %s: %s", 82 | trackname, e) 83 | 84 | 85 | def enrich_metadata(metadata, improve_artwork=False): 86 | logging.debug("enriching metadata from musicbrainz") 87 | 88 | if metadata.artist is None or metadata.title is None: 89 | logging.debug("artist and/or title undefined, can't enrich metadata") 90 | 91 | # Get album data 92 | if metadata.albummbid is None: 93 | if metadata.albumTitle: 94 | data = album_data(metadata.albumTitle, 95 | artistname=metadata.artist) 96 | if data is not None: 97 | logging.info("found data for %s on musicbrainz", metadata.artist) 98 | metadata.albummbid = data["id"] 99 | else: 100 | logging.info("did not find album %s on musicbrainz", metadata.artist) 101 | 102 | # Get track data 103 | if metadata.mbid is None: 104 | data = track_data(metadata.title, 105 | artistname=metadata.artist, 106 | releaseid=metadata.albummbid) 107 | if data is not None: 108 | logging.info("found data for %s on musicbrainz", metadata.title) 109 | metadata.mbid = data["id"] 110 | 111 | if "tag-list" in data: 112 | for tag in data["tag-list"]: 113 | logging.debug("adding tag %s", tag["name"]) 114 | metadata.add_tag(tag["name"]) 115 | else: 116 | logging.debug("no tags for %s/%s on musicbrainz", 117 | metadata.artist, metadata.title) 118 | 119 | if metadata.artistmbid is None: 120 | try: 121 | ac = data["artist-credit"][0] 122 | metadata.artistmbid = ac["artist"]["id"] 123 | logging.debug("artist mbid: %s", metadata.artistmbid) 124 | except Exception: 125 | logging.debug("did not receive artist mbid data") 126 | 127 | rdate = "9999-99-99" 128 | # Find data when this was first released and get the album mbid 129 | for release in data.get("release-list", []): 130 | if release.get("status", "").lower() == "official": 131 | 132 | if metadata.albummbid is None: 133 | metadata.albummbid = release.get("id") 134 | logging.debug("album mbid: %s", metadata.albummbid) 135 | 136 | d = release.get("date", "9999-99-99") 137 | 138 | # Sometimes only a year is listed 139 | if len(d) == 4: 140 | d = d + "-12-31" 141 | 142 | if d < rdate: 143 | rdate = d 144 | try: 145 | date.fromisoformat(rdate) 146 | logging.debug("setting release date: %s", rdate) 147 | if metadata.releaseDate is None: 148 | metadata.releaseDate = rdate 149 | except: 150 | # ignore invalid dates 151 | pass 152 | 153 | else: 154 | logging.info("did not find recording %s/%s on musicbrainz", 155 | metadata.artist, metadata.title) 156 | 157 | return 158 | 159 | """ 160 | Examples 161 | """ 162 | 163 | """ 164 | print(artist_data("Bruce Springsteen")) 165 | print(artist_data("Springsteen, Bruce")) 166 | print(artist_data("Sprteen, Brce")) 167 | print(artist_data("Springsteen and the E-Street Band")) 168 | """ 169 | 170 | """ 171 | print(artist_data("Wu Tang Clan")) 172 | print(album_data("Enter The Wu-Tang", "0febdcf7-4e1f-4661-9493-b40427de2c13")) 173 | print(album_data("Enter The Wu-Tang (36 Chambers)", "0febdcf7-4e1f-4661-9493-b40427de2c14")) 174 | print(album_data("Enter The Wu-Tang (36 Chambers) [Expanded Edition]")) 175 | """ 176 | 177 | """ 178 | print(artist_data("Springsteen")) 179 | print(track_data("Shadowboxin'")) 180 | print(track_data("Born to run")) 181 | print(track_data("Born to Run", artistid="70248960-cb53-4ea4-943a-edb18f7d336f")) 182 | print(track_data("Born to Run", releaseid="46e5c6a9-b0a7-4e42-9162-8f412705b09d")) 183 | """ 184 | 185 | """ 186 | data = track_data("Born to Run", releaseid="46e5c6a9-b0a7-4e42-9162-8f412705b09d") 187 | for tag in data["tag-list"]: 188 | print(tag) 189 | """ 190 | 191 | -------------------------------------------------------------------------------- /ac2/data/test_coverartarchive.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Created on 06.02.2020 3 | 4 | @author: matuschd 5 | ''' 6 | import unittest 7 | 8 | from ac2.metadata import Metadata 9 | from ac2.data import coverartarchive 10 | 11 | class Test(unittest.TestCase): 12 | 13 | def test_get_cover(self): 14 | md = Metadata() 15 | # A Rush of Blood to the Head, Coldplay 16 | md.artist = "Coldplay" # Necessary as unknown song won't be retrieved 17 | md.albummbid = "219b202d-290e-3960-b626-bf852a63bc50" 18 | self.assertIsNone(md.artUrl) 19 | self.assertIsNone(md.externalArtUrl) 20 | 21 | coverartarchive.enrich_metadata(md) 22 | 23 | self.assertIsNone(md.artUrl) 24 | self.assertIsNotNone(md.externalArtUrl) 25 | 26 | def test_unknown(self): 27 | md = Metadata() 28 | coverartarchive.enrich_metadata(md) 29 | 30 | self.assertIsNone(md.artUrl) 31 | self.assertIsNone(md.externalArtUrl) 32 | 33 | 34 | if __name__ == "__main__": 35 | unittest.main() -------------------------------------------------------------------------------- /ac2/data/test_guess.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Copyright (c) 2019 Modul 9/HiFiBerry 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. 21 | ''' 22 | import unittest 23 | 24 | from ac2.data.guess import ORDER_ARTIST_TITLE, ORDER_TITLE_ARTIST, ORDER_UNKNOWN, \ 25 | guess_order, guess_stream_order 26 | 27 | 28 | class TestGuess(unittest.TestCase): 29 | 30 | def testGuessKnown(self): 31 | test_data = [ 32 | ["Bruce Springsteen","The River"], 33 | ["The XX", "Intro"], 34 | ["Adele", "Tired"], 35 | ["Springsteen","Ghost of Tom Joad"] 36 | ] 37 | 38 | for [artist,title] in test_data: 39 | self.assertEqual(guess_order(artist, title), ORDER_ARTIST_TITLE) 40 | self.assertEqual(guess_order(title, artist), ORDER_TITLE_ARTIST) 41 | 42 | 43 | def testGuessUnknown(self): 44 | test_data = [ 45 | ["unknown","unknown"], 46 | ["-", "-"], 47 | ["asdsdasda","kjhdfgs"] 48 | ] 49 | 50 | for [artist,title] in test_data: 51 | self.assertEqual(guess_order(artist, title), ORDER_UNKNOWN) 52 | self.assertEqual(guess_order(title, artist), ORDER_UNKNOWN) 53 | 54 | def testGuessStream(self): 55 | stream="test" 56 | artist = "Bruce Springsteen" 57 | title = "The River" 58 | 59 | self.assertEqual(guess_stream_order(stream,"unknown","unknown"), ORDER_UNKNOWN) 60 | for _i in range(0,8): 61 | self.assertEqual(guess_stream_order(stream,artist,title), ORDER_ARTIST_TITLE) 62 | self.assertEqual(guess_stream_order(stream,"unknown","unknown"), ORDER_ARTIST_TITLE) 63 | 64 | stream="test2" 65 | self.assertEqual(guess_stream_order(stream,"unknown","unknown"), ORDER_UNKNOWN) 66 | for _i in range(0,4): 67 | self.assertEqual(guess_stream_order(stream,artist,title), ORDER_ARTIST_TITLE) 68 | self.assertEqual(guess_stream_order(stream,title,artist), ORDER_TITLE_ARTIST) 69 | self.assertEqual(guess_stream_order(stream,"unknown","unknown"), ORDER_UNKNOWN) 70 | 71 | 72 | if __name__ == "__main__": 73 | unittest.main() -------------------------------------------------------------------------------- /ac2/data/test_hifiberry.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Copyright (c) 2020 Modul 9/HiFiBerry 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. 21 | ''' 22 | import unittest 23 | from time import sleep 24 | 25 | from ac2.metadata import Metadata 26 | from ac2.data import hifiberry 27 | 28 | 29 | class Test(unittest.TestCase): 30 | 31 | def test_get_cover(self): 32 | md = Metadata() 33 | # A Rush of Blood to the Head, Coldplay 34 | md.artist="Coldplay" 35 | md.mbid="58b961e1-a2ef-4e92-a82b-199b15bb3cd8" 36 | md.albummbid = "219b202d-290e-3960-b626-bf852a63bc50" 37 | self.assertIsNone(md.artUrl) 38 | self.assertIsNone(md.externalArtUrl) 39 | 40 | hifiberry.enrich_metadata(md) 41 | # Cover might be be in cache at the HiFiBerry musicdb, 42 | # in this case try again a few seconds later 43 | if md.externalArtUrl is None: 44 | sleep(5) 45 | hifiberry.enrich_metadata(md) 46 | 47 | self.assertIsNone(md.artUrl) 48 | self.assertIsNotNone(md.externalArtUrl) 49 | 50 | 51 | if __name__ == "__main__": 52 | unittest.main() -------------------------------------------------------------------------------- /ac2/data/test_lastfm.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Created on 06.02.2020 3 | 4 | @author: matuschd 5 | ''' 6 | import unittest 7 | 8 | from ac2.metadata import Metadata 9 | import ac2.data.lastfm as lastfm 10 | 11 | class TestLastFM(unittest.TestCase): 12 | 13 | def test_enrich(self): 14 | # We should be able to get some metadata for this one 15 | md=Metadata("Bruce Springsteen","The River") 16 | lastfm.enrich_metadata(md) 17 | 18 | self.assertIsNotNone(md.externalArtUrl) 19 | self.assertIsNotNone(md.mbid) 20 | self.assertIsNotNone(md.artistmbid) 21 | 22 | 23 | if __name__ == "__main__": 24 | unittest.main() -------------------------------------------------------------------------------- /ac2/dev/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hifiberry/audiocontrol2/3944f96163a282ea99daa3db40f44347c69e7c76/ac2/dev/__init__.py -------------------------------------------------------------------------------- /ac2/dev/dummydata.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Copyright (c) 2018 Modul 9/HiFiBerry 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. 21 | ''' 22 | 23 | from time import sleep 24 | from threading import Thread 25 | from ac2.metadata import Metadata 26 | 27 | 28 | class DummyMetadataCreator(Thread): 29 | """ 30 | A class just use for development. It creates dummy metadata records and 31 | send it to the given MetadataDisplay objects 32 | """ 33 | 34 | def __init__(self, display=None, interval=10): 35 | super().__init__() 36 | self.stop = False 37 | self.interval = interval 38 | self.display = display 39 | 40 | def run(self): 41 | import random 42 | 43 | covers = ["https://images-na.ssl-images-amazon.com/images/I/81R6Jcf5eoL._SL1500_.jpg", 44 | "https://townsquare.media/site/443/files/2013/03/92rage.jpg?w=980&q=75", 45 | "file://unknown.png", 46 | None, 47 | None, 48 | None, 49 | None 50 | ] 51 | songs = [ 52 | ["Madonna", "Like a Virgin"], 53 | ["Rammstein", "Mutter"], 54 | ["Iggy Pop", "James Bond"], 55 | ["Porcupine Tree", "Normal"], 56 | ["Clinton Shorter", "Truth"], 57 | ["Bruce Springsteen", "The River"], 58 | ["Plan B", "Kidz"], 59 | ["The Spooks", "Things I've seen"], 60 | ["Aldous Harding", "The Barrel"] 61 | ] 62 | 63 | states = ["playing", "paused", "stopped"] 64 | 65 | while not(self.stop): 66 | 67 | coverindex = random.randrange(len(covers)) 68 | songindex = random.randrange(len(songs)) 69 | stateindex = random.randrange(len(states)) 70 | 71 | md = Metadata(artist=songs[songindex][0], 72 | title=songs[songindex][1], 73 | artUrl=covers[coverindex], 74 | playerName="dummy", 75 | playerState=states[stateindex]) 76 | if self.display is not None: 77 | self.display.notify(md) 78 | 79 | sleep(self.interval) 80 | 81 | def stop(self): 82 | self.stop = True 83 | -------------------------------------------------------------------------------- /ac2/helpers.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Copyright (c) 2018 Modul 9/HiFiBerry 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. 21 | ''' 22 | 23 | 24 | def array_to_string(arr, do_not_flatten_strings=True): 25 | """ 26 | Converts an array of objects to a comma separated string 27 | """ 28 | res = "" 29 | 30 | if arr is None: 31 | return None 32 | 33 | if do_not_flatten_strings and isinstance(arr, str): 34 | return arr 35 | 36 | if hasattr(arr, '__iter__'): 37 | for part in arr: 38 | if part is not None: 39 | res = res + str(part) + ", " 40 | if len(res) > 1: 41 | return res[:-2] 42 | else: 43 | return "" 44 | else: 45 | return str(arr) 46 | 47 | 48 | """ 49 | A simple function that allows to map attributes to different keys 50 | 51 | e.g. 52 | 53 | dst={} 54 | map_attribute({"k1":"v1"},dst,{"k1":"n1"}) 55 | pritn(dst) 56 | {"k1":"v1"} 57 | """ 58 | def map_attributes(src, dst, mapping, flatten_array=True): 59 | for key in src: 60 | if key in mapping: 61 | if flatten_array: 62 | dst[mapping[key]]=array_to_string(src[key]) 63 | else: 64 | dst[mapping[key]]=src[key] 65 | -------------------------------------------------------------------------------- /ac2/metadata.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Copyright (c) 2020 Modul 9/HiFiBerry 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. 21 | ''' 22 | 23 | import copy 24 | import threading 25 | import logging 26 | from time import time 27 | 28 | from expiringdict import ExpiringDict 29 | 30 | import ac2.data.musicbrainz as musicbrainz 31 | import ac2.data.lastfm as lastfmdata 32 | import ac2.data.fanarttv as fanarttv 33 | import ac2.data.hifiberry as hifiberrydb 34 | import ac2.data.coverartarchive as coverartarchive 35 | from ac2.data.identities import host_uuid 36 | from ac2.data.guess import guess_order, guess_stream_order, \ 37 | ORDER_ARTIST_TITLE, ORDER_TITLE_ARTIST, ORDER_ARTIST_TITLE 38 | from ac2.constants import STATE_PLAYING 39 | 40 | # Use external metadata? 41 | external_metadata = True 42 | 43 | order_cache = ExpiringDict(max_len=10, max_age_seconds=3600) 44 | 45 | class Metadata: 46 | """ 47 | Class to start metadata of a song 48 | """ 49 | 50 | loveSupportedDefault = False 51 | 52 | def __init__(self, artist=None, title=None, 53 | albumArtist=None, albumTitle=None, 54 | artUrl=None, 55 | discNumber=None, trackNumber=None, 56 | playerName=None, playerState="unknown", 57 | streamUrl=None): 58 | self.artist = artist 59 | self.title = title 60 | self.albumArtist = albumArtist 61 | self.albumTitle = albumTitle 62 | self.artUrl = artUrl 63 | self.externalArtUrl = None 64 | self.discNumber = discNumber 65 | self.tracknumber = trackNumber 66 | self.playerName = playerName 67 | self.playerState = playerState 68 | self.streamUrl = streamUrl 69 | self.playCount = None 70 | self.mbid = None 71 | self.artistmbid = None 72 | self.albummbid = None 73 | self.loved = None 74 | self.wiki = None 75 | self.loveSupported = Metadata.loveSupportedDefault 76 | self.tags = [] 77 | self.skipped = False 78 | self.host_uuid = None 79 | self.releaseDate = None 80 | self.trackid = None 81 | self.hifiberry_cover_found=False 82 | self.duration=0 83 | self.time=0 84 | self.position=0 # poosition in seconds 85 | self.positionupdate=time() # last time position has been updated 86 | 87 | # set the current position 88 | def set_position(self, seconds): 89 | self.position=seconds 90 | self.positionupdate=time() 91 | 92 | # get the current position 93 | # if the player is playing, this will automatically update the current position 94 | def get_position(self): 95 | if self.playerState != STATE_PLAYING: 96 | return self.position 97 | else: 98 | return self.position + time()-self.positionupdate 99 | 100 | def sameSong(self, other): 101 | if not isinstance(other, Metadata): 102 | return False 103 | 104 | return self.artist == other.artist and \ 105 | self.title == other.title 106 | 107 | def sameArtwork(self, other): 108 | if not isinstance(other, Metadata): 109 | return False 110 | 111 | return self.artUrl == other.artUrl 112 | 113 | def __eq__(self, other): 114 | if not isinstance(other, Metadata): 115 | return False 116 | 117 | return self.artist == other.artist and \ 118 | self.title == other.title and \ 119 | self.artUrl == other.artUrl and \ 120 | self.albumTitle == other.albumTitle and \ 121 | self.playerName == other.playerName and \ 122 | self.playerState == other.playerState 123 | 124 | def __ne__(self, other): 125 | if not isinstance(other, Metadata): 126 | return True 127 | 128 | return not(self.__eq__(other)) 129 | 130 | def fix_problems(self, guess=True): 131 | """ 132 | Cleanup metadata for known problems 133 | """ 134 | 135 | # MPD web radio stations use different schemes to encode 136 | # artist and title into a title string 137 | # we try to guess here what's used 138 | if (self.artist_unknown() and 139 | self.title is not None): 140 | 141 | if (" - " in self.title): 142 | [data1, data2] = self.title.split(" - ", 1) 143 | elif (", " in self.title): 144 | [data1, data2] = self.title.split(", ", 1) 145 | else: 146 | data1="" 147 | data2="" 148 | 149 | data1=data1.strip() 150 | data2=data2.strip() 151 | 152 | if len(data2) > 0: 153 | 154 | cached_order = order_cache.get(data1+"/"+data2,-1) 155 | 156 | if cached_order>=0: 157 | order = cached_order 158 | elif not(guess) or not(external_metadata): 159 | order = ORDER_ARTIST_TITLE 160 | else: 161 | if self.streamUrl is not None and self.streamUrl.startswith("http"): 162 | order = guess_stream_order(self.streamUrl, data1, data2) 163 | else: 164 | order = guess_order(data1, data2) 165 | 166 | if order == ORDER_TITLE_ARTIST: 167 | self.title = data1 168 | self.artist = data2 169 | else: 170 | self.artist = data1 171 | self.title = data2 172 | 173 | order_cache[data1+"/"+data2] = order 174 | 175 | 176 | 177 | def fill_undefined(self, metadata): 178 | for attrib in metadata.__dict__: 179 | if attrib in self.__dict__ and self.__dict__[attrib] is None: 180 | self.__dict__[attrib] = metadata.__dict__[attrib] 181 | 182 | def add_tag(self, tag): 183 | tag = tag.lower().replace("-", " ") 184 | if not tag in self.tags: 185 | self.tags.append(tag) 186 | 187 | def copy(self): 188 | return copy.copy(self) 189 | 190 | def is_unknown(self): 191 | if self.artist_unknown() or self.title_unknown(): 192 | return True 193 | 194 | return False 195 | 196 | def artist_unknown(self): 197 | if str(self.artist).lower() in ["","none","unknown","unknown artist"]: 198 | return True 199 | else: 200 | return False 201 | 202 | def title_unknown(self): 203 | if str(self.title).lower() in ["","none","unknown","unknown title","unknown song"]: 204 | return True 205 | else: 206 | return False 207 | 208 | def songId(self): 209 | return "{}/{}".format(self.artist, self.title) 210 | 211 | def __str__(self): 212 | return "[{}, {}] {}: {} ({}) {}".format(self.playerName, self.playerState, 213 | self.artist, self.title, 214 | self.albumTitle, self.artUrl) 215 | 216 | 217 | def enrich_metadata(metadata, callback=None): 218 | """ 219 | Add more metadata to a song based on the information that are already 220 | given. These will be retrieved from external sources. 221 | """ 222 | songId = metadata.songId() 223 | 224 | if external_metadata: 225 | 226 | metadata.host_uuid = host_uuid() 227 | 228 | # Try musicbrainzs first 229 | try: 230 | musicbrainz.enrich_metadata(metadata) 231 | except Exception as e: 232 | logging.warning("error when retrieving data from musicbrainz") 233 | logging.exception(e) 234 | 235 | # Then HiFiBerry MusicDB 236 | try: 237 | hifiberrydb.enrich_metadata(metadata) 238 | except Exception as e: 239 | logging.warning("error when retrieving data from hifiberry db") 240 | logging.exception(e) 241 | 242 | # Then Last.FM 243 | try: 244 | lastfmdata.enrich_metadata(metadata) 245 | except Exception as e: 246 | logging.warning("error when retrieving data from last.fm") 247 | logging.exception(e) 248 | 249 | # try Fanart.TV, but without artist picture 250 | try: 251 | fanarttv.enrich_metadata(metadata, allow_artist_picture=False) 252 | except Exception as e: 253 | logging.exception(e) 254 | 255 | # try coverartarchive 256 | try: 257 | coverartarchive.enrich_metadata(metadata) 258 | except Exception as e: 259 | logging.exception(e) 260 | 261 | hifiberrydb.send_update(metadata) 262 | 263 | # still no cover? try to get at least an artist picture 264 | try: 265 | fanarttv.enrich_metadata(metadata, allow_artist_picture=True) 266 | except Exception as e: 267 | logging.exception(e) 268 | 269 | if callback is not None: 270 | callback.update_metadata_attributes(metadata.__dict__, songId) 271 | 272 | 273 | def enrich_metadata_bg(metadata, callback): 274 | md = metadata.copy() 275 | threading.Thread(target=enrich_metadata, args=(md, callback)).start() 276 | 277 | -------------------------------------------------------------------------------- /ac2/ostools.py: -------------------------------------------------------------------------------- 1 | import glob 2 | import subprocess 3 | import logging 4 | import os 5 | 6 | 7 | def run_blocking_command(command): 8 | try: 9 | subprocess.run(command, shell=True, check=True) 10 | except subprocess.CalledProcessError as e: 11 | print(f"Error executing the command: {e}") 12 | 13 | 14 | def get_hw_params(file_path): 15 | try: 16 | with open(file_path, 'r') as f: 17 | return f.read() 18 | except Exception as e: 19 | print(f"Error reading file {file_path}: {e}") 20 | return None 21 | 22 | 23 | def kill_player(processname): 24 | command = "pkill " + processname 25 | run_blocking_command(command) 26 | 27 | 28 | def kill_kill_player(processname): 29 | command = "pkill -KILL " + processname 30 | run_blocking_command(command) 31 | 32 | 33 | def is_alsa_playing(): 34 | hw_params_files = glob.glob('/proc/asound/card*/pcm*/sub*/hw_params') 35 | 36 | for file_path in hw_params_files: 37 | hw_params = get_hw_params(file_path) 38 | if hw_params is not None: 39 | if hw_params.strip() != "closed": 40 | return True 41 | 42 | return False 43 | 44 | 45 | def active_player(): 46 | # Check if the "active-alsa-processes" script exists, use lsof otherwise 47 | script_path = "/opt/hifiberry/bin/active-alsa-processes" 48 | if os.path.exists(script_path): 49 | command = f"{script_path}" 50 | else: 51 | command = "lsof /dev/snd/pcmC*D*p | grep -v COMMAND | awk '{print $1}'" 52 | 53 | procs = [] 54 | try: 55 | output = subprocess.check_output(command, shell=True, text=True) 56 | procs = output.splitlines() 57 | except subprocess.CalledProcessError as e: 58 | # Handle if the command returns a non-zero exit status 59 | logging.exception(e) 60 | 61 | procs = [os.path.basename(p) for p in procs] 62 | 63 | return procs[0] if procs else None 64 | -------------------------------------------------------------------------------- /ac2/players/__init__.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Copyright (c) 2020 Modul 9/HiFiBerry 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. 21 | ''' 22 | 23 | import threading 24 | import logging 25 | import datetime 26 | import time 27 | 28 | control_registry={} 29 | registered_players = None 30 | 31 | class PlayerControl: 32 | 33 | def __init__(self, args): 34 | self.playername 35 | self.supported_commands=[] 36 | 37 | 38 | def start(self): 39 | # This might start a thread that handles updates 40 | pass 41 | 42 | def get_state(self): 43 | return {} 44 | 45 | def send_command(self,command, parameters={}): 46 | pass 47 | 48 | """ 49 | Return a list of the commands that the player supports 50 | This can be dynamic based on the sate of the player 51 | """ 52 | def get_supported_commands(self): 53 | return [] 54 | 55 | 56 | 57 | """ 58 | Checks if a player is active on the system and can result a 59 | state. This does NOT mean this player is running 60 | """ 61 | def is_active(self): 62 | return False 63 | 64 | 65 | def add_control_registry(name,control_class): 66 | global control_registry, registered_players 67 | if name in control_registry: 68 | logging.error("PlayerControl %s already registered", name) 69 | else: 70 | control_registry[name]=control_class 71 | registered_players = None 72 | 73 | 74 | def get_registered_players(): 75 | global registered_players 76 | 77 | if registered_players is None: 78 | registered_players={} 79 | for name in control_registry: 80 | registered_players[name]=control_registry[name]() 81 | 82 | return registered_players 83 | 84 | 85 | 86 | -------------------------------------------------------------------------------- /ac2/players/mpdcontrol.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Copyright (c) 2020 Modul 9/HiFiBerry 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. 21 | ''' 22 | 23 | import logging 24 | 25 | from mpd import MPDClient 26 | 27 | from ac2.helpers import map_attributes 28 | from ac2.players import PlayerControl 29 | from ac2.constants import CMD_NEXT, CMD_PREV, CMD_PAUSE, CMD_PLAYPAUSE, CMD_STOP, CMD_PLAY, CMD_SEEK, \ 30 | CMD_RANDOM, CMD_NORANDOM, CMD_REPEAT_ALL, CMD_REPEAT_NONE, \ 31 | STATE_PAUSED, STATE_PLAYING, STATE_STOPPED, STATE_UNDEF 32 | from ac2.metadata import Metadata 33 | from ac2.data import mpd as mpddata 34 | 35 | MPD_STATE_PLAY = "play" 36 | MPD_STATE_PAUSE = "pause" 37 | MPD_STATE_STOPPED = "stop" 38 | 39 | MPD_ATTRIBUTE_MAP = { 40 | "artist": "artist", 41 | "title": "title", 42 | "albumartist": "albumArtist", 43 | "album": "albumTitle", 44 | "disc": "discNumber", 45 | "track": "tracknumber", 46 | "duration": "duration", 47 | "time": "time", 48 | "file": "streamUrl" 49 | } 50 | 51 | STATE_MAP = { 52 | MPD_STATE_PAUSE: STATE_PAUSED, 53 | MPD_STATE_PLAY: STATE_PLAYING, 54 | MPD_STATE_STOPPED: STATE_STOPPED 55 | } 56 | 57 | 58 | class MPDControl(PlayerControl): 59 | 60 | def __init__(self, args={}): 61 | self.client = None 62 | self.playername = "MPD" 63 | if "port" in args: 64 | self.port = args["port"] 65 | else: 66 | self.port = 6600 67 | 68 | if "host" in args: 69 | self.host = args["host"] 70 | else: 71 | self.host = "localhost" 72 | 73 | if " timeout" in args: 74 | self.timeout = args["timeout"] 75 | else: 76 | self.timeout = 5 77 | 78 | self.connect() 79 | 80 | def start(self): 81 | # No threading implemented 82 | pass 83 | 84 | def connect(self): 85 | if self.client is not None: 86 | return self.client 87 | 88 | self.client = MPDClient() 89 | self.client.timeout = self.timeout 90 | try: 91 | self.client.connect(self.host, self.port) 92 | logging.info("Connected to %s:%s", self.host, self.port) 93 | except: 94 | self.client = None 95 | 96 | def disconnect(self): 97 | if self.client is None: 98 | return 99 | 100 | try: 101 | self.client.close() 102 | self.client.disconnect() 103 | except: 104 | pass 105 | 106 | self.client = None 107 | 108 | def get_supported_commands(self): 109 | return [CMD_NEXT, CMD_PREV, CMD_PAUSE, CMD_PLAYPAUSE, CMD_STOP, CMD_PLAY, CMD_SEEK, 110 | CMD_RANDOM, CMD_NORANDOM, CMD_REPEAT_ALL, CMD_REPEAT_NONE] 111 | 112 | def get_state(self): 113 | if self.client is None: 114 | self.connect() 115 | 116 | if self.client is None: 117 | return {} 118 | 119 | try: 120 | status = self.client.status() 121 | except: 122 | # Connection to MPD might be broken 123 | self.disconnect() 124 | self.connect() 125 | 126 | try: 127 | state = STATE_MAP[status["state"]] 128 | except: 129 | state = STATE_UNDEF 130 | 131 | return state 132 | 133 | def get_meta(self): 134 | state = self.get_state() 135 | 136 | song = None 137 | if state in [STATE_PLAYING, STATE_PAUSED]: 138 | song = self.client.currentsong() 139 | 140 | md = Metadata() 141 | md.playerName = "mpd" 142 | 143 | if song is not None: 144 | map_attributes(song, md.__dict__, MPD_ATTRIBUTE_MAP) 145 | 146 | return md 147 | 148 | def send_command(self, command, parameters={}): 149 | if command not in self.get_supported_commands(): 150 | return False 151 | 152 | if self.client is None: 153 | self.reconnect() 154 | 155 | if self.client is None: 156 | return False 157 | 158 | playstate = None 159 | if command in [CMD_PLAY, CMD_PLAYPAUSE]: 160 | playstate = self.get_state() 161 | 162 | if command == CMD_NEXT: 163 | self.client.next() 164 | elif command == CMD_PREV: 165 | self.client.previous() 166 | elif command == CMD_PAUSE: 167 | self.client.pause(1) 168 | elif command == CMD_STOP: 169 | self.client.stop() 170 | elif command == CMD_RANDOM: 171 | self.client.random(1) 172 | elif command == CMD_NORANDOM: 173 | self.client.random(0) 174 | elif command == CMD_REPEAT_ALL: 175 | self.client.repeat(1) 176 | elif command == CMD_REPEAT_NONE: 177 | self.client.repeat(0) 178 | elif command == CMD_REPEAT_ALL: 179 | self.client.repeat(1) 180 | elif command == CMD_PLAY: 181 | if playstate == STATE_PAUSED: 182 | self.client.pause(0) 183 | else: 184 | self.client.play(0) 185 | elif command == CMD_PLAYPAUSE: 186 | if playstate == STATE_PLAYING: 187 | self.client.pause(1) 188 | else: 189 | self.send_command(CMD_PLAY) 190 | else: 191 | logging.warning("command %s not implemented", command) 192 | 193 | """ 194 | Checks if a player is active on the system and can result a 195 | state. This does NOT mean this player is running 196 | """ 197 | 198 | def is_active(self): 199 | return self.client is not None 200 | -------------------------------------------------------------------------------- /ac2/players/mpris.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Copyright (c) 2020 Modul 9/HiFiBerry 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. 21 | ''' 22 | 23 | import dbus 24 | import logging 25 | from ac2.metadata import Metadata 26 | 27 | from ac2.constants import CMD_NEXT, CMD_PAUSE, CMD_PLAY, CMD_PLAYPAUSE, CMD_PREV, CMD_STOP 28 | from ac2.helpers import array_to_string 29 | 30 | mpris_commands = [CMD_NEXT, CMD_PREV, 31 | CMD_PAUSE, CMD_PLAYPAUSE, 32 | CMD_STOP, CMD_PLAY] 33 | 34 | 35 | MPRIS_PREFIX = "org.mpris.MediaPlayer2." 36 | 37 | 38 | class MPRIS(): 39 | 40 | def __init__(self): 41 | self.bus=None 42 | 43 | def connect_dbus(self): 44 | self.bus = dbus.SystemBus() 45 | self.device_prop_interfaces = {} 46 | 47 | def dbus_get_device_prop_interface(self, name): 48 | proxy = self.bus.get_object(name, "/org/mpris/MediaPlayer2") 49 | device_prop = dbus.Interface( 50 | proxy, "org.freedesktop.DBus.Properties") 51 | return device_prop 52 | 53 | def retrieve_players(self): 54 | """ 55 | Returns a list of all MPRIS enabled players that are active in 56 | the system 57 | """ 58 | return [name for name in self.bus.list_names() 59 | if name.startswith("org.mpris")] 60 | 61 | 62 | def retrieve_state(self, name): 63 | # This must be an MPRIS player 64 | try: 65 | device_prop = self.dbus_get_device_prop_interface(name) 66 | state = device_prop.Get("org.mpris.MediaPlayer2.Player", 67 | "PlaybackStatus") 68 | return state 69 | except Exception as e: 70 | logging.warning("got exception %s while polling MPRIS data", e) 71 | 72 | 73 | 74 | def get_supported_commands(self, name): 75 | commands = { 76 | "pause": "CanPause", 77 | "next": "CanGoNext", 78 | "previous": "CanGoPrevious", 79 | "play": "CanPlay", 80 | "seek": "CanSeek" 81 | } 82 | try: 83 | supported_commands = ["stop"] # Stop must always be supported 84 | device_prop = self.dbus_get_device_prop_interface(name) 85 | for command in commands: 86 | supported = device_prop.Get("org.mpris.MediaPlayer2.Player", 87 | commands[command]) 88 | if supported: 89 | supported_commands.append(command) 90 | except Exception as e: 91 | logging.warning("got exception %s", e) 92 | 93 | return supported_commands 94 | 95 | 96 | 97 | def send_command(self, playername, command): 98 | 99 | if not playername.startswith(MPRIS_PREFIX): 100 | playername=MPRIS_PREFIX + playername 101 | 102 | 103 | try: 104 | if command in mpris_commands: 105 | proxy = self.bus.get_object(playername, 106 | "/org/mpris/MediaPlayer2") 107 | player = dbus.Interface( 108 | proxy, dbus_interface='org.mpris.MediaPlayer2.Player') 109 | 110 | run_command = getattr(player, command, 111 | lambda: "Unknown command") 112 | return run_command() 113 | else: 114 | logging.error("MPRIS command %s not supported", command) 115 | except Exception as e: 116 | logging.error("exception %s while sending MPRIS command %s to %s", 117 | e, command, playername) 118 | return False 119 | 120 | def playername(self, name): 121 | if name is None: 122 | return 123 | if (name.startswith(MPRIS_PREFIX)): 124 | return name[len(MPRIS_PREFIX):] 125 | else: 126 | return name 127 | 128 | 129 | def get_meta(self, name): 130 | """ 131 | Return the metadata for the given player instance 132 | """ 133 | try: 134 | device_prop = self.dbus_get_device_prop_interface(name) 135 | prop = device_prop.Get( 136 | "org.mpris.MediaPlayer2.Player", "Metadata") 137 | try: 138 | artist = array_to_string(prop.get("xesam:artist")) 139 | except: 140 | artist = None 141 | 142 | try: 143 | title = prop.get("xesam:title") 144 | except: 145 | title = None 146 | 147 | try: 148 | albumArtist = array_to_string(prop.get("xesam:albumArtist")) 149 | except: 150 | albumArtist = None 151 | 152 | try: 153 | albumTitle = prop.get("xesam:album") 154 | except: 155 | albumTitle = None 156 | 157 | try: 158 | artURL = prop.get("mpris:artUrl") 159 | except: 160 | artURL = None 161 | 162 | try: 163 | discNumber = prop.get("xesam:discNumber") 164 | except: 165 | discNumber = None 166 | 167 | try: 168 | trackNumber = prop.get("xesam:trackNumber") 169 | except: 170 | trackNumber = None 171 | 172 | md = Metadata(artist, title, albumArtist, albumTitle, 173 | artURL, discNumber, trackNumber) 174 | 175 | try: 176 | md.streamUrl = prop.get("xesam:url") 177 | except: 178 | pass 179 | 180 | try: 181 | md.trackId = prop.get("mpris:trackid") 182 | except: 183 | pass 184 | 185 | 186 | if (name.startswith(MPRIS_PREFIX)): 187 | md.playerName = name[len(MPRIS_PREFIX):] 188 | else: 189 | md.playerName = name 190 | 191 | return md 192 | 193 | except dbus.exceptions.DBusException as e: 194 | if "ServiceUnknown" in e.__class__.__name__: 195 | # unfortunately we can't do anything about this and 196 | # logging doesn't help, therefore just ignoring this case 197 | pass 198 | #  logging.warning("service %s disappered, cleaning up", e) 199 | else: 200 | logging.warning("no mpris data received %s", e.__class__.__name__) 201 | 202 | md = Metadata() 203 | md.playerName = self.playername(name) 204 | return md 205 | -------------------------------------------------------------------------------- /ac2/players/plexamp.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import logging 3 | from xml.etree import ElementTree 4 | 5 | from ac2.players import PlayerControl 6 | from ac2.constants import CMD_NEXT, CMD_PREV, CMD_PAUSE, CMD_PLAY, CMD_STOP, STATE_PAUSED 7 | from ac2.metadata import Metadata 8 | 9 | PLEXAMP_CMD_MAP={ 10 | CMD_NEXT: "skipNext", 11 | CMD_PREV: "skipPrevious", 12 | CMD_PAUSE: "pause", 13 | CMD_PLAY: "play", 14 | CMD_STOP: "stop" 15 | } 16 | 17 | class PlexampControl(PlayerControl): 18 | def __init__(self, args={}): 19 | self.playername = "plexamp" 20 | self.state = STATE_PAUSED 21 | self.port = "32500" 22 | self.host = "127.0.0.1" 23 | 24 | def start(self): 25 | # No threading implemented 26 | pass 27 | 28 | def get_supported_commands(self): 29 | return [CMD_NEXT, CMD_PREV, CMD_PAUSE, CMD_PLAY, CMD_STOP] 30 | 31 | def get_timeline(self): 32 | response = requests.get("http://" + self.host + ":" + self.port + "/player/timeline/poll?wait=1&includeMetadata=1&commandID=1") 33 | return ElementTree.fromstring(response.content) 34 | 35 | def get_state(self): 36 | try: 37 | tree = self.get_timeline() 38 | timeline = tree.find("Timeline") 39 | return timeline.get("state") 40 | except Exception as e: 41 | return "stopped" 42 | 43 | def get_meta(self): 44 | try: 45 | metadata = Metadata() 46 | tree = self.get_timeline() 47 | timeline = tree.find("Timeline") 48 | track = tree.find('Timeline').find("Track") 49 | 50 | metadata.playerName = self.playername 51 | metadata.duration = timeline.get("duration") 52 | metadata.time = timeline.get("time") 53 | metadata.albumTitle = track.get("parentTitle") 54 | metadata.artist = track.get("grandparentTitle") 55 | metadata.title = track.get("title") 56 | metadata.releaseDate = track.get("parentYear") 57 | metadata.position = timeline.get("time") 58 | metadata.artUrl = timeline.get("protocol") + "://" + timeline.get("address") + ":" + timeline.get("port") + "/photo/:/transcode?width=512&height=512&url=" + track.get("thumb") 59 | 60 | return metadata 61 | except Exception as e: 62 | metadata = Metadata() 63 | metadata.playerName = self.playername 64 | return metadata 65 | 66 | def send_command(self,command, parameters={}, mapping=True): 67 | if mapping and command not in self.get_supported_commands(): 68 | return False 69 | 70 | if mapping: 71 | cmd = PLEXAMP_CMD_MAP[command] 72 | else: 73 | cmd = command 74 | 75 | requests.get("http://" + self.host + ":" + self.port + "/player/playback/" + cmd + "?commandID=1&type=music") 76 | 77 | def is_active(self): 78 | return True 79 | -------------------------------------------------------------------------------- /ac2/players/vollibrespot.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Copyright (c) 2020 Modul 9/HiFiBerry 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. 21 | ''' 22 | 23 | import socket 24 | import logging 25 | import threading 26 | import json 27 | import time 28 | 29 | from ac2.helpers import map_attributes 30 | from ac2.players import PlayerControl 31 | from ac2.constants import CMD_NEXT, CMD_PREV, CMD_PAUSE, CMD_PLAYPAUSE, CMD_PLAY, \ 32 | STATE_PAUSED, STATE_PLAYING, STATE_STOPPED 33 | from ac2.metadata import Metadata 34 | 35 | VOLSPOTIFY_HELO = 0x1 36 | VOLSPOTIFY_HEARTBEAT = 0x2 37 | VOLSPOTIFY_TOKEN = 0x3 38 | VOLSPOTIFY_PAUSE = 0x4 39 | VOLSPOTIFY_PLAY = 0x5 40 | VOLSPOTIFY_PLAYPAUSE = 0x6 41 | VOLSPOTIFY_NEXT = 0x7 42 | VOLSPOTIFY_PREV = 0x8 43 | 44 | VOLSPOTIFY_ATTRIBUTE_MAP = { 45 | "album_name": "albumTitle", 46 | "artist_name": "artist", 47 | "track_name": "title" 48 | } 49 | 50 | VOLSPOTIFY_CMD_MAP = { 51 | CMD_NEXT: VOLSPOTIFY_NEXT, 52 | CMD_PREV: VOLSPOTIFY_PREV, 53 | CMD_PAUSE: VOLSPOTIFY_PAUSE, 54 | CMD_PLAYPAUSE: VOLSPOTIFY_PLAYPAUSE, 55 | CMD_PLAY: VOLSPOTIFY_PLAY 56 | } 57 | 58 | MYNAME = "volllibrespot" 59 | 60 | 61 | class VollibspotifyControl(PlayerControl): 62 | 63 | def __init__(self, args={}): 64 | self.client = None 65 | self.playername = MYNAME 66 | self.state = STATE_STOPPED 67 | self.metadata = Metadata() 68 | 69 | if "port" in args: 70 | self.port = args["port"] 71 | else: 72 | self.port = 5030 73 | 74 | if "host" in args: 75 | self.host = args["host"] 76 | else: 77 | self.host = "localhost" 78 | 79 | self.lastupdated = 0 80 | self.tokenupdated = 0 81 | self.token = None 82 | self.access_token = None 83 | 84 | def start(self): 85 | self.listener = VollibspotifyMetadataListener(self) 86 | self.listener.start() 87 | self.tokenrefresher = VollibspotifyTokenRefresher(self) 88 | self.tokenrefresher.start() 89 | 90 | def get_supported_commands(self): 91 | return [CMD_NEXT, CMD_PREV, CMD_PAUSE, CMD_PLAYPAUSE, CMD_PLAY] 92 | 93 | def get_state(self): 94 | # If there was no update form Spotify during the last 30 minutes, 95 | # there's probably nothing playing anymore 96 | if time.time() - self.lastupdated < 1800: 97 | return self.state 98 | else: 99 | return STATE_STOPPED 100 | 101 | def set_state(self, state): 102 | self.state = state 103 | self.report_alive() 104 | 105 | def report_alive(self): 106 | self.lastupdated = time.time() 107 | 108 | def get_meta(self): 109 | return self.metadata 110 | 111 | def send_command(self, command, parameters={}, mapping=True): 112 | if mapping and command not in self.get_supported_commands(): 113 | return False 114 | 115 | if mapping: 116 | cmd = VOLSPOTIFY_CMD_MAP[command] 117 | else: 118 | cmd = command 119 | 120 | serverAddressPort = (self.host, self.port + 1) 121 | UDPClientSocket = socket.socket(family=socket.AF_INET, type=socket.SOCK_DGRAM) 122 | UDPClientSocket.sendto(bytes([cmd]), serverAddressPort) 123 | logging.debug("sent %s to vollibrespot", cmd) 124 | 125 | def is_active(self): 126 | return True 127 | 128 | def __del__(self): 129 | """ 130 | Finish background threads 131 | """ 132 | self.listener.finished = True 133 | self.tokenrefresher.finished = True 134 | 135 | 136 | class VollibspotifyMetadataListener(threading.Thread): 137 | 138 | def __init__(self, control): 139 | threading.Thread.__init__(self) 140 | self.control = control 141 | self.finished = False 142 | 143 | def run(self): 144 | bufferSize = 4096 145 | 146 | # Create a datagram socket 147 | serverSocket = socket.socket(family=socket.AF_INET, type=socket.SOCK_DGRAM) 148 | serverSocket.bind((self.control.host, self.control.port)) 149 | 150 | while(not self.finished): 151 | bytesAddressPair = serverSocket.recvfrom(bufferSize) 152 | message = bytesAddressPair[0] 153 | try: 154 | message = message.decode("utf-8") 155 | except: 156 | logging.warning("can't decode %s", message) 157 | message = "" 158 | 159 | if message == "kSpPlaybackInactive": 160 | self.control.set_state(STATE_PAUSED) 161 | elif message == "kSpSinkInactive": 162 | self.control.set_state(STATE_PAUSED) 163 | elif message == 'kSpDeviceInactive': 164 | self.control.set_state(STATE_STOPPED) 165 | elif message in ["kSpSinkActive", "kSpPlaybackActive"]: 166 | self.control.set_state(STATE_PLAYING) 167 | elif message[0] == '{': 168 | self.parse_message(message) 169 | self.control.report_alive() 170 | elif message in [ "\r\n" , "kSpPlaybackLoading", "kSpDeviceActive"]: 171 | logging.debug("ignoring message %s", message) 172 | self.control.report_alive() 173 | else: 174 | logging.error("Don't know what to do with %s", message) 175 | self.control.report_alive() 176 | 177 | logging.debug("processed %s", message) 178 | 179 | def parse_message(self, message): 180 | try: 181 | data = json.loads(message) 182 | logging.debug(data) 183 | if "metadata" in data: 184 | logging.error(data["metadata"]) 185 | md = Metadata() 186 | map_attributes(data["metadata"], md.__dict__, VOLSPOTIFY_ATTRIBUTE_MAP) 187 | md.artUrl = self.cover_url(data["metadata"]["albumartId"]) 188 | md.playerName = MYNAME 189 | self.control.metadata = md 190 | elif "position_ms" in data: 191 | pos = float(data["position_ms"]) / 1000 192 | self.control.metadata.set_position(pos) 193 | elif "volume" in data: 194 | logging.debug("ignoring volume data") 195 | elif "token" in data: 196 | logging.info("got access_token update") 197 | self.control.access_token = data["token"] 198 | elif 'state' in data: 199 | state = data['state'].get('status') 200 | logging.info("got a state change") 201 | if state == 'pause': 202 | self.control.set_state(STATE_PAUSED) 203 | elif state == 'play': 204 | self.control.set_state(STATE_PLAYING) 205 | else: 206 | logging.warning("don't know how to handle %s", data) 207 | 208 | except Exception as e: 209 | logging.error("error while parsing %s (%s)", message, e) 210 | 211 | def cover_url(self, artids): 212 | if artids is None or len(artids) == 0: 213 | return None 214 | 215 | # Use the last one for now which seems to be the highest resolution 216 | artworkid = artids[len(artids) - 1] 217 | return "https://i.scdn.co/image/" + artworkid 218 | 219 | 220 | # 221 | # A thread that regularly sends token request 222 | # 223 | class VollibspotifyTokenRefresher(threading.Thread): 224 | 225 | def __init__(self, control): 226 | threading.Thread.__init__(self) 227 | self.control = control 228 | self.finished = False 229 | 230 | def run(self): 231 | while (not self.finished): 232 | self.control.send_command(VOLSPOTIFY_HEARTBEAT, mapping=False) 233 | time.sleep(1) 234 | self.control.send_command(VOLSPOTIFY_TOKEN, mapping=False) 235 | logging.debug("sent token request") 236 | time.sleep(1800) 237 | -------------------------------------------------------------------------------- /ac2/plugins/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hifiberry/audiocontrol2/3944f96163a282ea99daa3db40f44347c69e7c76/ac2/plugins/__init__.py -------------------------------------------------------------------------------- /ac2/plugins/control/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hifiberry/audiocontrol2/3944f96163a282ea99daa3db40f44347c69e7c76/ac2/plugins/control/__init__.py -------------------------------------------------------------------------------- /ac2/plugins/control/controller.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Copyright (c) 2019 Modul 9/HiFiBerry 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | The above copyright notice and this permission notice shall be included in all 10 | copies or substantial portions of the Software. 11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 12 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 13 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 14 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 15 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 16 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 17 | SOFTWARE. 18 | ''' 19 | from threading import Thread 20 | 21 | from ac2.constants import STATE_PLAYING, STATE_PAUSED, STATE_STOPPED, STATE_UNDEF 22 | 23 | 24 | class Controller( Thread ): 25 | ''' 26 | Main class for a controller that can handle player and/or volume 27 | control. 28 | It will run as a background thread 29 | ''' 30 | 31 | def __init__( self ): 32 | super().__init__() 33 | self.volumecontrol = None 34 | self.playercontrol = None 35 | self.playerstate = STATE_UNDEF 36 | self.name = "generic controller" 37 | 38 | def set_volume_control( self, volumecontrol ): 39 | self.volumecontrol = volumecontrol 40 | 41 | def set_player_control( self, playercontrol ): 42 | self.playercontrol = playercontrol 43 | 44 | def update_playback_state(self, state): 45 | self.playerstate = state 46 | 47 | def __str__(self): 48 | return self.name 49 | -------------------------------------------------------------------------------- /ac2/plugins/control/keyboard.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Copyright (c) 2019 Modul 9/HiFiBerry 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | The above copyright notice and this permission notice shall be included in all 10 | copies or substantial portions of the Software. 11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 12 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 13 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 14 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 15 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 16 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 17 | SOFTWARE. 18 | ''' 19 | 20 | import asyncio 21 | import logging 22 | 23 | from typing import Dict 24 | from usagecollector.client import report_usage 25 | 26 | from ac2.plugins.control.controller import Controller 27 | from evdev import InputDevice, ecodes, list_devices 28 | 29 | 30 | class Keyboard(Controller): 31 | 32 | def __init__(self, params: Dict[str, str]=None): 33 | super().__init__() 34 | 35 | self.name = "keyboard" 36 | 37 | if params is None or len(params) == 0: 38 | # Default code table that works with this remote control: 39 | # 40 | self.codetable = { 41 | # volume up 42 | 115: "volume_up", 43 | # volume down 44 | 114: "volume_down", 45 | # mute 46 | 113: "mute", 47 | # right 48 | 106: "next", 49 | # left 50 | 105: "previous", 51 | # enter 52 | 28: "playpause", 53 | # up 54 | 103: "previous", 55 | # down 56 | 108: "next", 57 | # Windows play key (untested) 58 | 248: "play", 59 | # Pause key 60 | 19: "pause" 61 | } 62 | else: 63 | self.codetable = {} 64 | for i in params: 65 | self.codetable[int(i)] = params[i] 66 | 67 | def handle_key_event(self, event): 68 | command = self.codetable.get(event.code) 69 | try: 70 | command_run = False 71 | if command == "volume_up": 72 | if self.volumecontrol is not None: 73 | self.volumecontrol.change_volume_percent(5) 74 | command_run = True 75 | else: 76 | logging.info("ignoring %s, no volume control", 77 | command) 78 | 79 | elif command == "volume_down": 80 | if self.volumecontrol is not None: 81 | self.volumecontrol.change_volume_percent(-5) 82 | command_run = True 83 | else: 84 | logging.info("ignoring %s, no volume control", 85 | command) 86 | 87 | elif command == "mute": 88 | if self.volumecontrol is not None: 89 | self.volumecontrol.toggle_mute() 90 | command_run = True 91 | else: 92 | logging.info("ignoring %s, no volume control", 93 | command) 94 | 95 | elif command == "previous": 96 | if self.playercontrol is not None: 97 | self.playercontrol.previous() 98 | command_run = True 99 | else: 100 | logging.info("ignoring %s, no playback control", 101 | command) 102 | 103 | elif command == "next": 104 | if self.playercontrol is not None: 105 | self.playercontrol.next() 106 | command_run = True 107 | else: 108 | logging.info("ignoring %s, no playback control", 109 | command) 110 | 111 | elif command == "playpause": 112 | if self.playercontrol is not None: 113 | self.playercontrol.playpause() 114 | command_run = True 115 | else: 116 | logging.info("ignoring %s, no playback control", 117 | command) 118 | elif command == "play": 119 | if self.playercontrol is not None: 120 | self.playercontrol.play() 121 | command_run = True 122 | else: 123 | logging.info("ignoring %s, no playback control", 124 | command) 125 | elif command == "pause": 126 | if self.playercontrol is not None: 127 | self.playercontrol.pause() 128 | command_run = True 129 | else: 130 | logging.info("ignoring %s, no playback control", 131 | command) 132 | 133 | if command_run: 134 | report_usage("audiocontrol_keyboard_key", 1) 135 | 136 | logging.info("processed %s", command) 137 | 138 | except Exception as e: 139 | logging.warning("problem handling %s (%s)", command, e) 140 | 141 | def run(self): 142 | logging.info("starting keyboard listener") 143 | try: 144 | asyncio.run(self.bind_devices()) 145 | except Exception as e: 146 | logging.exception(e) 147 | logging.error("could not start Keyboard listener, " 148 | "no keyboard detected or no permissions") 149 | 150 | async def bind_devices(self): 151 | keyboards = self.get_keyboards() 152 | await asyncio.gather(*[self.listen(keyboard) for keyboard in keyboards]) 153 | 154 | async def listen(self, dev): 155 | logging.info(f"keyboard listener started for {dev.name}") 156 | async for event in dev.async_read_loop(): 157 | if event.type == ecodes.EV_KEY and event.value == 1: 158 | self.handle_key_event(event) 159 | 160 | def get_keyboards(self): 161 | devices = [InputDevice(path) for path in list_devices()] 162 | devicecount = 0 163 | for device in devices: 164 | 165 | if 1 in device.capabilities(): 166 | if any(x in self.codetable.keys() for x in device.capabilities()[1]): 167 | devicecount += 1 168 | yield device 169 | 170 | logging.info("Found " + str(devicecount) 171 | +"keyboard devices with the required keys") 172 | -------------------------------------------------------------------------------- /ac2/plugins/control/powercontroller.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Copyright (c) 2021 Modul 9/HiFiBerry 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | The above copyright notice and this permission notice shall be included in all 10 | copies or substantial portions of the Software. 11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 12 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 13 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 14 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 15 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 16 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 17 | SOFTWARE. 18 | ''' 19 | 20 | import logging 21 | import time 22 | import os 23 | from typing import Dict 24 | from datetime import timedelta 25 | 26 | from smbus import SMBus 27 | import gpiod 28 | from gpiod.line import Edge 29 | import select 30 | 31 | from ac2.constants import STATE_PLAYING, STATE_UNDEF 32 | from ac2.plugins.control.controller import Controller 33 | from usagecollector.client import report_usage 34 | 35 | ADDRESS = 0x77 36 | 37 | REG_VL = 0xfd 38 | REG_VH = 0xfe 39 | REG_ROTARYCHANGE = 0x0c 40 | REG_LEDMODE = 0x01 41 | REG_LEDR = 0x02 42 | REG_LEDG = 0x03 43 | REG_LEDB = 0x04 44 | REG_BUTTONMODE = 0x05 45 | REG_BUTTONSTATE = 0x06 46 | REG_POWEROFFTIMER = 0x09 47 | REG_BUTTONPOWEROFFTIME = 0x0a 48 | REG_INTERRUPTPIN = 0x0e 49 | 50 | LEDMODE_STATIC = 0 51 | LEDMODE_PULSING = 1 52 | LEDMODE_BLINK = 2 53 | LEDMODE_FLASH = 3 54 | LEDMODE_OFF = 4 55 | 56 | # Use Pi's GPIO15 (RXD) as interrupt pin 57 | INTPINS = { 58 | 0: 0, 59 | 1: 4, 60 | 2: 15, 61 | 3: 14 62 | } 63 | 64 | BUTTONMODE_SHORT_LONG_PRESS = 0 65 | 66 | MIN_VERSION = 4 # requires functionality to set interrupt pin that was introduced in v4 67 | 68 | 69 | def twos_comp(val, bits): 70 | """compute the 2's complement of int value val""" 71 | if (val & (1 << (bits - 1))) != 0: # if sign bit is set e.g., 8bit: 128-255 72 | val = val - (1 << bits) # compute negative value 73 | return val # return positive value as is 74 | 75 | 76 | class Powercontroller(Controller): 77 | """ 78 | Support for the HiFiBerry power controller 79 | """ 80 | 81 | def __init__(self, params: Dict[str, str] = None): 82 | super().__init__() 83 | 84 | self.name = "powercontroller" 85 | self.finished = False 86 | self.bus = SMBus(1) 87 | self.stepsize = 2 88 | self.intpin = 0 89 | self.intpinpi = 0 90 | self.chippath = "/dev/gpiochip0" 91 | 92 | # configure GPIO 93 | try: 94 | self.chip = gpiod.Chip(self.chippath) 95 | logging.info("Initialized GPIO using gpiod") 96 | except Exception as e: 97 | logging.error("Couldn't initialize gpiod, won't load powercontroller module: %s", e) 98 | self.finished = True 99 | 100 | if self.finished: 101 | logging.error("Couldn't initialize GPIO, aborting") 102 | return 103 | 104 | if params is None: 105 | params = {} 106 | 107 | try: 108 | self.intpin = int(params.get("intpin", "1"), base=10) 109 | if self.intpin == 0: 110 | self.intpin = 1 111 | 112 | self.intpinpi = INTPINS[self.intpin] 113 | logging.info("Using controller int pin %s on GPIO %s", self.intpin, self.intpinpi) 114 | except Exception as e: 115 | logging.error("can't read intpin, won't start powercontroller plugin (%s)", e) 116 | self.finished = True 117 | 118 | try: 119 | vl = self.bus.read_byte_data(ADDRESS, REG_VL) 120 | vh = self.bus.read_byte_data(ADDRESS, REG_VH) 121 | version = 256 * vh + vl 122 | logging.info("found powercontroller software version %s on I2C address %s", version, ADDRESS) 123 | if version < MIN_VERSION: 124 | logging.error("version %s is lower than minimal supported version %s, stopping", 125 | version, MIN_VERSION) 126 | self.finished = True 127 | else: 128 | # TODO: report activation 129 | pass 130 | 131 | self.init_controller() 132 | 133 | except Exception as e: 134 | logging.error("no power controller found, ignoring, %s", e) 135 | self.finished = True 136 | 137 | 138 | def init_controller(self): 139 | self.bus.write_byte_data(ADDRESS, REG_BUTTONPOWEROFFTIME, 20) # We deal with this directly 140 | self.bus.write_byte_data(ADDRESS, REG_BUTTONMODE, BUTTONMODE_SHORT_LONG_PRESS) 141 | self.bus.write_byte_data(ADDRESS, REG_INTERRUPTPIN, self.intpin) # Set interrupt pin 142 | self.update_playback_state(STATE_UNDEF) 143 | 144 | def volchange(self, val): 145 | if self.volumecontrol is not None: 146 | self.volumecontrol.change_volume_percent(val) 147 | report_usage("audiocontrol_powercontroller_volume", 1) 148 | else: 149 | logging.info("no volume control, ignoring powercontroller feedback") 150 | 151 | def playpause(self): 152 | if self.playercontrol is not None: 153 | self.playercontrol.playpause() 154 | report_usage("audiocontrol_powercontroller_button", 1) 155 | else: 156 | logging.info("no player control, ignoring press") 157 | 158 | def update_playback_state(self, state): 159 | if self.playerstate != state: 160 | self.playerstate = state 161 | logging.info("Update LED state for state=%s", state) 162 | try: 163 | if state == STATE_PLAYING: 164 | self.bus.write_byte_data(ADDRESS, REG_LEDR, 0) 165 | self.bus.write_byte_data(ADDRESS, REG_LEDG, 100) 166 | self.bus.write_byte_data(ADDRESS, REG_LEDB, 0) 167 | self.bus.write_byte_data(ADDRESS, REG_LEDMODE, LEDMODE_STATIC) 168 | else: 169 | self.bus.write_byte_data(ADDRESS, REG_LEDR, 0) 170 | self.bus.write_byte_data(ADDRESS, REG_LEDG, 0) 171 | self.bus.write_byte_data(ADDRESS, REG_LEDB, 80) 172 | self.bus.write_byte_data(ADDRESS, REG_LEDMODE, LEDMODE_PULSING) 173 | except Exception as e: 174 | logging.error("Could not write to power controller: %s", e) 175 | 176 | def shutdown(self): 177 | logging.info("shutdown initiated by button press") 178 | self.bus.write_byte_data(ADDRESS, REG_LEDR, 100) 179 | self.bus.write_byte_data(ADDRESS, REG_LEDG, 0) 180 | self.bus.write_byte_data(ADDRESS, REG_LEDB, 0) 181 | self.bus.write_byte_data(ADDRESS, REG_LEDMODE, LEDMODE_BLINK) 182 | self.bus.write_byte_data(ADDRESS, REG_POWEROFFTIMER, 30) # poweroff in 30 seconds 183 | 184 | os.system("systemctl poweroff") 185 | 186 | def interrupt_callback(self): 187 | try: 188 | rotary_change = twos_comp(self.bus.read_byte_data(ADDRESS, REG_ROTARYCHANGE), 8) # this is a signed byte 189 | button_state = self.bus.read_byte_data(ADDRESS, REG_BUTTONSTATE) 190 | 191 | logging.info("Received interrupt (rotary_change=%s, button_state=%s)",rotary_change, button_state) 192 | 193 | if rotary_change != 0: 194 | self.volchange(rotary_change * self.stepsize) 195 | 196 | if button_state == 1: 197 | # short press 198 | self.bus.write_byte_data(ADDRESS, REG_BUTTONSTATE, 0) 199 | self.playpause() 200 | elif button_state == 2: 201 | # Long press 202 | self.bus.write_byte_data(ADDRESS, REG_BUTTONSTATE, 0) 203 | self.shutdown() 204 | 205 | 206 | except Exception as e: 207 | logging.error("Couldn't read data from I2C, aborting... (%s)", e) 208 | self.finished = True 209 | 210 | def run(self): 211 | try: 212 | with gpiod.request_lines( 213 | self.chippath, 214 | consumer="async-watch-line-value", 215 | config={ 216 | self.intpinpi: gpiod.LineSettings( 217 | edge_detection=Edge.BOTH, 218 | debounce_period=timedelta(milliseconds=10), 219 | ) 220 | }, 221 | ) as request: 222 | poll = select.poll() 223 | poll.register(request.fd, select.POLLIN) 224 | while True: 225 | # Other fds could be registered with the poll and be handled 226 | # separately using the return value (fd, event) from poll() 227 | poll.poll() 228 | for event in request.read_edge_events(): 229 | self.interrupt_callback() 230 | except Exception as e: 231 | logging.error("Error during event wait or callback: %s", e) 232 | self.finished = True 233 | -------------------------------------------------------------------------------- /ac2/plugins/control/rotary.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Copyright (c) 2019 Modul 9/HiFiBerry 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | The above copyright notice and this permission notice shall be included in all 10 | copies or substantial portions of the Software. 11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 12 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 13 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 14 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 15 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 16 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 17 | SOFTWARE. 18 | ''' 19 | 20 | import logging 21 | from typing import Dict 22 | 23 | from usagecollector.client import report_usage 24 | 25 | from ac2.plugins.control.controller import Controller 26 | 27 | from pyky040 import pyky040 28 | 29 | class Rotary(Controller): 30 | 31 | def __init__(self, params: Dict[str, str]=None): 32 | super().__init__() 33 | 34 | self.clk = 4 35 | self.dt = 17 36 | self.sw = 27 37 | self.step = 5 38 | 39 | self.name = "rotary" 40 | 41 | if params is None: 42 | params={} 43 | 44 | if "clk" in params: 45 | try: 46 | self.clk = int(params["clk"]) 47 | except: 48 | logging.error("can't parse %s",params["clk"]) 49 | 50 | 51 | if "dt" in params: 52 | try: 53 | self.dt = int(params["dt"]) 54 | except: 55 | logging.error("can't parse %s",params["dt"]) 56 | 57 | if "sw" in params: 58 | try: 59 | self.sw = int(params["sw"]) 60 | except: 61 | logging.error("can't parse %s",params["sw"]) 62 | 63 | if "step" in params: 64 | try: 65 | self.step = int(params["step"]) 66 | except: 67 | logging.error("can't parse %s",params["step"]) 68 | 69 | logging.info("initializing rotary controller on GPIOs " 70 | " clk=%s, dt=%s, sw=%s, step=%s%%", 71 | self.clk, self.dt, self.sw, self.step) 72 | 73 | self.encoder = pyky040.Encoder(CLK=self.clk, DT=self.dt, SW=self.sw) 74 | self.encoder.setup(scale_min=0, 75 | scale_max=100, 76 | step=1, 77 | inc_callback=self.increase, 78 | dec_callback=self.decrease, 79 | sw_callback=self.button) 80 | 81 | def increase(self,val): 82 | if self.volumecontrol is not None: 83 | self.volumecontrol.change_volume_percent(self.step) 84 | report_usage("audiocontrol_rotary_volume", 1) 85 | else: 86 | logging.info("no volume control, ignoring rotary control") 87 | 88 | def decrease(self,val): 89 | if self.volumecontrol is not None: 90 | self.volumecontrol.change_volume_percent(-self.step) 91 | report_usage("audiocontrol_rotary_volume", 1) 92 | else: 93 | logging.info("no volume control, ignoring rotary control") 94 | 95 | def button(self): 96 | if self.playercontrol is not None: 97 | self.playercontrol.playpause() 98 | report_usage("audiocontrol_rotary_button", 1) 99 | else: 100 | logging.info("no player control, ignoring press") 101 | 102 | def run(self): 103 | self.encoder.watch() 104 | -------------------------------------------------------------------------------- /ac2/plugins/metadata/__init__.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Copyright (c) 2018 Modul 9/HiFiBerry 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. 21 | ''' 22 | 23 | import threading 24 | import logging 25 | import datetime 26 | import time 27 | 28 | 29 | class MetadataDisplay: 30 | 31 | def __init__(self): 32 | logging.debug("initializing MetadataDisplay instance") 33 | self.notifierthread = None 34 | self.starttime = None 35 | self.async_delay = 1 36 | pass 37 | 38 | def notify(self, metadata): 39 | raise RuntimeError("notify not implemented") 40 | 41 | def notify_async(self, metadata): 42 | # Don't run 2 async notifier threads in parallel 43 | # If there is already one running. wait a bit and try again 44 | if self.notifierthread is not None and self.notifierthread.is_alive(): 45 | time.sleep(self.async_delay) 46 | 47 | if self.notifierthread is not None and self.notifierthread.is_alive(): 48 | logging.info("notifier background thread %s still running after %s seconds, " 49 | "not sending notify", 50 | self.notifierthread, 51 | datetime.datetime.now() - self.notifystarttime) 52 | else: 53 | self.notifierthread = threading.Thread(target=self.notify, 54 | args=(metadata,), 55 | name="notifier thread "+self.__str__()) 56 | self.notifystarttime = datetime.datetime.now() 57 | self.notifierthread.start() 58 | -------------------------------------------------------------------------------- /ac2/plugins/metadata/console.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Copyright (c) 2019 Modul 9/HiFiBerry 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. 21 | ''' 22 | 23 | from ac2.plugins.metadata import MetadataDisplay 24 | from typing import Dict 25 | 26 | class MetadataConsole(MetadataDisplay): 27 | 28 | def __init__(self, _params: Dict[str, str]=None): 29 | super().__init__(self) 30 | pass 31 | 32 | def notify(self, metadata): 33 | print("{:16s}: {}".format(metadata.playerName, metadata)) 34 | 35 | def notify_volume(self, volume): 36 | print(f"Volume changed to {volume}%") 37 | 38 | def __str__(self): 39 | return "console" 40 | -------------------------------------------------------------------------------- /ac2/plugins/metadata/http_post.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Copyright (c) 2018 Modul 9/HiFiBerry 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. 21 | ''' 22 | 23 | from ac2.plugins.metadata import MetadataDisplay 24 | 25 | import requests 26 | import logging 27 | import os 28 | import urllib.parse 29 | 30 | 31 | class MetadataHTTPRequest(MetadataDisplay): 32 | ''' 33 | Post metadata via HTTP 34 | ''' 35 | 36 | def __init__(self, url=None, request_type="json"): 37 | super().__init__() 38 | self.request_type = request_type 39 | self.url = url 40 | pass 41 | 42 | def notify(self, metadata): 43 | 44 | localfile = None 45 | 46 | # enrich_metadata(metadata) 47 | 48 | if metadata.artUrl is not None: 49 | if metadata.artUrl.startswith("file://"): 50 | localfile = metadata.artUrl[7:] 51 | else: 52 | url = urllib.parse.urlparse(metadata.artUrl, scheme="file") 53 | if url.scheme == "file": 54 | localfile = url.path 55 | 56 | if localfile is not None and os.path.isfile(localfile): 57 | # use only file part of path name 58 | metadata.artUrl = "artwork/" + \ 59 | os.path.split(localfile)[1] 60 | 61 | md_dict=metadata.__dict__ 62 | try: 63 | if md_dict.get("artist").lower() == "unknown artist": 64 | md_dict["artist"] = None 65 | except: 66 | # artist is None 67 | pass 68 | 69 | try: 70 | if md_dict.get("title","").lower() == "unknown title": 71 | md_dict["title"] = None 72 | except: 73 | # title is None 74 | pass 75 | 76 | 77 | if (self.request_type == "json"): 78 | try: 79 | r = requests.post(self.url, 80 | json=md_dict, 81 | timeout=10) 82 | logging.info("posted metadata update to %s (%s)", 83 | self.url, 84 | md_dict) 85 | except Exception as e: 86 | logging.error("Exception when posting metadata: %s", e) 87 | return 88 | else: 89 | logging.error("request_type %s not supported", self.request_type) 90 | return 91 | 92 | if (r.status_code > 299) or (r.status_code < 200): 93 | logging.error("got HTTP error %s when posting metadata to %s", 94 | r.status_code, 95 | self.url) 96 | 97 | def notify_volume(self, volume): 98 | pass 99 | 100 | def __str__(self): 101 | return "http" 102 | -------------------------------------------------------------------------------- /ac2/plugins/metadata/lametric.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Copyright (c) 2020 Modul 9/HiFiBerry 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. 21 | ''' 22 | 23 | import logging 24 | import time 25 | import json 26 | from typing import Dict 27 | from urllib.parse import urlparse 28 | from threading import Thread 29 | 30 | from usagecollector.client import report_usage 31 | 32 | from ac2.plugins.metadata import MetadataDisplay 33 | from ac2.simple_http import post_data, retrieve_url 34 | 35 | ACCESS_TOKEN="ZTgxZDY3YTY0MGNhMzhmZjRlN2IzZTJmZjNmMjY5N2M3NWQwODJjYTYwZDk1ZDQxMmJlZmQzMDIxNDM5OWRhMA==" 36 | 37 | class LaMetricPush(MetadataDisplay): 38 | ''' 39 | Post metadata to a LaMetric time device 40 | ''' 41 | 42 | def __init__(self, params: Dict[str, str]={}): 43 | super().__init__() 44 | self.set_ips(params.get("ip", "")) 45 | if len(self.urls)==0: 46 | discover = LaMetricDiscovery(self) 47 | discover.start() 48 | 49 | def set_ips(self, ip_list): 50 | if isinstance(ip_list, str): 51 | ips = [] 52 | for ip in ip_list.split(","): 53 | ips.append(ip.strip()) 54 | 55 | ip_list = ips 56 | 57 | self.urls=[] 58 | for ip in ip_list: 59 | if len(ip)>0: 60 | url = "https://{}:4343/api/v1/dev/widget/update/com.lametric.b647e225d0b81484c19ff25030915e58".format(ip) 61 | self.urls.append(url) 62 | report_usage("audiocontrol_lametric_discovered", 1) 63 | 64 | 65 | def notify(self, metadata): 66 | if metadata.artist is None or metadata.title is None: 67 | logging.debug("ignoring undefined metatdata") 68 | return 69 | 70 | data = { 71 | "frames": [ 72 | { 73 | "text": metadata.artist+"-"+metadata.title, 74 | "icon": "a22046", 75 | "duration": 10000, 76 | } 77 | ] 78 | } 79 | 80 | headers = { 81 | "X-Access-Token": ACCESS_TOKEN, 82 | "Accept": "application/json", 83 | "Cache-Control": "no-cache" 84 | } 85 | 86 | for url in self.urls: 87 | logging.info("sending update to LaMetric at %s",url) 88 | report_usage("audiocontrol_lametric_metadata", 1) 89 | 90 | post_data(url, json.dumps(data), headers=headers, verify=False) 91 | 92 | def notify_volume(self, volume): 93 | pass 94 | 95 | class LaMetricDiscovery(Thread): 96 | 97 | def __init__( self, lametric ): 98 | super().__init__() 99 | self.lametric = lametric 100 | 101 | 102 | def my_broadcasts(self): 103 | import netifaces 104 | res = [] 105 | for iface in netifaces.interfaces(): 106 | try: 107 | config = netifaces.ifaddresses(iface)[netifaces.AF_INET][0] 108 | bcast = config["broadcast"] 109 | if bcast.startswith("127."): 110 | continue 111 | logging.debug("found broadcast address %s",bcast) 112 | res.append(bcast) 113 | except: 114 | pass 115 | return res 116 | 117 | 118 | def run(self): 119 | import socket 120 | import sys 121 | 122 | res = [] 123 | 124 | for dst in self.my_broadcasts(): 125 | st = "upnp:rootdevice" 126 | if len(sys.argv) > 2: 127 | st = sys.argv[2] 128 | msg = [ 129 | 'M-SEARCH * HTTP/1.1', 130 | 'Host:239.255.255.250:1900', 131 | 'ST:%s' % (st,), 132 | 'Man:"ssdp:discover"', 133 | 'MX:1', 134 | ''] 135 | urls = set() 136 | s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) 137 | s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) 138 | s.settimeout(5) 139 | try: 140 | s.sendto('\r\n'.join(msg).encode("utf-8"), (dst, 1900) ) 141 | except Exception as e: 142 | logging.warning("Exception %s when sending to %s",e, dst) 143 | continue 144 | while True: 145 | try: 146 | data, _addr = s.recvfrom(32*1024) 147 | for line in data.decode("utf-8").splitlines(): 148 | if line.startswith("LOCATION:"): 149 | (_loc,url) = line.split(":",1) 150 | urls.add(url.strip()) 151 | except socket.timeout: 152 | break 153 | 154 | for url in urls: 155 | desc = retrieve_url(url, timeout=2) 156 | if desc is None: 157 | continue 158 | 159 | if "urn:schemas-upnp-org:device:LaMetric:1" in desc.text: 160 | o = urlparse(url) 161 | ip = o.hostname 162 | logging.info("found LaMetric at "+ip) 163 | 164 | res.append(ip) 165 | 166 | self.lametric.set_ips(res) 167 | 168 | # 169 | # Demo code 170 | # 171 | 172 | def demo(): 173 | from ac2.metadata import Metadata 174 | 175 | lp = LaMetricPush() 176 | time.sleep(15) 177 | md = Metadata(artist="demo artist", title="demo title") 178 | lp.notify(md) 179 | 180 | if __name__== "__main__": 181 | logging.basicConfig(format='%(levelname)s: %(module)s - %(message)s', 182 | level=logging.DEBUG) 183 | demo() 184 | 185 | -------------------------------------------------------------------------------- /ac2/plugins/metadata/lastfm.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Copyright (c) 2019 Modul 9/HiFiBerry 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. 21 | ''' 22 | 23 | import time 24 | import logging 25 | import datetime 26 | from threading import Thread 27 | 28 | from usagecollector.client import report_usage 29 | 30 | from ac2.plugins.metadata import MetadataDisplay 31 | import pylast 32 | 33 | class ScrobbleSender(Thread): 34 | 35 | def __init__(self, lastfm, metadata): 36 | super().__init__() 37 | self.lastfm = lastfm 38 | self.metadata = metadata 39 | 40 | def run(self): 41 | try: 42 | logging.info("scrobbling " + str(self.metadata)) 43 | unix_timestamp = int(time.mktime( 44 | datetime.datetime.now().timetuple())) 45 | self.lastfm.scrobble( 46 | artist=self.metadata.artist, 47 | title=self.metadata.title, 48 | timestamp=unix_timestamp) 49 | except Exception as e: 50 | logging.error("Could not scrobble %s/%s: %s", 51 | self.metadata.artist, 52 | self.metadata.title, 53 | e) 54 | self.network = None 55 | 56 | 57 | 58 | class LastFMScrobbler(MetadataDisplay): 59 | 60 | def __init__(self, api_key, api_secret, 61 | username, password, 62 | password_hash=None, 63 | network="lastfm"): 64 | 65 | super().__init__() 66 | 67 | if password_hash is None: 68 | password_hash = pylast.md5(password) 69 | 70 | self.username = username 71 | self.password_hash = password_hash 72 | self.networkname = network.lower() 73 | self.api_key = api_key 74 | self.api_secret = api_secret 75 | 76 | self.current_metadata = None 77 | self.starttime = 0 78 | self.network = None 79 | 80 | def get_network(self): 81 | if self.network is not None: 82 | return self.network 83 | 84 | if self.networkname == "lastfm": 85 | self.network = pylast.LastFMNetwork( 86 | api_key=self.api_key, 87 | api_secret=self.api_secret, 88 | username=self.username, 89 | password_hash=self.password_hash) 90 | elif self.netnetworkname == "librefm": 91 | self.network = pylast.LibreFMNetwork( 92 | api_key=self.api_key, 93 | api_secret=self.api_secret, 94 | username=self.username, 95 | password_hash=self.password_hash) 96 | else: 97 | raise RuntimeError("Network {} unknown".format(self.networkname)) 98 | 99 | if self.network is not None: 100 | self.network.enable_caching() 101 | 102 | return self.network 103 | 104 | def love(self, love): 105 | try: 106 | track = self.get_network().get_track(self.current_metadata.artist, 107 | self.current_metadata.title) 108 | if love: 109 | logging.info("sending love to Last.FM") 110 | track.love() 111 | report_usage("audiocontrol_lastfm_love", 1) 112 | else: 113 | logging.info("sending unlove to Last.FM") 114 | track.unlove() 115 | report_usage("audiocontrol_lastfm_love", 1) 116 | except Exception as e: 117 | logging.warning("got exception %s while love/unlove", e) 118 | return False 119 | 120 | return True 121 | 122 | def notify(self, metadata): 123 | """ 124 | Scrobble metadata of last song, store meta data of the current song 125 | """ 126 | 127 | if metadata is not None and metadata.sameSong(self.current_metadata): 128 | self.current_metadata = metadata 129 | logging.debug("updated metadata for current song, not scrobbling now") 130 | return 131 | 132 | # Check if the last song was played at least 30 seconds, otherwise 133 | # don't scrobble it' 134 | now = time.time() 135 | listening_time = (now - self.starttime) 136 | lastsong_md = None 137 | 138 | if listening_time > 30: 139 | lastsong_md = self.current_metadata 140 | else: 141 | logging.debug("not yet logging %s, not listened for at least 30s", 142 | lastsong_md) 143 | 144 | self.starttime = now 145 | logging.info("new song: %s", metadata) 146 | self.current_metadata = metadata 147 | 148 | if (lastsong_md is not None) and not(lastsong_md.is_unknown()): 149 | sender = ScrobbleSender(self.get_network(), lastsong_md) 150 | sender.start() 151 | report_usage("audiocontrol_lastfm_scrobble", 1) 152 | else: 153 | logging.info("no track data, not scrobbling %s", lastsong_md) 154 | 155 | def notify_volume(self, volume): 156 | pass 157 | 158 | def __str__(self): 159 | return "lastfmscrobbler@{}".format(self.networkname) 160 | -------------------------------------------------------------------------------- /ac2/plugins/metadata/postgresql.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Copyright (c) 2018 Modul 9/HiFiBerry 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. 21 | ''' 22 | 23 | from datetime import datetime, timedelta 24 | import logging 25 | from typing import Dict 26 | 27 | from ac2.plugins.metadata import MetadataDisplay 28 | from ac2.metadata import enrich_metadata 29 | 30 | 31 | class MetadataPostgres(MetadataDisplay): 32 | 33 | def __init__(self, params: Dict[str, str]=None): 34 | logging.debug("initializing PostgreSQL scrobbler") 35 | super().__init__() 36 | self.starttimestamp = None 37 | self.conn = None 38 | self.user = params.get("user", "hifiberry") 39 | self.password = params.get("password", "hbos19") 40 | self.host = params.get("host", "127.0.0.1") 41 | self.database = params.get("database","hifiberry") 42 | self.table = params.get("table","scrobbles") 43 | self.currentmetadata = None 44 | logging.info("initialized postgres logger %s@%s:%s/%s", 45 | self.user, 46 | self.host, 47 | self.database, 48 | self.table) 49 | 50 | def notify(self, metadata): 51 | 52 | # Ignore empty notifies 53 | if (metadata.artist is None and metadata.title is None): 54 | logging.debug("no artist and/or title, not logging") 55 | return 56 | 57 | if metadata.sameSong(self.currentmetadata): 58 | # This is still the same song, but some information might have 59 | # been updated 60 | self.currentmetadata = metadata 61 | if metadata.playerState == "playing": 62 | logging.debug("metadata updated, but not yet saved") 63 | return 64 | 65 | # Build dict and store it to database 66 | if self.currentmetadata is not None: 67 | enrich_metadata(self.currentmetadata) 68 | songdict = self.currentmetadata.__dict__ 69 | 70 | # Some fields are not needed 71 | for attrib in ["wiki", "loveSupported"]: 72 | if attrib in songdict: 73 | del songdict[attrib] 74 | 75 | songdict["started"] = self.starttimestamp.isoformat() 76 | songdict["finished"] = datetime.now().isoformat() 77 | 78 | # If a song had been played for less than 10 seconds mark 79 | # is as "skipped" 80 | if (datetime.now() - self.starttimestamp) < timedelta(seconds=10): 81 | songdict["skipped"] = True 82 | 83 | if self.currentmetadata.is_unknown(): 84 | logging.debug("title unknown, not saving to postgresql") 85 | else: 86 | logging.debug("saving metadata to postgresql") 87 | self.write_metadata(songdict) 88 | 89 | logging.debug("started listening to a new song") 90 | self.currentmetadata = metadata 91 | self.starttimestamp = datetime.now() 92 | 93 | def notify_volume(self, volume): 94 | pass 95 | 96 | def write_metadata(self, songdict): 97 | try: 98 | if songdict is None: 99 | return 100 | 101 | if songdict.get("artist") is None or songdict.get("title") is None: 102 | logging.debug("undefined artist/title, won't store in db") 103 | 104 | from psycopg2.extras import Json 105 | conn = self.db_connection() 106 | if conn is None: 107 | logging.info("not connected to database, not scrobbling") 108 | 109 | cur = conn.cursor() 110 | logging.debug("inserting %s", songdict) 111 | cur.execute("INSERT INTO scrobbles (songdata) VALUES (%s) returning id", 112 | [Json(songdict)]) 113 | record_id = cur.fetchone()[0] 114 | # # TODO: Check if the song was only paused for a few minutes 115 | # # in this case, adapt the data previously inserted into the database 116 | 117 | conn.commit() 118 | cur.close() 119 | 120 | except Exception as e: 121 | logging.warning("can't write to database: %s", e) 122 | self.conn = None 123 | 124 | def db_connection(self): 125 | try: 126 | import psycopg2 127 | if self.conn is not None: 128 | return self.conn 129 | 130 | if self.user is not None and self.password is not None: 131 | self.conn = psycopg2.connect(dbname=self.database, 132 | user=self.user, 133 | password=self.password, 134 | host=self.host) 135 | else: 136 | logging.warning("username and/or password missing for db connection") 137 | 138 | except Exception as e: 139 | logging.warning("can't connect to postgresql: %s", e) 140 | self.conn = None 141 | 142 | return self.conn 143 | 144 | def __str__(self): 145 | return "postgres@{}".format(self.host) 146 | -------------------------------------------------------------------------------- /ac2/plugins/volume/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hifiberry/audiocontrol2/3944f96163a282ea99daa3db40f44347c69e7c76/ac2/plugins/volume/__init__.py -------------------------------------------------------------------------------- /ac2/plugins/volume/http.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Copyright (c) 2018 Modul 9/HiFiBerry 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. 21 | ''' 22 | 23 | import requests 24 | import logging 25 | 26 | 27 | class VolumeHTTPRequest(): 28 | ''' 29 | Post volume via HTTP 30 | ''' 31 | 32 | def __init__(self, url=None, request_type="json"): 33 | super() 34 | self.request_type = request_type 35 | self.url = url 36 | pass 37 | 38 | def notify_volume(self, volume_percent): 39 | 40 | if (self.request_type == "json"): 41 | try: 42 | r = requests.post(self.url, json={"percent":volume_percent}) 43 | except Exception as e: 44 | logging.error("Exception when posting metadata: %s", e) 45 | return 46 | else: 47 | logging.error("request_type %s not supported", self.request_type) 48 | return 49 | 50 | if (r.status_code > 299) or (r.status_code < 200): 51 | logging.error("got HTTP error %s when posting metadata to %s", 52 | r.status_code, 53 | self.url) 54 | -------------------------------------------------------------------------------- /ac2/processmapper.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Copyright (c) 2018 Modul 9/HiFiBerry 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. 21 | ''' 22 | 23 | import logging 24 | 25 | 26 | class ProcessMapper: 27 | _instance = None 28 | 29 | def __new__(cls): 30 | if cls._instance is None: 31 | cls._instance = super().__new__(cls) 32 | cls._instance.name_to_process = {} 33 | cls._instance.process_to_name = {} 34 | return cls._instance 35 | 36 | def add_mapping(self, name, process_name): 37 | """ 38 | Add a mapping between a name and a process name. 39 | """ 40 | self.name_to_process[name] = process_name 41 | self.process_to_name[process_name] = name 42 | 43 | def get_process_name(self, name, defaultname=None): 44 | """ 45 | Get the process name corresponding to a given name. 46 | """ 47 | return self.name_to_process.get(name, defaultname) 48 | 49 | def get_name(self, process_name): 50 | """ 51 | Get the name corresponding to a given process name. 52 | """ 53 | return self.process_to_name.get(process_name) 54 | 55 | def load_mappings_from_config(self, config_section): 56 | """ 57 | Load mappings from a section section of a config file. 58 | """ 59 | for name, process_name in config_section.items(): 60 | self.add_mapping(name, process_name) 61 | logging.debug("Added process mapping: %s -> %s", name, process_name) 62 | 63 | -------------------------------------------------------------------------------- /ac2/simple_http.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Copyright (c) 2020 Modul 9/HiFiBerry 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. 21 | ''' 22 | 23 | import logging 24 | from expiringdict import ExpiringDict 25 | 26 | import requests 27 | 28 | from ac2.data.identities import host_uuid, release 29 | 30 | cache = ExpiringDict(max_len=100, 31 | max_age_seconds=600) 32 | negativeCache = ExpiringDict(max_len=100, 33 | max_age_seconds=600) 34 | 35 | 36 | def clear_cache(): 37 | cache.clear() 38 | negativeCache.clear() 39 | 40 | def is_cached(url): 41 | return url in cache 42 | 43 | 44 | def is_negative_cached(url): 45 | return url in negativeCache 46 | 47 | 48 | def retrieve_url(url, headers = {}, params= {}, verify=True, timeout=10): 49 | 50 | if url in cache: 51 | logging.debug("retrieved from cache: %s", url) 52 | return cache[url] 53 | else: 54 | try: 55 | if negativeCache.get(url) is None: 56 | headers['User-agent'] = 'audiocontrol/{}/{}'.format(release(), host_uuid()) 57 | res = requests.get(url, 58 | headers=headers, 59 | verify=verify, 60 | params=params, 61 | timeout=timeout) 62 | cache[url] = res 63 | return res 64 | else: 65 | logging.debug("negative cache hit: %s", url) 66 | except Exception as e: 67 | logging.debug("HTTP exception while retrieving %s: %s", url, e) 68 | negativeCache[url] = True 69 | 70 | 71 | def post_data(url, data, headers = {}, verify=True, timeout=10): 72 | 73 | res = None 74 | try: 75 | headers['User-agent'] = 'audiocontrol/{}/{}'.format(release(), host_uuid()) 76 | res = requests.post(url, 77 | data = data, 78 | headers=headers, 79 | verify = verify, 80 | timeout = timeout) 81 | except Exception as e: 82 | logging.debug("HTTP exception while posting %s: %s", url, e) 83 | 84 | return res -------------------------------------------------------------------------------- /ac2/socketio.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | 4 | import socketio 5 | from bottle import Bottle 6 | from ac2.controller import AudioController 7 | from ac2.metadata import Metadata 8 | 9 | from ac2.plugins.metadata import MetadataDisplay 10 | 11 | _LOGGER = logging.getLogger(__name__) 12 | sio = socketio.Server() 13 | 14 | @sio.event 15 | def connect(sid, environ): 16 | _LOGGER.info("connect client %s", sid) 17 | 18 | @sio.event 19 | def disconnect(sid): 20 | _LOGGER.info("disconnect client %s", sid) 21 | 22 | 23 | class MetadataHandler(socketio.Namespace, MetadataDisplay): 24 | 25 | def __init__(self): 26 | super().__init__(namespace='/metadata') 27 | MetadataDisplay.__init__(self) 28 | self.metadata = Metadata() 29 | 30 | def on_get(self, sid): 31 | _LOGGER.debug("metadata on_get event from %s", sid) 32 | return self.metadata.__dict__ 33 | 34 | def notify(self, metadata): 35 | _LOGGER.debug('metadata notify: %s', json.dumps(self.metadata.__dict__, skipkeys=True)) 36 | self.metadata = metadata 37 | sio.emit("update", self.metadata.__dict__, namespace="/metadata") 38 | 39 | 40 | class PlayerHandler(socketio.Namespace): 41 | 42 | def __init__(self, audio_controller: AudioController): 43 | super().__init__(namespace='/player') 44 | self.audio_controller = audio_controller 45 | 46 | def on_status(self, sid): 47 | _LOGGER.debug("player status event from %s", sid) 48 | return self.audio_controller.states() 49 | 50 | def on_playing(self, sid): 51 | _LOGGER.debug("player playing event from %s", sid) 52 | return self.audio_controller.playing 53 | 54 | def on_play(self, sid): 55 | _LOGGER.debug("player play event from %s", sid) 56 | self.audio_controller.playpause(pause=False) 57 | 58 | def on_pause(self, sid): 59 | _LOGGER.debug("player pause event from %s", sid) 60 | self.audio_controller.playpause(pause=True) 61 | 62 | def on_play_pause(self, sid): 63 | _LOGGER.debug("player playpause event from %s", sid) 64 | self.audio_controller.playpause(pause=None) 65 | 66 | def on_stop(self, sid): 67 | _LOGGER.debug("player stop event from %s", sid) 68 | self.audio_controller.stop() 69 | 70 | def on_next(self, sid): 71 | _LOGGER.debug("player next event from %s", sid) 72 | self.audio_controller.next() 73 | 74 | def on_previous(self, sid): 75 | _LOGGER.debug("player previous event from %s", sid) 76 | self.audio_controller.previous() 77 | 78 | 79 | class VolumeHandler(socketio.Namespace): 80 | 81 | def __init__(self, audio_controller: AudioController): 82 | super().__init__(namespace='/volume') 83 | self.audio_controller = audio_controller 84 | 85 | def notify_volume(self, volume): 86 | _LOGGER.debug('volume update %s', volume) 87 | self.volume = volume 88 | sio.emit("update", {'percent': volume}, namespace="/volume") 89 | 90 | def on_get(self, sid): 91 | _LOGGER.debug("volume get event from %s", sid) 92 | if not self.audio_controller.volume_control: 93 | return {"error": "no volume control available"} 94 | return {"percent": self.audio_controller.volume_control.current_volume()} 95 | 96 | def on_set(self, sid, volume): 97 | _LOGGER.debug("volume set event from %s", sid) 98 | if not self.audio_controller.volume_control: 99 | return {"error": "no volume control available"} 100 | try: 101 | self.audio_controller.volume_control.set_volume(volume['percent']) 102 | except: 103 | return {"error": "volume needs to be sent as json like {'percent': 50}"} 104 | return {"percent": self.audio_controller.volume_control.current_volume()} 105 | 106 | class SocketioAPI(): 107 | def __init__(self, bottle: Bottle, audio_controller: AudioController) -> None: 108 | self.app = socketio.WSGIApp(sio, bottle) 109 | self.player_handler = PlayerHandler(audio_controller) 110 | sio.register_namespace(self.player_handler) 111 | self.metadata_handler = MetadataHandler() 112 | sio.register_namespace(self.metadata_handler) 113 | self.volume_handler = VolumeHandler(audio_controller) 114 | sio.register_namespace(self.volume_handler) 115 | -------------------------------------------------------------------------------- /ac2/test_metadata.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Copyright (c) 2020 Modul 9/HiFiBerry 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. 21 | ''' 22 | 23 | import unittest 24 | 25 | from ac2.metadata import Metadata, enrich_metadata 26 | 27 | class MetaDataTest(unittest.TestCase): 28 | 29 | md_updated = False 30 | 31 | def test_init(self): 32 | md = Metadata( 33 | artist="artist", 34 | title="title", 35 | albumArtist="albumartist", 36 | albumTitle="albumtitle", 37 | artUrl="http://test", 38 | discNumber=1, 39 | trackNumber=2, 40 | playerName="player", 41 | playerState="unknown", 42 | streamUrl="http://stream") 43 | self.assertEqual(md.artist, "artist") 44 | self.assertEqual(md.title, "title") 45 | self.assertEqual(md.albumArtist, "albumartist") 46 | self.assertEqual(md.albumTitle, "albumtitle") 47 | self.assertEqual(md.artUrl, "http://test") 48 | self.assertEqual(md.externalArtUrl, None) 49 | self.assertEqual(md.discNumber, 1) 50 | self.assertEqual(md.tracknumber, 2) 51 | self.assertEqual(md.playerName, "player") 52 | self.assertEqual(md.playerState, "unknown") 53 | 54 | 55 | def test_same_song(self): 56 | md1=Metadata("artist1","song1") 57 | md2=Metadata("artist1","song1", albumTitle="album1") 58 | md3=Metadata("artist1","song1", albumTitle="album2") 59 | md4=Metadata("artist2","song1") 60 | md5=Metadata("","song1") 61 | 62 | self.assertTrue(md1.sameSong(md2)) 63 | self.assertTrue(md1.sameSong(md3)) 64 | self.assertTrue(md2.sameSong(md3)) 65 | 66 | self.assertFalse(md1.sameSong(md4)) 67 | self.assertFalse(md1.sameSong(md5)) 68 | 69 | 70 | def test_same_artwork(self): 71 | md1=Metadata("artist1","song1") 72 | md1.artUrl = "http://art1" 73 | 74 | md2=Metadata("artist1","song1") 75 | md2.artUrl = "http://art1" 76 | md2.externalArtUrl = "http://art2" 77 | 78 | md3=Metadata("artist1","song1") 79 | md3.artUrl = "http://art3" 80 | md3.externalArtUrl = "http://art1" 81 | 82 | self.assertTrue(md1.sameArtwork(md1)) 83 | self.assertTrue(md1.sameArtwork(md2)) 84 | self.assertFalse(md1.sameArtwork(md3)) 85 | self.assertFalse(md2.sameArtwork(md3)) 86 | 87 | def test_tags(self): 88 | 89 | md1=Metadata("artist1","song1") 90 | md1.add_tag("tag1") 91 | md1.add_tag("tag2") 92 | md1.add_tag("tag3") 93 | 94 | self.assertIn("tag1", md1.tags) 95 | self.assertIn("tag2", md1.tags) 96 | self.assertIn("tag3", md1.tags) 97 | 98 | def test_song_id(self): 99 | md1=Metadata("artist1","song1",albumTitle="abum1") 100 | md2=Metadata("artist1","song1",albumTitle="abum2") 101 | md3=Metadata("artist2","song1") 102 | md4=Metadata("artist2","song1",albumTitle="abum1") 103 | 104 | self.assertEqual(md1.songId(),md2.songId()) 105 | self.assertEqual(md3.songId(),md4.songId()) 106 | self.assertNotEqual(md1.songId(),md3.songId()) 107 | self.assertNotEqual(md2.songId(),md3.songId()) 108 | self.assertNotEqual(md1.songId(),md4.songId()) 109 | 110 | 111 | def test_unknown(self): 112 | 113 | md1=Metadata() 114 | md2=Metadata("","") 115 | md3=Metadata("None","None") 116 | md4=Metadata("unknown artist","unknown title") 117 | md5=Metadata("unknown","unknown") 118 | md6=Metadata("artist","") 119 | md7=Metadata(None,"name") 120 | md8=Metadata("Unknown","song") 121 | md9=Metadata("artist","unknown") 122 | md10=Metadata("artist","unknown song") 123 | md11=Metadata("artist","songs") 124 | 125 | self.assertTrue(md1.is_unknown()) 126 | self.assertTrue(md2.is_unknown()) 127 | self.assertTrue(md3.is_unknown()) 128 | self.assertTrue(md4.is_unknown()) 129 | self.assertTrue(md5.is_unknown()) 130 | self.assertTrue(md6.is_unknown()) 131 | self.assertTrue(md7.is_unknown()) 132 | self.assertTrue(md8.is_unknown()) 133 | self.assertTrue(md9.is_unknown()) 134 | self.assertTrue(md10.is_unknown()) 135 | self.assertFalse(md11.is_unknown()) 136 | 137 | 138 | def test_enrich(self): 139 | # We should be able to get some metadata for this one 140 | md=Metadata("Bruce Springsteen","The River") 141 | self.md_updated = False 142 | self.updates = None 143 | song_id = md.songId() 144 | self.song_id = None 145 | 146 | self.assertIsNone(md.artUrl) 147 | self.assertIsNone(md.externalArtUrl) 148 | self.assertFalse(MetaDataTest.md_updated) 149 | enrich_metadata(md, callback=self) 150 | 151 | self.assertIsNotNone(md.externalArtUrl) 152 | self.assertIsNotNone(md.mbid) 153 | self.assertIsNotNone(self.updates) 154 | self.assertIn("externalArtUrl", self.updates) 155 | self.assertIn("mbid",self.updates) 156 | self.assertIn("artistmbid",self.updates) 157 | self.assertIn("albummbid",self.updates) 158 | self.assertEqual(self.song_id, song_id) 159 | 160 | 161 | def test_guess(self): 162 | md=Metadata("","Bruce Springsteen - The River") 163 | md.fix_problems(guess=True) 164 | 165 | self.assertEqual(md.artist,"Bruce Springsteen") 166 | self.assertEqual(md.title,"The River") 167 | 168 | md=Metadata("","The River - Bruce Springsteen") 169 | md.fix_problems(guess=True) 170 | 171 | self.assertEqual(md.artist,"Bruce Springsteen") 172 | self.assertEqual(md.title,"The River") 173 | 174 | md=Metadata("","Michael Kiwanuka - You Ain't The Problem") 175 | md.fix_problems(guess=True) 176 | 177 | self.assertEqual(md.artist,"Michael Kiwanuka") 178 | self.assertEqual(md.title,"You Ain't The Problem") 179 | 180 | 181 | def update_metadata_attributes(self, updates, song_id): 182 | self.updates = updates 183 | self.song_id = song_id 184 | 185 | if __name__ == "__main__": 186 | unittest.main() -------------------------------------------------------------------------------- /ac2/test_simple_http.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Copyright (c) 2020 Modul 9/HiFiBerry 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. 21 | ''' 22 | 23 | import unittest 24 | from datetime import datetime 25 | 26 | from ac2.simple_http import retrieve_url, post_data, is_cached, is_negative_cached, clear_cache 27 | 28 | GOOGLE = "https://google.com" 29 | NOT_EXISTING = "http://does-not-exist.nowhere.none" 30 | BAD_CERT = "https://wrong.host.badssl.com" 31 | POST = "https://webhook.site/d6c0f2b6-c361-4952-bab5-d95bba6a0fc3" 32 | TIMEOUT = "http://2.2.2.2" 33 | 34 | class Test(unittest.TestCase): 35 | 36 | 37 | def test_retrieve(self): 38 | res1 = retrieve_url(GOOGLE) 39 | self.assertIsNotNone(res1) 40 | self.assertTrue("html" in res1.text) 41 | res2 = retrieve_url(GOOGLE) 42 | self.assertEqual(res1, res2) 43 | res = retrieve_url(NOT_EXISTING) 44 | self.assertIsNone(res) 45 | 46 | def test_ssl(self): 47 | self.assertIsNotNone(retrieve_url(GOOGLE)) 48 | self.assertIsNotNone(retrieve_url(BAD_CERT, verify=False)) 49 | clear_cache() 50 | self.assertIsNone(retrieve_url(BAD_CERT)) 51 | clear_cache() 52 | self.assertIsNone(retrieve_url(BAD_CERT, verify=True)) 53 | 54 | def test_post(self): 55 | res = post_data(POST, {"test": "testdata"}) 56 | self.assertIsNotNone(res) 57 | 58 | def test_cache(self): 59 | retrieve_url(GOOGLE) 60 | self.assertTrue(is_cached(GOOGLE)) 61 | self.assertFalse(is_negative_cached(GOOGLE)) 62 | retrieve_url(NOT_EXISTING) 63 | self.assertFalse(is_cached(NOT_EXISTING)) 64 | self.assertTrue(is_negative_cached(NOT_EXISTING)) 65 | 66 | def test_timeout(self): 67 | clear_cache() 68 | t1 = datetime.now() 69 | self.assertIsNone(retrieve_url(TIMEOUT, timeout=5)) 70 | t2 = datetime.now() 71 | # This should take about 5 seconds 72 | self.assertLess((t2-t1).total_seconds(),7) 73 | self.assertLess(4,(t2-t1).total_seconds()) 74 | clear_cache() 75 | t1 = datetime.now() 76 | self.assertIsNone(retrieve_url(TIMEOUT, timeout=1)) 77 | t2 = datetime.now() 78 | self.assertLess((t2-t1).total_seconds(),3) 79 | 80 | 81 | if __name__ == "__main__": 82 | #import sys;sys.argv = ['', 'Test.testName'] 83 | unittest.main() -------------------------------------------------------------------------------- /ac2/version.py: -------------------------------------------------------------------------------- 1 | VERSION="1.0.0" 2 | -------------------------------------------------------------------------------- /ac2/watchdog.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Copyright (c) 2018 Modul 9/HiFiBerry 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. 21 | ''' 22 | 23 | import logging 24 | import os 25 | import time 26 | import signal 27 | 28 | player_mapping = {} 29 | monitored_threads = {} 30 | 31 | 32 | def restart_service(service_name): 33 | if service_name in player_mapping: 34 | for service in player_mapping[service_name]: 35 | cmd = 'systemctl restart {}'.format(service) 36 | os.system(cmd) 37 | else: 38 | logging.warning("don't know how to restart %s", service_name) 39 | 40 | 41 | def add_monitored_thread(thread, name): 42 | monitored_threads[name] = thread 43 | 44 | 45 | def monitor_threads_and_exit(): 46 | all_alive = True 47 | while all_alive: 48 | time.sleep(5) 49 | for threadname in monitored_threads: 50 | thread = monitored_threads[threadname] 51 | if not(thread.is_alive()): 52 | logging.error("Monitored thread %s died, exiting...", threadname) 53 | all_alive = False 54 | 55 | os.kill(os.getpid(), signal.SIGTERM) 56 | time.sleep(5) 57 | os.kill(os.getpid(), signal.SIGKILL) 58 | -------------------------------------------------------------------------------- /audiocontrol2.conf.default: -------------------------------------------------------------------------------- 1 | [system] 2 | startup-finished=/bin/systemd-notify --ready 3 | 4 | [mpris] 5 | auto_pause=1 6 | loop_delay=1 7 | # Ignore spotify until the MPRIS bug is fixed 8 | #ignore=spotifyd 9 | 10 | [webserver] 11 | enable=yes 12 | port=81 13 | 14 | [lastfm] 15 | network=lastfm 16 | username= 17 | password= 18 | 19 | [watchdog] 20 | raat=raat 21 | spotifyd=spotify 22 | lms=lmsmpris 23 | ShairportSync=shairport-sync 24 | 25 | [volume] 26 | mixer_control=Master 27 | 28 | [metadata_post] 29 | url=http://127.0.0.1:80/sources/metadata 30 | 31 | [volume_post] 32 | url=http://127.0.0.1:80/sound/volume 33 | 34 | [privacy] 35 | external_metadata=1 36 | 37 | [controller:ac2.plugins.control.keyboard.Keyboard] 38 | 39 | #[controller:ac2.plugins.control.rotary.Rotary] 40 | #clk = 23 41 | #dt = 24 42 | #sw = 25 43 | #step = 5 44 | 45 | [metadata:ac2.plugins.metadata.lametric.LaMetricPush] 46 | 47 | [controller:ac2.plugins.control.powercontroller.Powercontroller] 48 | intpin=1 49 | 50 | [mpd] 51 | musicdir=/data/library/music 52 | -------------------------------------------------------------------------------- /doc/api.md: -------------------------------------------------------------------------------- 1 | # REST API 2 | 3 | With the REST API you can control players using HTTP requests. At this point, no encryption and authentication are supported. 4 | Be carefull to comunicate via Port 81 and not Port 80, as it is already taken up by the Web Interface and you won't reach the API. 5 | 6 | Also be aware that the http://[name].local changes with how you name your Device. Space is represented by "-" (e.g. Hifi-Berry.local) 7 | 8 | ## Control active player 9 | 10 | Player control commands use POST requests 11 | ``` 12 | /api/player/play 13 | /api/player/pause 14 | /api/player/playpause 15 | /api/player/stop 16 | /api/player/next 17 | /api/player/previous 18 | ``` 19 | 20 | ## Player status 21 | 22 | List of all players with their current status can be retrieved by a GET to 23 | ``` 24 | /api/player/status 25 | ``` 26 | 27 | ## Activate another player 28 | ``` 29 | /api/player/activate/ 30 | ``` 31 | 32 | This will start music playback on the given player. Note that this will just send a 33 | PLAY command to this specific player. Not all players might support this for various reasons: 34 | - player is not enabled 35 | - player is not connected to a server 36 | - player has not active playlist 37 | - player is already running on another server 38 | - ... 39 | 40 | If this player can't become active for any of these reasons, the current player will stay active. 41 | 42 | ## Metadata 43 | 44 | Metadata of the current track can be retrieved by a GET to 45 | ``` 46 | /api/track/metadata 47 | ``` 48 | 49 | ## Love/unlove 50 | 51 | To send a love/unlove to Last.FM (if configured), use a HTTP POST to 52 | 53 | ``` 54 | /api/track/love 55 | /api/track/unlove 56 | ``` 57 | 58 | ## Volume 59 | 60 | ``` 61 | /api/volume 62 | ``` 63 | 64 | This endpoint can be used to get the current volume using a HTTP get request 65 | or set the volume using HTTP POST. 66 | 67 | When setting the volume, use JSON encoding with the volume defined as "percent": 68 | 69 | ``` 70 | curl -X POST -H "Content-Type: application/json" -d '{"percent":"50"}' http://127.0.0.1:80/api/volume 71 | ``` 72 | 73 | If the percent value starts with + or -, it will change the volume by this amount (e.g. "+1" will by 74 | [one louder](https://www.youtube.com/watch?v=_sRhuh8Aphc)) 75 | 76 | To mute/unmute the volume or toggle the mute state use POST requests to 77 | 78 | ``` 79 | /api/volume/mute 80 | /api/volume/unmute 81 | /api/volume/togglemute 82 | ``` 83 | 84 | ``` 85 | curl -X POST http://127.0.0.1:81/api/volume/togglemute 86 | ``` 87 | 88 | ## System 89 | ``` 90 | /api/system/poweroff 91 | ``` 92 | 93 | This endpoint is used to turnoff your device in a controlled maner using an authenticated (header `Authtoken`) HTTP POST request. 94 | 95 | This endpoint is only available if your `/etc/audiocontrol2.conf` includes a secret authorization token (`authtoken`): 96 | ``` 97 | [webserver] 98 | enable=yes 99 | port=81 100 | authtoken=hifiberry 101 | ``` 102 | 103 | ## Examples 104 | 105 | Note that these examples assume audiocontrol to listen on port 80. On HiFiBerryOS, audiocontrol is listening on port 81. Therefore, you will need to change the port number. 106 | 107 | ```console 108 | curl -X post http://127.0.0.1:81/api/player/previous 109 | curl -X post http://127.0.0.1:81/api/track/love 110 | curl http://127.0.0.1:80/api/track/metadata 111 | curl -X POST -H "Content-Type: application/json" -d '{"percent":"+5"}' http://127.0.0.1:81/api/volume 112 | curl -X POST hifiberry.local:81/api/system/poweroff -H "Authtoken: hifiberry" 113 | ``` 114 | -------------------------------------------------------------------------------- /doc/extensions.md: -------------------------------------------------------------------------------- 1 | # Extending Audiocontrol 2 | 3 | Adding more modules for audiocontrol is very simple. 4 | 5 | ## Metadata display 6 | 7 | A metadata display receives updates when metadata change. This can be a new song, but also a change in the player 8 | state (e.g. from playing to paused) 9 | 10 | ``` 11 | class MyDisplay(): 12 | 13 | def notify(self, metadata): 14 | # do something 15 | ``` 16 | 17 | ## Integrating extensions 18 | 19 | Extensions can be integrated using the \[plugin\] section: 20 | 21 | ```[plugins] 22 | plugin_dir=/data/ac2plugins 23 | metadata=MyDisplay 24 | ``` 25 | 26 | plugin_dir defined a directory where modules are located. 27 | metadata is a comma-seperated list of metadata display plugins 28 | -------------------------------------------------------------------------------- /doc/ky040.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hifiberry/audiocontrol2/3944f96163a282ea99daa3db40f44347c69e7c76/doc/ky040.jpg -------------------------------------------------------------------------------- /doc/lametric.md: -------------------------------------------------------------------------------- 1 | # LaMetric extension 2 | 3 | [LaMetric Time](https://store.lametric.com/?rfsn=3238201.c5edf5) is a "connected clock". We like the pixely look of it. 4 | While it's not the ideal device to display long text, it's still fun to use it with audiocontrol and 5 | [HiFiBerryOS](https://hifiberry.com/os). 6 | We wouldn't recommend it as a music player (you have a HiFiBerry for this that will sound much better!), but as we like 7 | it as a display. 8 | 9 | The extension is quite simple (just have a look at the 10 | [source](https://github.com/hifiberry/audiocontrol2/blob/master/ac2/plugins/metadata/lametric.py)). 11 | 12 | It will automatically detect LaMetric Time devices in your local network. No data will be send to LaMetric 13 | servers, everything is running locally. 14 | 15 | To use the extension, you have to do 2 simple steps: 16 | 17 | ## Load the LaMetric module 18 | 19 | Just add the line 20 | ``` 21 | [metadata:ac2.plugins.metadata.lametric.LaMetricPush] 22 | ``` 23 | 24 | to you /etc/audiocontrol2.conf file. If you're using HiFiBerryOS, this line should be there already. 25 | 26 | ## Install the LaMetric app 27 | 28 | Open the LaMetric app on your smartphone, select your device and press the "+" button to add an app. 29 | This will open the LaMetric app store. Just search for "HiFiBerryOS" and install the app. 30 | 31 | ![lm1](lm1.PNG) 32 | ![lm2](lm2.PNG) 33 | ![lm3](lm3.PNG) 34 | ![lm4](lm4.PNG) 35 | 36 | That's it. Audiocontrol will now send artist and song name of the song currently playing to your 37 | LaMetric devices in your local network. 38 | 39 | ## Advanced configuration 40 | 41 | If you have multiple LaMetric devices in your network and you want them to show information from different 42 | players, you can configure the IP address of the LaMetric device like this: 43 | ``` 44 | [metadata:ac2.plugins.metadata.lametric.LaMetricPush] 45 | ip = 10.1.1.23 46 | ``` 47 | -------------------------------------------------------------------------------- /doc/lm1.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hifiberry/audiocontrol2/3944f96163a282ea99daa3db40f44347c69e7c76/doc/lm1.PNG -------------------------------------------------------------------------------- /doc/lm2.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hifiberry/audiocontrol2/3944f96163a282ea99daa3db40f44347c69e7c76/doc/lm2.PNG -------------------------------------------------------------------------------- /doc/lm3.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hifiberry/audiocontrol2/3944f96163a282ea99daa3db40f44347c69e7c76/doc/lm3.PNG -------------------------------------------------------------------------------- /doc/lm4.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hifiberry/audiocontrol2/3944f96163a282ea99daa3db40f44347c69e7c76/doc/lm4.PNG -------------------------------------------------------------------------------- /doc/readme.md: -------------------------------------------------------------------------------- 1 | * [REST API](api.md) 2 | * [Writing extensions](extensions.md) 3 | * [Anatomy of a controller plugin](rotary-controller-plugin.md) 4 | * [Lametric extension](lametric.md) 5 | * [socketio API](socketio_api.md) -------------------------------------------------------------------------------- /doc/rotary-controller-plugin.md: -------------------------------------------------------------------------------- 1 | # Anatomy of a controller plugin 2 | 3 | It's quite easy to extend audiocontrol with plugins that support addional methods to control players and/or volume. 4 | 5 | A simple example is the "rotary controller" module that allows to use a rotary controller connected to some Raspberry Pi GPIOs. 6 | Rotation will be mapped to volume, while the button press will be mapped to play/pause command. 7 | 8 | ## Hardware 9 | 10 | To understand the code a bit, let's first have a look at the hardware. We're using a standard rotary controller 11 | that is often called "KY040". The board is a standard rotary controller with a few external resistors. These are 12 | necessary! You can't connect a rotary controller to the Pi without these. 13 | 14 | ![KY040 rotary controller](ky040.jpg) 15 | 16 | It will be connected to the Pi as follows: 17 | 18 | | Encoder | GPIO Pin Pi | GPIO number | 19 | |---|---|---| 20 | |GND|9|GND| 21 | |+|1|3.3V| 22 | |CLK|7|4| 23 | |DT|11|17| 24 | |SW|13|27| 25 | 26 | Make sure you understand the difference between Raspberry Pi GPIOs and Pin numbers! For the configuration, you need to use the GPIO number, not the physical pin numbers. 27 | 28 | You can also use other unused GPIOs.We use these as they are close together. Cabled are directly soldered onto the Pins 29 | on a HiFiBerry MiniAmp like this: 30 | 31 | ![Soldered onto the MiniAmp](rotary-soldered.jpg) 32 | While soldering onto the MiniAmp does void warranty, the MiniAmp is quite cheap and you might just risk t 33 | his. 34 | ## Software 35 | 36 | The module use for the control is [rotary.py](https://github.com/hifiberry/audiocontrol2/blob/master/ac2/plugins/control/rotary.py) 37 | 38 | Let's have a look on some parts of it to understand what it does: 39 | 40 | ``` 41 | from ac2.plugins.control.controller import Controller 42 | ``` 43 | 44 | This imports the controller base object that will give access to both volume and player control 45 | 46 | ``` 47 | from pyky040 import pyky040 48 | ``` 49 | 50 | This imports a Python module that deals with the rotary control. In Python, you will often find existing modules that do what you 51 | need. Don't reinvent, just use what's already there! 52 | 53 | ``` 54 | class Rotary(Controller): 55 | 56 | def __init__(self, params: Dict[str, str]=None): 57 | super().__init__() 58 | 59 | ``` 60 | 61 | Our controller is inherited from the Controller class, we need to make sure to call its contructor 62 | 63 | ``` 64 | self.clk = 4 65 | self.dt = 17 66 | self.sw = 27 67 | self.step = 5 68 | ``` 69 | 70 | Just some default settings 71 | 72 | ``` 73 | if params is None: 74 | params={} 75 | 76 | if "clk" in params: 77 | try: 78 | self.clk = int(params["clk"]) 79 | except: 80 | logging.error("can't parse %s",params["clk"]) 81 | 82 | 83 | if "dt" in params: 84 | try: 85 | self.dt = int(params["dt"]) 86 | except: 87 | logging.error("can't parse %s",params["dt"]) 88 | 89 | if "sw" in params: 90 | try: 91 | self.sw = int(params["sw"]) 92 | except: 93 | logging.error("can't parse %s",params["sw"]) 94 | 95 | if "step" in params: 96 | try: 97 | self.step = int(params["step"]) 98 | except: 99 | logging.error("can't parse %s",params["step"]) 100 | 101 | logging.info("initializing rotary controller on GPIOs " 102 | " clk=%s,dt=%s,sw=%s, step=%s%", 103 | self.clk, self.dt, self.sw, self.step) 104 | ``` 105 | 106 | Parameters can be read from the audio control config file. If there are configuration in this file, 107 | this code will just parse and use them. 108 | 109 | ``` 110 | self.encoder = pyky040.Encoder(CLK=self.clk, DT=self.dt, SW=self.sw) 111 | self.encoder.setup(scale_min=0, 112 | scale_max=100, 113 | step=1, 114 | inc_callback=self.increase, 115 | dec_callback=self.decrease, 116 | sw_callback=self.button) 117 | ``` 118 | 119 | Initialize the pyky040 module with the setting. We don't use the "scale" attributes in our 120 | application, but they need to be defined. 121 | The code maps three rotary control actions to methods. The will be called on "incease" (rotation 122 | to the left), "decrease" (rotation to the left) and "switch" (button pressed). 123 | 124 | The methods are very simple: 125 | 126 | ``` 127 | def increase(self,val): 128 | if self.volumecontrol is not None: 129 | self.volumecontrol.change_volume_percent(self.step) 130 | else: 131 | logging.info("no volume control, ignoring rotary control") 132 | 133 | def decrease(self,val): 134 | if self.volumecontrol is not None: 135 | self.volumecontrol.change_volume_percent(-self.step) 136 | else: 137 | logging.info("no volume control, ignoring rotary control") 138 | 139 | def button(self): 140 | if self.playercontrol is not None: 141 | self.playercontrol.playpause() 142 | else: 143 | logging.info("no player control, ignoring press") 144 | ``` 145 | 146 | We added some check if a player or volume control object is available. In some cases 147 | (e.g. no alsa volume control configured, these object might be None and we don't want 148 | the plugin to crash these cases. 149 | 150 | Other then that the code is pretty straight-forward. The "increase" method just notifies 151 | the volume control to change the volume by the given step size (in %). The other method 152 | are very similar. 153 | 154 | We're almost done here, only one method is missing: 155 | 156 | ``` 157 | def run(self): 158 | self.encoder.watch() 159 | ``` 160 | 161 | This makes sure, the plugin monitors the status of the GPIO pins the whole time and calls 162 | the configured methods if there is some action. 163 | 164 | ## Configuration 165 | 166 | Once you have configured the plugin, you need to put into one of the directories in audiocontrol's PYTHONPATH 167 | If you're using HiFiBerryOS, we recommend to put it into /data/ac2plugins. Don't put it into /opt/... as these 168 | files will be lost on the next update. 169 | 170 | Now, you can configure it in /etc/audiocontrol2.conf: 171 | 172 | ``` 173 | [controller:ac2.plugins.control.rotary.Rotary] 174 | clk = 4 175 | dt = 17 176 | sw = 27 177 | step = 5 178 | ``` 179 | 180 | The "[controller:ac2.plugins.control.rotary.Rotary]" tells audiocontrol to load the controller class. 181 | The settings in the following lines will be send to the constructor as the "params" argument 182 | -------------------------------------------------------------------------------- /doc/rotary-soldered.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hifiberry/audiocontrol2/3944f96163a282ea99daa3db40f44347c69e7c76/doc/rotary-soldered.jpg -------------------------------------------------------------------------------- /doc/rpigpio.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hifiberry/audiocontrol2/3944f96163a282ea99daa3db40f44347c69e7c76/doc/rpigpio.png -------------------------------------------------------------------------------- /doc/socketio_api.md: -------------------------------------------------------------------------------- 1 | # Socketio API 2 | With this API you can control players with socketio. It is possible the register for metadata and volume events. 3 | 4 | # Howto enable Socketio API 5 | This API is only available if it is enabled in your `etc/audiocontrol2.conf`: 6 | ``` 7 | [webserver] 8 | enable=yes 9 | port=81 10 | socketio_enabled=True 11 | ``` 12 | 13 | # Example client 14 | This client prints the metadata json dump whenever new metadata is available in audiocontrol2. 15 | Use pip install "python-socketio[asyncio_client]" to install socketio with the async client. Be aware to use the same version of socketio as install on HiFiBerryOS (5.4.0 at the time of writing). 16 | 17 | ``` 18 | import asyncio 19 | import json 20 | import socketio 21 | 22 | sio = socketio.AsyncClient() 23 | 24 | @sio.event 25 | async def connect(): 26 | print('connection established') 27 | 28 | @sio.event(namespace="/metadata") 29 | async def update(data): 30 | print('metadata update: %s', json.dumps(data)) 31 | 32 | @sio.event 33 | async def disconnect(): 34 | print('disconnected from server') 35 | 36 | async def main(): 37 | await sio.connect('http://hifiberry.local:81') 38 | await sio.wait() 39 | 40 | if __name__ == '__main__': 41 | asyncio.run(main()) 42 | ``` 43 | -------------------------------------------------------------------------------- /links.txt: -------------------------------------------------------------------------------- 1 | Media player symbols in Unicode 2 | https://en.wikipedia.org/wiki/Media_control_symbols 3 | 4 | Material design icons 5 | https://material.io/resources/icons/?icon=play_arrow&style=baseline 6 | https://github.com/google/material-design-icons 7 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pyalsaaudio 2 | musicbrainzngs 3 | expiringdict 4 | netifaces 5 | # git+https://github.com/hifiberry/usagecollector@master 6 | python-Levenshtein 7 | musicbrainzngs 8 | python-mpd2 9 | requests 10 | dbus-python 11 | pylast 12 | python-socketio 13 | gevent 14 | gevent-websocket 15 | evdev -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | from setuptools.command.install_scripts import install_scripts as _install_scripts 3 | from pathlib import Path 4 | import os 5 | 6 | # Custom command to modify the generated script 7 | class CustomInstallScripts(_install_scripts): 8 | def run(self): 9 | super().run() # Run the standard install_scripts command 10 | # Modify the installed scripts 11 | for script in self.get_outputs(): 12 | if os.path.basename(script) == "audiocontrol2": 13 | self.modify_script(script) 14 | 15 | def modify_script(self, script_path): 16 | # Read the original script 17 | with open(script_path, "r") as f: 18 | original_content = f.read() 19 | 20 | # Replace the dynamic entry point resolution with direct import 21 | custom_content = ( 22 | "#!/usr/bin/python3\n" 23 | "from ac2.audiocontrol2 import main\n" 24 | "if __name__ == '__main__':\n" 25 | " main()\n" 26 | ) 27 | 28 | # Write the modified script 29 | with open(script_path, "w") as f: 30 | f.write(custom_content) 31 | print(f"Customized script: {script_path}") 32 | 33 | # Your other setup configuration 34 | description = "Tool to handle multiple audio players" 35 | long_description = Path("README.md").read_text() if Path("README.md").exists() else description 36 | from ac2.version import VERSION 37 | 38 | setup( 39 | name="audiocontrol2", 40 | version=VERSION, 41 | description=description, 42 | long_description=long_description, 43 | author="HiFiBerry", 44 | author_email="support@hifiberry.com", 45 | url="https://github.com/hifiberry/audiocontrol2", 46 | packages=find_packages(), 47 | install_requires=[ 48 | "gevent", 49 | "gevent-websocket", 50 | "socketio", 51 | "bottle", 52 | "expiringdict", 53 | "musicbrainzngs", 54 | "mpd", 55 | "dbus", 56 | "pylast", 57 | "usagecollector", 58 | "netifaces", 59 | "requests", 60 | "evdev", 61 | ], 62 | entry_points={ 63 | "console_scripts": [ 64 | "audiocontrol2=ac2.audiocontrol2:main", 65 | ], 66 | }, 67 | classifiers=[ 68 | "Programming Language :: Python :: 3", 69 | "License :: OSI Approved :: MIT License", 70 | "Operating System :: OS Independent", 71 | ], 72 | python_requires=">=3.6", 73 | include_package_data=True, 74 | cmdclass={"install_scripts": CustomInstallScripts}, 75 | ) 76 | 77 | 78 | -------------------------------------------------------------------------------- /test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hifiberry/audiocontrol2/3944f96163a282ea99daa3db40f44347c69e7c76/test/__init__.py -------------------------------------------------------------------------------- /test/keyboard_test.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from time import sleep 3 | 4 | import pytest 5 | from ac2.plugins.control.keyboard import Keyboard 6 | from evdev import UInput, ecodes as e 7 | 8 | 9 | @pytest.fixture 10 | def test_keyboard(): 11 | cap = { 12 | e.EV_KEY : [e.KEY_UP, e.KEY_DOWN, e.KEY_ENTER] 13 | } 14 | ui = UInput(cap, name='test_keyboard', version=0x3) 15 | yield ui 16 | 17 | def test_listener_started(caplog, test_keyboard): 18 | caplog.set_level(logging.DEBUG) 19 | 20 | keyboard = Keyboard() 21 | keyboard.daemon = True 22 | keyboard.start() 23 | sleep(1) 24 | 25 | assert "keyboard listener started for test_keyboard" in caplog.text 26 | 27 | def test_previous(caplog, test_keyboard): 28 | caplog.set_level(logging.DEBUG) 29 | 30 | keyboard = Keyboard() 31 | keyboard.daemon = True 32 | keyboard.start() 33 | sleep(1) 34 | 35 | assert "keyboard listener started for test_keyboard" in caplog.text 36 | 37 | test_keyboard.write(e.EV_KEY, e.KEY_UP, 1) 38 | test_keyboard.write(e.EV_KEY, e.KEY_UP, 0) 39 | test_keyboard.syn() 40 | 41 | sleep(1) 42 | assert "processed previous" in caplog.text 43 | assert "ignoring previous, no playback control" in caplog.text 44 | 45 | def test_with_player_control(caplog, test_keyboard, mocker): 46 | caplog.set_level(logging.DEBUG) 47 | 48 | keyboard = Keyboard() 49 | player_control = mocker.Mock() 50 | keyboard.set_player_control(player_control) 51 | keyboard.daemon = True 52 | keyboard.start() 53 | sleep(1) 54 | 55 | assert "keyboard listener started for test_keyboard" in caplog.text 56 | 57 | test_keyboard.write(e.EV_KEY, e.KEY_UP, 1) 58 | test_keyboard.write(e.EV_KEY, e.KEY_UP, 0) 59 | test_keyboard.syn() 60 | 61 | sleep(1) 62 | assert "processed previous" in caplog.text 63 | assert player_control.previous.called_once() 64 | assert "ignoring previous, no playback control" not in caplog.text 65 | -------------------------------------------------------------------------------- /tpl/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | {{get('playerName', 'unknown')}}/{{get('playerState', 'unknown')}} 15 | 16 | 172 | 173 | 179 | 180 | 181 | 182 | 183 | 184 |
185 | 186 |
187 |
{{get('artist', 'unknown')}}:  
188 |
{{get('title', 'unknown')}}
189 |
190 | 191 |
192 |
{{get('albumTitle', 'unknown')}}
193 |
194 | 195 | 196 |
197 | 198 |
199 | 200 | 203 | 204 | 210 | 211 |
212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | --------------------------------------------------------------------------------