├── .gitignore ├── LICENSE ├── README.md ├── beetsplug ├── __init__.py └── beetstream │ ├── __init__.py │ ├── albums.py │ ├── artists.py │ ├── coverart.py │ ├── dummy.py │ ├── playlistprovider.py │ ├── playlists.py │ ├── search.py │ ├── songs.py │ ├── stream.py │ ├── users.py │ └── utils.py ├── missing-endpoints.md ├── pyproject.toml ├── requirements.txt └── setup.cfg /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,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 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Sacha Bron 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 | # Beetstream 2 | 3 | Beetstream is a [Beets.io](https://beets.io) plugin that exposes [SubSonic API endpoints](http://www.subsonic.org/pages/api.jsp), allowing you to stream your music everywhere. 4 | 5 | ## Motivation 6 | 7 | I personally use Beets to manage my music library on a Raspberry Pi but when I was looking for a way to stream it to my phone I couldn't find any comfortable, suitable and free options. 8 | I tried [AirSonic](https://airsonic.github.io) and [SubSonic](http://www.subsonic.org), [Plex](https://www.plex.tv) and some other tools but a lot of these solutions want to manage the library as they need (but I prefer Beets) and AirSonic/SubSonic were quite slow and CPU intensive and seemed to have a lot of overhead just to browse albums and send music files. Thus said, SubSonic APIs are good and implemented by a lot of different [clients](#supported-clients), so I decided to re-implement the server side but based on Beets database (and some piece of code). 9 | 10 | ## Install & Run 11 | 12 | Requires Python 3.8 or newer. 13 | 14 | 1) First of all, you need to [install Beets](https://beets.readthedocs.io/en/stable/guides/main.html): 15 | 16 | 2) Install the dependancies with: 17 | 18 | ``` 19 | $ pip install beetstream 20 | ``` 21 | 22 | 3) Enable the plugin for Beets in your config file `~/.config/beets/config.yaml`: 23 | ```yaml 24 | plugins: beetstream 25 | ``` 26 | 27 | 4) **Optional** You can change the host and port in your config file `~/.config/beets/config.yaml`. 28 | You can also chose to never re-encode files even if the clients asks for it with the option `never_transcode: True`. This can be useful if you have a weak CPU or a lot of clients. 29 | 30 | Here are the default values: 31 | ```yaml 32 | beetstream: 33 | host: 0.0.0.0 34 | port: 8080 35 | never_transcode: False 36 | ``` 37 | 38 | 5) Run with: 39 | ``` 40 | $ beet beetstream 41 | ``` 42 | 43 | ## Clients Configuration 44 | 45 | ### Authentication 46 | 47 | There is currently no security whatsoever. You can put whatever user and password you want in your favorite app. 48 | 49 | ### Server and Port 50 | 51 | Currently runs on port `8080`. i.e: `https://192.168.1.10:8080`. You can configure it in `~/.config/beets/config.yaml`. Defaults are: 52 | ```yaml 53 | beetstream: 54 | host: 0.0.0.0 55 | port: 8080 56 | ``` 57 | 58 | ## Supported Clients 59 | 60 | All clients below are working with this server. By "working", it means one can use most of the features, browse library and most importantly play music! 61 | 62 | ### Android 63 | 64 | - [Subsonic](https://play.google.com/store/apps/details?id=net.sourceforge.subsonic.androidapp) (official app) 65 | - [DSub](https://play.google.com/store/apps/details?id=github.daneren2005.dsub) 66 | - [Audinaut](https://play.google.com/store/apps/details?id=net.nullsum.audinaut) 67 | - [Ultrasonic](https://play.google.com/store/apps/details?id=org.moire.ultrasonic) 68 | - [GoSONIC](https://play.google.com/store/apps/details?id=com.readysteadygosoftware.gosonic) 69 | - [Subtracks](https://play.google.com/store/apps/details?id=com.subtracks) 70 | - [Music Stash](https://play.google.com/store/apps/details?id=com.ghenry22.mymusicstash) 71 | - [substreamer](https://play.google.com/store/apps/details?id=com.ghenry22.substream2) 72 | 73 | ### Desktop 74 | 75 | - [Clementine](https://www.clementine-player.org) 76 | 77 | ### Web 78 | 79 | - [Jamstash](http://jamstash.com) ([Chrome App](https://chrome.google.com/webstore/detail/jamstash/jccdpflnecheidefpofmlblgebobbloc)) 80 | - [SubFire](http://subfireplayer.net) 81 | 82 | _Currently supports a subset of API v1.16.1, avaiable as Json, Jsonp and XML._ 83 | 84 | ## Contributing 85 | 86 | There is still some [missing endpoints](missing-endpoints.md) and `TODO` in the code. 87 | Feel free to create some PR! 88 | -------------------------------------------------------------------------------- /beetsplug/__init__.py: -------------------------------------------------------------------------------- 1 | from pkgutil import extend_path 2 | __path__ = extend_path(__path__, __name__) 3 | -------------------------------------------------------------------------------- /beetsplug/beetstream/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # This file is part of beets. 3 | # Copyright 2016, Adrian Sampson. 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining 6 | # a copy of this software and associated documentation files (the 7 | # "Software"), to deal in the Software without restriction, including 8 | # without limitation the rights to use, copy, modify, merge, publish, 9 | # distribute, sublicense, and/or sell copies of the Software, and to 10 | # permit persons to whom the Software is furnished to do so, subject to 11 | # the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be 14 | # included in all copies or substantial portions of the Software. 15 | 16 | """Beetstream is a Beets.io plugin that exposes SubSonic API endpoints.""" 17 | from beets.plugins import BeetsPlugin 18 | from beets import config 19 | from beets import ui 20 | import flask 21 | from flask import g 22 | from flask_cors import CORS 23 | 24 | ARTIST_ID_PREFIX = "1" 25 | ALBUM_ID_PREFIX = "2" 26 | SONG_ID_PREFIX = "3" 27 | 28 | # Flask setup. 29 | app = flask.Flask(__name__) 30 | 31 | @app.before_request 32 | def before_request(): 33 | g.lib = app.config['lib'] 34 | 35 | @app.route('/') 36 | def home(): 37 | return "Beetstream server running" 38 | 39 | from beetsplug.beetstream.utils import * 40 | import beetsplug.beetstream.albums 41 | import beetsplug.beetstream.artists 42 | import beetsplug.beetstream.coverart 43 | import beetsplug.beetstream.dummy 44 | import beetsplug.beetstream.playlists 45 | import beetsplug.beetstream.search 46 | import beetsplug.beetstream.songs 47 | import beetsplug.beetstream.users 48 | 49 | # Plugin hook. 50 | class BeetstreamPlugin(BeetsPlugin): 51 | def __init__(self): 52 | super(BeetstreamPlugin, self).__init__() 53 | self.config.add({ 54 | 'host': u'0.0.0.0', 55 | 'port': 8080, 56 | 'cors': '*', 57 | 'cors_supports_credentials': True, 58 | 'reverse_proxy': False, 59 | 'include_paths': False, 60 | 'never_transcode': False, 61 | 'playlist_dir': '', 62 | }) 63 | 64 | def commands(self): 65 | cmd = ui.Subcommand('beetstream', help=u'run Beetstream server, exposing SubSonic API') 66 | cmd.parser.add_option(u'-d', u'--debug', action='store_true', 67 | default=False, help=u'debug mode') 68 | 69 | def func(lib, opts, args): 70 | args = ui.decargs(args) 71 | if args: 72 | self.config['host'] = args.pop(0) 73 | if args: 74 | self.config['port'] = int(args.pop(0)) 75 | 76 | app.config['lib'] = lib 77 | # Normalizes json output 78 | app.config['JSONIFY_PRETTYPRINT_REGULAR'] = False 79 | 80 | app.config['INCLUDE_PATHS'] = self.config['include_paths'] 81 | app.config['never_transcode'] = self.config['never_transcode'] 82 | playlist_dir = self.config['playlist_dir'] 83 | if not playlist_dir: 84 | try: 85 | playlist_dir = config['smartplaylist']['playlist_dir'].get() 86 | except: 87 | pass 88 | app.config['playlist_dir'] = playlist_dir 89 | 90 | # Enable CORS if required. 91 | if self.config['cors']: 92 | self._log.info(u'Enabling CORS with origin: {0}', 93 | self.config['cors']) 94 | app.config['CORS_ALLOW_HEADERS'] = "Content-Type" 95 | app.config['CORS_RESOURCES'] = { 96 | r"/*": {"origins": self.config['cors'].get(str)} 97 | } 98 | CORS( 99 | app, 100 | supports_credentials=self.config[ 101 | 'cors_supports_credentials' 102 | ].get(bool) 103 | ) 104 | 105 | # Allow serving behind a reverse proxy 106 | if self.config['reverse_proxy']: 107 | app.wsgi_app = ReverseProxied(app.wsgi_app) 108 | 109 | # Start the web application. 110 | app.run(host=self.config['host'].as_str(), 111 | port=self.config['port'].get(int), 112 | debug=opts.debug, threaded=True) 113 | cmd.func = func 114 | return [cmd] 115 | 116 | class ReverseProxied(object): 117 | '''Wrap the application in this middleware and configure the 118 | front-end server to add these headers, to let you quietly bind 119 | this to a URL other than / and to an HTTP scheme that is 120 | different than what is used locally. 121 | 122 | In nginx: 123 | location /myprefix { 124 | proxy_pass http://192.168.0.1:5001; 125 | proxy_set_header Host $host; 126 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 127 | proxy_set_header X-Scheme $scheme; 128 | proxy_set_header X-Script-Name /myprefix; 129 | } 130 | 131 | From: http://flask.pocoo.org/snippets/35/ 132 | 133 | :param app: the WSGI application 134 | ''' 135 | def __init__(self, app): 136 | self.app = app 137 | 138 | def __call__(self, environ, start_response): 139 | script_name = environ.get('HTTP_X_SCRIPT_NAME', '') 140 | if script_name: 141 | environ['SCRIPT_NAME'] = script_name 142 | path_info = environ['PATH_INFO'] 143 | if path_info.startswith(script_name): 144 | environ['PATH_INFO'] = path_info[len(script_name):] 145 | 146 | scheme = environ.get('HTTP_X_SCHEME', '') 147 | if scheme: 148 | environ['wsgi.url_scheme'] = scheme 149 | return self.app(environ, start_response) 150 | -------------------------------------------------------------------------------- /beetsplug/beetstream/albums.py: -------------------------------------------------------------------------------- 1 | from beetsplug.beetstream.utils import * 2 | from beetsplug.beetstream import app 3 | import flask 4 | from flask import g, request, Response 5 | import xml.etree.cElementTree as ET 6 | from PIL import Image 7 | import io 8 | from random import shuffle 9 | 10 | @app.route('/rest/getAlbum', methods=["GET", "POST"]) 11 | @app.route('/rest/getAlbum.view', methods=["GET", "POST"]) 12 | def get_album(): 13 | res_format = request.values.get('f') or 'xml' 14 | id = int(album_subid_to_beetid(request.values.get('id'))) 15 | 16 | album = g.lib.get_album(id) 17 | songs = sorted(album.items(), key=lambda song: song.track) 18 | 19 | if (is_json(res_format)): 20 | res = wrap_res("album", { 21 | **map_album(album), 22 | **{ "song": list(map(map_song, songs)) } 23 | }) 24 | return jsonpify(request, res) 25 | else: 26 | root = get_xml_root() 27 | albumXml = ET.SubElement(root, 'album') 28 | map_album_xml(albumXml, album) 29 | 30 | for song in songs: 31 | s = ET.SubElement(albumXml, 'song') 32 | map_song_xml(s, song) 33 | 34 | return Response(xml_to_string(root), mimetype='text/xml') 35 | 36 | @app.route('/rest/getAlbumList', methods=["GET", "POST"]) 37 | @app.route('/rest/getAlbumList.view', methods=["GET", "POST"]) 38 | def album_list(): 39 | return get_album_list(1) 40 | 41 | 42 | @app.route('/rest/getAlbumList2', methods=["GET", "POST"]) 43 | @app.route('/rest/getAlbumList2.view', methods=["GET", "POST"]) 44 | def album_list_2(): 45 | return get_album_list(2) 46 | 47 | def get_album_list(version): 48 | res_format = request.values.get('f') or 'xml' 49 | # TODO type == 'starred' and type == 'frequent' 50 | sort_by = request.values.get('type') or 'alphabeticalByName' 51 | size = int(request.values.get('size') or 10) 52 | offset = int(request.values.get('offset') or 0) 53 | fromYear = int(request.values.get('fromYear') or 0) 54 | toYear = int(request.values.get('toYear') or 3000) 55 | genre = request.values.get('genre') 56 | 57 | albums = list(g.lib.albums()) 58 | 59 | if sort_by == 'newest': 60 | albums.sort(key=lambda album: int(dict(album)['added']), reverse=True) 61 | elif sort_by == 'alphabeticalByName': 62 | albums.sort(key=lambda album: strip_accents(dict(album)['album']).upper()) 63 | elif sort_by == 'alphabeticalByArtist': 64 | albums.sort(key=lambda album: strip_accents(dict(album)['albumartist']).upper()) 65 | elif sort_by == 'alphabeticalByArtist': 66 | albums.sort(key=lambda album: strip_accents(dict(album)['albumartist']).upper()) 67 | elif sort_by == 'recent': 68 | albums.sort(key=lambda album: dict(album)['year'], reverse=True) 69 | elif sort_by == 'byGenre': 70 | albums = list(filter(lambda album: dict(album)['genre'].lower() == genre.lower(), albums)) 71 | elif sort_by == 'byYear': 72 | # TODO use month and day data to sort 73 | if fromYear <= toYear: 74 | albums = list(filter(lambda album: dict(album)['year'] >= fromYear and dict(album)['year'] <= toYear, albums)) 75 | albums.sort(key=lambda album: int(dict(album)['year'])) 76 | else: 77 | albums = list(filter(lambda album: dict(album)['year'] >= toYear and dict(album)['year'] <= fromYear, albums)) 78 | albums.sort(key=lambda album: int(dict(album)['year']), reverse=True) 79 | elif sort_by == 'random': 80 | shuffle(albums) 81 | 82 | albums = handleSizeAndOffset(albums, size, offset) 83 | 84 | if version == 1: 85 | if (is_json(res_format)): 86 | return jsonpify(request, wrap_res("albumList", { 87 | "album": list(map(map_album_list, albums)) 88 | })) 89 | else: 90 | root = get_xml_root() 91 | album_list_xml = ET.SubElement(root, 'albumList') 92 | 93 | for album in albums: 94 | a = ET.SubElement(album_list_xml, 'album') 95 | map_album_list_xml(a, album) 96 | 97 | return Response(xml_to_string(root), mimetype='text/xml') 98 | 99 | elif version == 2: 100 | if (is_json(res_format)): 101 | return jsonpify(request, wrap_res("albumList2", { 102 | "album": list(map(map_album, albums)) 103 | })) 104 | else: 105 | root = get_xml_root() 106 | album_list_xml = ET.SubElement(root, 'albumList2') 107 | 108 | for album in albums: 109 | a = ET.SubElement(album_list_xml, 'album') 110 | map_album_xml(a, album) 111 | 112 | return Response(xml_to_string(root), mimetype='text/xml') 113 | 114 | @app.route('/rest/getGenres', methods=["GET", "POST"]) 115 | @app.route('/rest/getGenres.view', methods=["GET", "POST"]) 116 | def genres(): 117 | res_format = request.values.get('f') or 'xml' 118 | with g.lib.transaction() as tx: 119 | mixed_genres = list(tx.query(""" 120 | SELECT genre, COUNT(*) AS n_song, "" AS n_album FROM items GROUP BY genre 121 | UNION ALL 122 | SELECT genre, "" AS n_song, COUNT(*) AS n_album FROM albums GROUP BY genre 123 | """)) 124 | 125 | genres = {} 126 | for genre in mixed_genres: 127 | key = genre[0] 128 | if (not key in genres.keys()): 129 | genres[key] = (genre[1], 0) 130 | if (genre[2]): 131 | genres[key] = (genres[key][0], genre[2]) 132 | 133 | genres = [(k, v[0], v[1]) for k, v in genres.items()] 134 | # genres.sort(key=lambda genre: strip_accents(genre[0]).upper()) 135 | genres.sort(key=lambda genre: genre[1]) 136 | genres.reverse() 137 | genres = filter(lambda genre: genre[0] != u"", genres) 138 | 139 | if (is_json(res_format)): 140 | def map_genre(genre): 141 | return { 142 | "value": genre[0], 143 | "songCount": genre[1], 144 | "albumCount": genre[2] 145 | } 146 | 147 | return jsonpify(request, wrap_res("genres", { 148 | "genre": list(map(map_genre, genres)) 149 | })) 150 | else: 151 | root = get_xml_root() 152 | genres_xml = ET.SubElement(root, 'genres') 153 | 154 | for genre in genres: 155 | genre_xml = ET.SubElement(genres_xml, 'genre') 156 | genre_xml.text = genre[0] 157 | genre_xml.set("songCount", str(genre[1])) 158 | genre_xml.set("albumCount", str(genre[2])) 159 | 160 | return Response(xml_to_string(root), mimetype='text/xml') 161 | 162 | @app.route('/rest/getMusicDirectory', methods=["GET", "POST"]) 163 | @app.route('/rest/getMusicDirectory.view', methods=["GET", "POST"]) 164 | def musicDirectory(): 165 | # Works pretty much like a file system 166 | # Usually Artist first, than Album, than Songs 167 | res_format = request.values.get('f') or 'xml' 168 | id = request.values.get('id') 169 | 170 | if id.startswith(ARTIST_ID_PREFIX): 171 | artist_id = id 172 | artist_name = artist_id_to_name(artist_id) 173 | albums = g.lib.albums(artist_name.replace("'", "\\'")) 174 | albums = filter(lambda album: album.albumartist == artist_name, albums) 175 | 176 | if (is_json(res_format)): 177 | return jsonpify(request, wrap_res("directory", { 178 | "id": artist_id, 179 | "name": artist_name, 180 | "child": list(map(map_album, albums)) 181 | })) 182 | else: 183 | root = get_xml_root() 184 | artist_xml = ET.SubElement(root, 'directory') 185 | artist_xml.set("id", artist_id) 186 | artist_xml.set("name", artist_name) 187 | 188 | for album in albums: 189 | a = ET.SubElement(artist_xml, 'child') 190 | map_album_xml(a, album) 191 | 192 | return Response(xml_to_string(root), mimetype='text/xml') 193 | elif id.startswith(ALBUM_ID_PREFIX): 194 | # Album 195 | id = int(album_subid_to_beetid(id)) 196 | album = g.lib.get_album(id) 197 | songs = sorted(album.items(), key=lambda song: song.track) 198 | 199 | if (is_json(res_format)): 200 | res = wrap_res("directory", { 201 | **map_album(album), 202 | **{ "child": list(map(map_song, songs)) } 203 | }) 204 | return jsonpify(request, res) 205 | else: 206 | root = get_xml_root() 207 | albumXml = ET.SubElement(root, 'directory') 208 | map_album_xml(albumXml, album) 209 | 210 | for song in songs: 211 | s = ET.SubElement(albumXml, 'child') 212 | map_song_xml(s, song) 213 | 214 | return Response(xml_to_string(root), mimetype='text/xml') 215 | elif id.startswith(SONG_ID_PREFIX): 216 | # Song 217 | id = int(song_subid_to_beetid(id)) 218 | song = g.lib.get_item(id) 219 | 220 | if (is_json(res_format)): 221 | return jsonpify(request, wrap_res("directory", map_song(song))) 222 | else: 223 | root = get_xml_root() 224 | s = ET.SubElement(root, 'directory') 225 | map_song_xml(s, song) 226 | 227 | return Response(xml_to_string(root), mimetype='text/xml') 228 | -------------------------------------------------------------------------------- /beetsplug/beetstream/artists.py: -------------------------------------------------------------------------------- 1 | import time 2 | from beetsplug.beetstream.utils import * 3 | from beetsplug.beetstream import app 4 | from flask import g, request, Response 5 | import xml.etree.cElementTree as ET 6 | 7 | @app.route('/rest/getArtists', methods=["GET", "POST"]) 8 | @app.route('/rest/getArtists.view', methods=["GET", "POST"]) 9 | def all_artists(): 10 | return get_artists("artists") 11 | 12 | @app.route('/rest/getIndexes', methods=["GET", "POST"]) 13 | @app.route('/rest/getIndexes.view', methods=["GET", "POST"]) 14 | def indexes(): 15 | return get_artists("indexes") 16 | 17 | def get_artists(version): 18 | res_format = request.values.get('f') or 'xml' 19 | with g.lib.transaction() as tx: 20 | rows = tx.query("SELECT DISTINCT albumartist FROM albums") 21 | all_artists = [row[0] for row in rows] 22 | all_artists.sort(key=lambda name: strip_accents(name).upper()) 23 | all_artists = filter(lambda name: len(name) > 0, all_artists) 24 | 25 | indicies_dict = {} 26 | 27 | for name in all_artists: 28 | index = strip_accents(name[0]).upper() 29 | if index not in indicies_dict: 30 | indicies_dict[index] = [] 31 | indicies_dict[index].append(name) 32 | 33 | if (is_json(res_format)): 34 | indicies = [] 35 | for index, artist_names in indicies_dict.items(): 36 | indicies.append({ 37 | "name": index, 38 | "artist": list(map(map_artist, artist_names)) 39 | }) 40 | 41 | return jsonpify(request, wrap_res(version, { 42 | "ignoredArticles": "", 43 | "lastModified": int(time.time() * 1000), 44 | "index": indicies 45 | })) 46 | else: 47 | root = get_xml_root() 48 | indexes_xml = ET.SubElement(root, version) 49 | indexes_xml.set('ignoredArticles', "") 50 | 51 | indicies = [] 52 | for index, artist_names in indicies_dict.items(): 53 | indicies.append({ 54 | "name": index, 55 | "artist": artist_names 56 | }) 57 | 58 | for index in indicies: 59 | index_xml = ET.SubElement(indexes_xml, 'index') 60 | index_xml.set('name', index["name"]) 61 | 62 | for a in index["artist"]: 63 | artist = ET.SubElement(index_xml, 'artist') 64 | map_artist_xml(artist, a) 65 | 66 | return Response(xml_to_string(root), mimetype='text/xml') 67 | 68 | @app.route('/rest/getArtist', methods=["GET", "POST"]) 69 | @app.route('/rest/getArtist.view', methods=["GET", "POST"]) 70 | def artist(): 71 | res_format = request.values.get('f') or 'xml' 72 | artist_id = request.values.get('id') 73 | artist_name = artist_id_to_name(artist_id) 74 | albums = g.lib.albums(artist_name.replace("'", "\\'")) 75 | albums = filter(lambda album: album.albumartist == artist_name, albums) 76 | 77 | if (is_json(res_format)): 78 | return jsonpify(request, wrap_res("artist", { 79 | "id": artist_id, 80 | "name": artist_name, 81 | "album": list(map(map_album, albums)) 82 | })) 83 | else: 84 | root = get_xml_root() 85 | artist_xml = ET.SubElement(root, 'artist') 86 | artist_xml.set("id", artist_id) 87 | artist_xml.set("name", artist_name) 88 | 89 | for album in albums: 90 | a = ET.SubElement(artist_xml, 'album') 91 | map_album_xml(a, album) 92 | 93 | return Response(xml_to_string(root), mimetype='text/xml') 94 | 95 | @app.route('/rest/getArtistInfo2', methods=["GET", "POST"]) 96 | @app.route('/rest/getArtistInfo2.view', methods=["GET", "POST"]) 97 | def artistInfo2(): 98 | res_format = request.values.get('f') or 'xml' 99 | artist_name = artist_id_to_name(request.values.get('id')) 100 | 101 | if (is_json(res_format)): 102 | return jsonpify(request, wrap_res("artistInfo2", { 103 | "biography": f"wow. much artist. very {artist_name}", 104 | "musicBrainzId": "", 105 | "lastFmUrl": "", 106 | "smallImageUrl": "", 107 | "mediumImageUrl": "", 108 | "largeImageUrl": "" 109 | })) 110 | else: 111 | root = get_xml_root() 112 | artist_xml = ET.SubElement(root, 'artistInfo2') 113 | 114 | biography = ET.SubElement(artist_xml, "biography") 115 | biography.text = f"wow. much artist very {artist_name}." 116 | musicBrainzId = ET.SubElement(artist_xml, "musicBrainzId") 117 | musicBrainzId.text = "" 118 | lastFmUrl = ET.SubElement(artist_xml, "lastFmUrl") 119 | lastFmUrl.text = "" 120 | smallImageUrl = ET.SubElement(artist_xml, "smallImageUrl") 121 | smallImageUrl.text = "" 122 | mediumImageUrl = ET.SubElement(artist_xml, "mediumImageUrl") 123 | mediumImageUrl.text = "" 124 | largeImageUrl = ET.SubElement(artist_xml, "largeImageUrl") 125 | largeImageUrl.text = "" 126 | 127 | return Response(xml_to_string(root), mimetype='text/xml') 128 | -------------------------------------------------------------------------------- /beetsplug/beetstream/coverart.py: -------------------------------------------------------------------------------- 1 | from beetsplug.beetstream.utils import * 2 | from beetsplug.beetstream import app 3 | from flask import g, request 4 | from io import BytesIO 5 | from PIL import Image 6 | import flask 7 | import os 8 | import subprocess 9 | import tempfile 10 | 11 | @app.route('/rest/getCoverArt', methods=["GET", "POST"]) 12 | @app.route('/rest/getCoverArt.view', methods=["GET", "POST"]) 13 | def cover_art_file(): 14 | id = request.values.get('id') 15 | size = request.values.get('size') 16 | album = None 17 | 18 | if id[:len(ALBUM_ID_PREFIX)] == ALBUM_ID_PREFIX: 19 | album_id = int(album_subid_to_beetid(id) or -1) 20 | album = g.lib.get_album(album_id) 21 | else: 22 | item_id = int(song_subid_to_beetid(id) or -1) 23 | item = g.lib.get_item(item_id) 24 | 25 | if item is not None: 26 | if item.album_id is not None: 27 | album = g.lib.get_album(item.album_id) 28 | if not album or not album.artpath: 29 | tmp_file = tempfile.NamedTemporaryFile(prefix='beetstream-cover-', suffix='.png') 30 | tmp_file_name = tmp_file.name 31 | try: 32 | tmp_file.close() 33 | subprocess.run(['ffmpeg', '-i', item.path, '-an', '-c:v', 34 | 'copy', tmp_file_name, 35 | '-hide_banner', '-loglevel', 'error',]) 36 | 37 | return _send_image(tmp_file_name, size) 38 | finally: 39 | os.remove(tmp_file_name) 40 | 41 | if album and album.artpath: 42 | image_path = album.artpath.decode('utf-8') 43 | 44 | if size is not None and int(size) > 0: 45 | return _send_image(image_path, size) 46 | 47 | return flask.send_file(image_path) 48 | else: 49 | return flask.abort(404) 50 | 51 | def _send_image(path_or_bytesio, size): 52 | converted = BytesIO() 53 | img = Image.open(path_or_bytesio) 54 | 55 | if size is not None and int(size) > 0: 56 | size = int(size) 57 | img = img.resize((size, size)) 58 | 59 | img.convert('RGB').save(converted, 'PNG') 60 | converted.seek(0) 61 | 62 | return flask.send_file(converted, mimetype='image/png') 63 | -------------------------------------------------------------------------------- /beetsplug/beetstream/dummy.py: -------------------------------------------------------------------------------- 1 | from beetsplug.beetstream.utils import * 2 | from beetsplug.beetstream import app 3 | from flask import request, Response 4 | import xml.etree.cElementTree as ET 5 | 6 | # Fake endpoint to avoid some apps errors 7 | @app.route('/rest/scrobble', methods=["GET", "POST"]) 8 | @app.route('/rest/scrobble.view', methods=["GET", "POST"]) 9 | @app.route('/rest/ping', methods=["GET", "POST"]) 10 | @app.route('/rest/ping.view', methods=["GET", "POST"]) 11 | def ping(): 12 | res_format = request.values.get('f') or 'xml' 13 | 14 | if (is_json(res_format)): 15 | return jsonpify(request, { 16 | "subsonic-response": { 17 | "status": "ok", 18 | "version": "1.16.1" 19 | } 20 | }) 21 | else: 22 | root = get_xml_root() 23 | return Response(xml_to_string(root), mimetype='text/xml') 24 | 25 | @app.route('/rest/getLicense', methods=["GET", "POST"]) 26 | @app.route('/rest/getLicense.view', methods=["GET", "POST"]) 27 | def getLicense(): 28 | res_format = request.values.get('f') or 'xml' 29 | 30 | if (is_json(res_format)): 31 | return jsonpify(request, wrap_res("license", { 32 | "valid": True, 33 | "email": "foo@example.com", 34 | "trialExpires": "3000-01-01T00:00:00.000Z" 35 | })) 36 | else: 37 | root = get_xml_root() 38 | l = ET.SubElement(root, 'license') 39 | l.set("valid", "true") 40 | l.set("email", "foo@example.com") 41 | l.set("trialExpires", "3000-01-01T00:00:00.000Z") 42 | return Response(xml_to_string(root), mimetype='text/xml') 43 | 44 | @app.route('/rest/getMusicFolders', methods=["GET", "POST"]) 45 | @app.route('/rest/getMusicFolders.view', methods=["GET", "POST"]) 46 | def music_folder(): 47 | res_format = request.values.get('f') or 'xml' 48 | if (is_json(res_format)): 49 | return jsonpify(request, wrap_res("musicFolders", { 50 | "musicFolder": [{ 51 | "id": 0, 52 | "name": "Music" 53 | }] 54 | })) 55 | else: 56 | root = get_xml_root() 57 | folder = ET.SubElement(root, 'musicFolders') 58 | folder.set("id", "0") 59 | folder.set("name", "Music") 60 | 61 | return Response(xml_to_string(root), mimetype='text/xml') 62 | -------------------------------------------------------------------------------- /beetsplug/beetstream/playlistprovider.py: -------------------------------------------------------------------------------- 1 | import glob 2 | import os 3 | import pathlib 4 | import re 5 | import sys 6 | from beetsplug.beetstream.utils import strip_accents 7 | from flask import current_app as app 8 | from werkzeug.utils import safe_join 9 | 10 | extinf_regex = re.compile(r'^#EXTINF:([0-9]+)( [^,]+)?,[\s]*(.*)') 11 | highint32 = 1<<31 12 | 13 | class PlaylistProvider: 14 | def __init__(self, dir): 15 | self.dir = dir 16 | self._playlists = {} 17 | 18 | def _refresh(self): 19 | self._playlists = {p.id: p for p in self._load_playlists()} 20 | app.logger.debug(f"Loaded {len(self._playlists)} playlists") 21 | 22 | def _load_playlists(self): 23 | if not self.dir: 24 | return 25 | paths = glob.glob(os.path.join(self.dir, "**.m3u8")) 26 | paths += glob.glob(os.path.join(self.dir, "**.m3u")) 27 | paths.sort() 28 | for path in paths: 29 | try: 30 | yield self._playlist(path) 31 | except Exception as e: 32 | app.logger.error(f"Failed to load playlist {filepath}: {e}") 33 | 34 | def playlists(self): 35 | self._refresh() 36 | playlists = self._playlists 37 | ids = [k for k, v in playlists.items() if v] 38 | ids.sort() 39 | return [playlists[id] for id in ids] 40 | 41 | def playlist(self, id): 42 | filepath = safe_join(self.dir, id) 43 | playlist = self._playlist(filepath) 44 | if playlist.id not in self._playlists: # add to cache 45 | playlists = self._playlists.copy() 46 | playlists[playlist.id] = playlist 47 | self._playlists = playlists 48 | return playlist 49 | 50 | def _playlist(self, filepath): 51 | id = self._path2id(filepath) 52 | name = pathlib.Path(os.path.basename(filepath)).stem 53 | playlist = self._playlists.get(id) 54 | mtime = pathlib.Path(filepath).stat().st_mtime 55 | if playlist and playlist.modified == mtime: 56 | return playlist # cached metadata 57 | app.logger.debug(f"Loading playlist {filepath}") 58 | return Playlist(id, name, mtime, filepath) 59 | 60 | def _path2id(self, filepath): 61 | return os.path.relpath(filepath, self.dir) 62 | 63 | class Playlist: 64 | def __init__(self, id, name, modified, path): 65 | self.id = id 66 | self.name = name 67 | self.modified = modified 68 | self.path = path 69 | self.count = 0 70 | self.duration = 0 71 | artists = {} 72 | max_artists = 10 73 | for item in self.items(): 74 | self.count += 1 75 | self.duration += item.duration 76 | artist = Artist(item.title.split(' - ')[0]) 77 | found = artists.get(artist.key) 78 | if found: 79 | found.count += 1 80 | else: 81 | if len(artists) > max_artists: 82 | l = _sortedartists(artists)[:max_artists] 83 | artists = {a.key: a for a in l} 84 | artists[artist.key] = artist 85 | self.artists = ', '.join([a.name for a in _sortedartists(artists)]) 86 | 87 | def items(self): 88 | return parse_m3u_playlist(self.path) 89 | 90 | def _sortedartists(artists): 91 | l = [a for _,a in artists.items()] 92 | l.sort(key=lambda a: (highint32-a.count, a.name)) 93 | return l 94 | 95 | class Artist: 96 | def __init__(self, name): 97 | self.key = strip_accents(name.lower()) 98 | self.name = name 99 | self.count = 1 100 | 101 | def parse_m3u_playlist(filepath): 102 | ''' 103 | Parses an M3U playlist and yields its items, one at a time. 104 | CAUTION: Attribute values that contain ',' or ' ' are not supported! 105 | ''' 106 | with open(filepath, 'r', encoding='UTF-8') as file: 107 | linenum = 0 108 | item = PlaylistItem() 109 | while line := file.readline(): 110 | line = line.rstrip() 111 | linenum += 1 112 | if linenum == 1: 113 | assert line == '#EXTM3U', f"File {filepath} is not an EXTM3U playlist!" 114 | continue 115 | if len(line.strip()) == 0: 116 | continue 117 | m = extinf_regex.match(line) 118 | if m: 119 | item = PlaylistItem() 120 | duration = m.group(1) 121 | item.duration = int(duration) 122 | attrs = m.group(2) 123 | if attrs: 124 | item.attrs = {k: v.strip('"') for k,v in [kv.split('=') for kv in attrs.strip().split(' ')]} 125 | else: 126 | item.attrs = {} 127 | item.title = m.group(3) 128 | continue 129 | if line.startswith('#'): 130 | continue 131 | item.uri = line 132 | yield item 133 | item = PlaylistItem() 134 | 135 | class PlaylistItem(): 136 | def __init__(self): 137 | self.title = None 138 | self.duration = None 139 | self.uri = None 140 | self.attrs = None 141 | -------------------------------------------------------------------------------- /beetsplug/beetstream/playlists.py: -------------------------------------------------------------------------------- 1 | import xml.etree.cElementTree as ET 2 | from beetsplug.beetstream.utils import * 3 | from beetsplug.beetstream import app 4 | from flask import g, request, Response 5 | from .playlistprovider import PlaylistProvider 6 | 7 | _playlist_provider = PlaylistProvider('') 8 | 9 | # TODO link with https://beets.readthedocs.io/en/stable/plugins/playlist.html 10 | @app.route('/rest/getPlaylists', methods=['GET', 'POST']) 11 | @app.route('/rest/getPlaylists.view', methods=['GET', 'POST']) 12 | def playlists(): 13 | res_format = request.values.get('f') or 'xml' 14 | playlists = playlist_provider().playlists() 15 | if (is_json(res_format)): 16 | return jsonpify(request, wrap_res('playlists', { 17 | 'playlist': [map_playlist(p) for p in playlists] 18 | })) 19 | else: 20 | root = get_xml_root() 21 | playlists_el = ET.SubElement(root, 'playlists') 22 | for p in playlists: 23 | playlist_el = ET.SubElement(playlists_el, 'playlist') 24 | map_playlist_xml(playlist_el, p) 25 | return Response(xml_to_string(root), mimetype='text/xml') 26 | 27 | @app.route('/rest/getPlaylist', methods=['GET', 'POST']) 28 | @app.route('/rest/getPlaylist.view', methods=['GET', 'POST']) 29 | def playlist(): 30 | res_format = request.values.get('f') or 'xml' 31 | id = request.values.get('id') 32 | playlist = playlist_provider().playlist(id) 33 | items = playlist.items() 34 | if (is_json(res_format)): 35 | p = map_playlist(playlist) 36 | p['entry'] = [_song(item.attrs['id']) for item in items] 37 | return jsonpify(request, wrap_res('playlist', p)) 38 | else: 39 | root = get_xml_root() 40 | playlist_xml = ET.SubElement(root, 'playlist') 41 | map_playlist_xml(playlist_xml, playlist) 42 | for item in items: 43 | song = g.lib.get_item(item.attrs['id']) 44 | entry = ET.SubElement(playlist_xml, 'entry') 45 | map_song_xml(entry, song) 46 | return Response(xml_to_string(root), mimetype='text/xml') 47 | 48 | def _song(id): 49 | return map_song(g.lib.get_item(int(id))) 50 | 51 | def playlist_provider(): 52 | if 'playlist_dir' in app.config: 53 | _playlist_provider.dir = app.config['playlist_dir'] 54 | if not _playlist_provider.dir: 55 | app.logger.warning('No playlist_dir configured') 56 | return _playlist_provider 57 | -------------------------------------------------------------------------------- /beetsplug/beetstream/search.py: -------------------------------------------------------------------------------- 1 | from beetsplug.beetstream.utils import * 2 | from beetsplug.beetstream import app 3 | from flask import g, request, Response 4 | import xml.etree.cElementTree as ET 5 | 6 | @app.route('/rest/search2', methods=["GET", "POST"]) 7 | @app.route('/rest/search2.view', methods=["GET", "POST"]) 8 | def search2(): 9 | return search(2) 10 | 11 | @app.route('/rest/search3', methods=["GET", "POST"]) 12 | @app.route('/rest/search3.view', methods=["GET", "POST"]) 13 | def search3(): 14 | return search(3) 15 | 16 | def search(version): 17 | res_format = request.values.get('f') or 'xml' 18 | query = request.values.get('query') or "" 19 | artistCount = int(request.values.get('artistCount') or 20) 20 | artistOffset = int(request.values.get('artistOffset') or 0) 21 | albumCount = int(request.values.get('albumCount') or 20) 22 | albumOffset = int(request.values.get('albumOffset') or 0) 23 | songCount = int(request.values.get('songCount') or 20) 24 | songOffset = int(request.values.get('songOffset') or 0) 25 | 26 | songs = handleSizeAndOffset(list(g.lib.items("title:{}".format(query.replace("'", "\\'")))), songCount, songOffset) 27 | albums = handleSizeAndOffset(list(g.lib.albums("album:{}".format(query.replace("'", "\\'")))), albumCount, albumOffset) 28 | 29 | with g.lib.transaction() as tx: 30 | rows = tx.query("SELECT DISTINCT albumartist FROM albums") 31 | artists = [row[0] for row in rows] 32 | artists = list(filter(lambda artist: strip_accents(query).lower() in strip_accents(artist).lower(), artists)) 33 | artists.sort(key=lambda name: strip_accents(name).upper()) 34 | artists = handleSizeAndOffset(artists, artistCount, artistOffset) 35 | 36 | if (is_json(res_format)): 37 | return jsonpify(request, wrap_res("searchResult{}".format(version), { 38 | "artist": list(map(map_artist, artists)), 39 | "album": list(map(map_album, albums)), 40 | "song": list(map(map_song, songs)) 41 | })) 42 | else: 43 | root = get_xml_root() 44 | search_result = ET.SubElement(root, 'searchResult{}'.format(version)) 45 | 46 | for artist in artists: 47 | a = ET.SubElement(search_result, 'artist') 48 | map_artist_xml(a, artist) 49 | 50 | for album in albums: 51 | a = ET.SubElement(search_result, 'album') 52 | map_album_xml(a, album) 53 | 54 | for song in songs: 55 | s = ET.SubElement(search_result, 'song') 56 | map_song_xml(s, song) 57 | 58 | return Response(xml_to_string(root), mimetype='text/xml') 59 | -------------------------------------------------------------------------------- /beetsplug/beetstream/songs.py: -------------------------------------------------------------------------------- 1 | from beetsplug.beetstream.utils import * 2 | from beetsplug.beetstream import app, stream 3 | from flask import g, request, Response 4 | from beets.random import random_objs 5 | import xml.etree.cElementTree as ET 6 | 7 | @app.route('/rest/getSong', methods=["GET", "POST"]) 8 | @app.route('/rest/getSong.view', methods=["GET", "POST"]) 9 | def song(): 10 | res_format = request.values.get('f') or 'xml' 11 | id = int(song_subid_to_beetid(request.values.get('id'))) 12 | song = g.lib.get_item(id) 13 | 14 | if (is_json(res_format)): 15 | return jsonpify(request, wrap_res("song", map_song(song))) 16 | else: 17 | root = get_xml_root() 18 | s = ET.SubElement(root, 'song') 19 | map_song_xml(s, song) 20 | 21 | return Response(xml_to_string(root), mimetype='text/xml') 22 | 23 | @app.route('/rest/getSongsByGenre', methods=["GET", "POST"]) 24 | @app.route('/rest/getSongsByGenre.view', methods=["GET", "POST"]) 25 | def songs_by_genre(): 26 | res_format = request.values.get('f') or 'xml' 27 | genre = request.values.get('genre') 28 | count = int(request.values.get('count') or 10) 29 | offset = int(request.values.get('offset') or 0) 30 | 31 | songs = handleSizeAndOffset(list(g.lib.items('genre:' + genre.replace("'", "\\'"))), count, offset) 32 | 33 | if (is_json(res_format)): 34 | return jsonpify(request, wrap_res("songsByGenre", { 35 | "song": list(map(map_song, songs)) 36 | })) 37 | else: 38 | root = get_xml_root() 39 | songs_by_genre = ET.SubElement(root, 'songsByGenre') 40 | 41 | for song in songs: 42 | s = ET.SubElement(songs_by_genre, 'song') 43 | map_song_xml(s, song) 44 | 45 | return Response(xml_to_string(root), mimetype='text/xml') 46 | 47 | @app.route('/rest/stream', methods=["GET", "POST"]) 48 | @app.route('/rest/stream.view', methods=["GET", "POST"]) 49 | def stream_song(): 50 | maxBitrate = int(request.values.get('maxBitRate') or 0) 51 | format = request.values.get('format') 52 | 53 | id = int(song_subid_to_beetid(request.values.get('id'))) 54 | item = g.lib.get_item(id) 55 | 56 | itemPath = item.path.decode('utf-8') 57 | 58 | if app.config['never_transcode'] or format == 'raw' or maxBitrate <= 0 or item.bitrate <= maxBitrate * 1000: 59 | return stream.send_raw_file(itemPath) 60 | else: 61 | return stream.try_to_transcode(itemPath, maxBitrate) 62 | 63 | @app.route('/rest/download', methods=["GET", "POST"]) 64 | @app.route('/rest/download.view', methods=["GET", "POST"]) 65 | def download_song(): 66 | id = int(song_subid_to_beetid(request.values.get('id'))) 67 | item = g.lib.get_item(id) 68 | 69 | return stream.send_raw_file(item.path.decode('utf-8')) 70 | 71 | @app.route('/rest/getRandomSongs', methods=["GET", "POST"]) 72 | @app.route('/rest/getRandomSongs.view', methods=["GET", "POST"]) 73 | def random_songs(): 74 | res_format = request.values.get('f') or 'xml' 75 | size = int(request.values.get('size') or 10) 76 | songs = list(g.lib.items()) 77 | songs = random_objs(songs, -1, size) 78 | 79 | if (is_json(res_format)): 80 | return jsonpify(request, wrap_res("randomSongs", { 81 | "song": list(map(map_song, songs)) 82 | })) 83 | else: 84 | root = get_xml_root() 85 | album = ET.SubElement(root, 'randomSongs') 86 | 87 | for song in songs: 88 | s = ET.SubElement(album, 'song') 89 | map_song_xml(s, song) 90 | 91 | return Response(xml_to_string(root), mimetype='text/xml') 92 | 93 | # TODO link with Last.fm or ListenBrainz 94 | @app.route('/rest/getTopSongs', methods=["GET", "POST"]) 95 | @app.route('/rest/getTopSongs.view', methods=["GET", "POST"]) 96 | def top_songs(): 97 | res_format = request.values.get('f') or 'xml' 98 | if (is_json(res_format)): 99 | return jsonpify(request, wrap_res("topSongs", {})) 100 | else: 101 | root = get_xml_root() 102 | ET.SubElement(root, 'topSongs') 103 | return Response(xml_to_string(root), mimetype='text/xml') 104 | 105 | 106 | @app.route('/rest/getStarred', methods=["GET", "POST"]) 107 | @app.route('/rest/getStarred.view', methods=["GET", "POST"]) 108 | def starred_songs(): 109 | res_format = request.values.get('f') or 'xml' 110 | if (is_json(res_format)): 111 | return jsonpify(request, wrap_res("starred", { 112 | "song": [] 113 | })) 114 | else: 115 | root = get_xml_root() 116 | ET.SubElement(root, 'starred') 117 | return Response(xml_to_string(root), mimetype='text/xml') 118 | 119 | @app.route('/rest/getStarred2', methods=["GET", "POST"]) 120 | @app.route('/rest/getStarred2.view', methods=["GET", "POST"]) 121 | def starred2_songs(): 122 | res_format = request.values.get('f') or 'xml' 123 | if (is_json(res_format)): 124 | return jsonpify(request, wrap_res("starred2", { 125 | "song": [] 126 | })) 127 | else: 128 | root = get_xml_root() 129 | ET.SubElement(root, 'starred2') 130 | return Response(xml_to_string(root), mimetype='text/xml') 131 | -------------------------------------------------------------------------------- /beetsplug/beetstream/stream.py: -------------------------------------------------------------------------------- 1 | from beetsplug.beetstream.utils import path_to_content_type 2 | from flask import send_file, Response 3 | 4 | import importlib 5 | have_ffmpeg = importlib.util.find_spec("ffmpeg") is not None 6 | 7 | if have_ffmpeg: 8 | import ffmpeg 9 | 10 | def send_raw_file(filePath): 11 | return send_file(filePath, mimetype=path_to_content_type(filePath)) 12 | 13 | def transcode_and_stream(filePath, maxBitrate): 14 | if not have_ffmpeg: 15 | raise RuntimeError("Can't transcode, ffmpeg-python is not available") 16 | 17 | outputStream = ( 18 | ffmpeg 19 | .input(filePath) 20 | .audio 21 | .output('pipe:', format="mp3", audio_bitrate=maxBitrate * 1000) 22 | .run_async(pipe_stdout=True, quiet=True) 23 | ) 24 | 25 | return Response(outputStream.stdout, mimetype='audio/mpeg') 26 | 27 | def try_to_transcode(filePath, maxBitrate): 28 | if have_ffmpeg: 29 | return transcode_and_stream(filePath, maxBitrate) 30 | else: 31 | return send_raw_file(filePath) 32 | -------------------------------------------------------------------------------- /beetsplug/beetstream/users.py: -------------------------------------------------------------------------------- 1 | from beetsplug.beetstream.utils import * 2 | from beetsplug.beetstream import app 3 | from flask import g, request, Response 4 | import xml.etree.cElementTree as ET 5 | 6 | @app.route('/rest/getUser', methods=["GET", "POST"]) 7 | @app.route('/rest/getUser.view', methods=["GET", "POST"]) 8 | def user(): 9 | res_format = request.values.get('f') or 'xml' 10 | if (is_json(res_format)): 11 | return jsonpify(request, wrap_res("user", { 12 | "username" : "admin", 13 | "email" : "foo@example.com", 14 | "scrobblingEnabled" : True, 15 | "adminRole" : True, 16 | "settingsRole" : True, 17 | "downloadRole" : True, 18 | "uploadRole" : True, 19 | "playlistRole" : True, 20 | "coverArtRole" : True, 21 | "commentRole" : True, 22 | "podcastRole" : True, 23 | "streamRole" : True, 24 | "jukeboxRole" : True, 25 | "shareRole" : True, 26 | "videoConversionRole" : True, 27 | "avatarLastChanged" : "1970-01-01T00:00:00.000Z", 28 | "folder" : [ 0 ] 29 | })) 30 | else: 31 | root = get_xml_root() 32 | u = ET.SubElement(root, 'user') 33 | u.set("username", "admin") 34 | u.set("email", "foo@example.com") 35 | u.set("scrobblingEnabled", "true") 36 | u.set("adminRole", "true") 37 | u.set("settingsRole", "true") 38 | u.set("downloadRole", "true") 39 | u.set("uploadRole", "true") 40 | u.set("playlistRole", "true") 41 | u.set("coverArtRole", "true") 42 | u.set("commentRole", "true") 43 | u.set("podcastRole", "true") 44 | u.set("streamRole", "true") 45 | u.set("jukeboxRole", "true") 46 | u.set("shareRole", "true") 47 | u.set("videoConversionRole", "true") 48 | u.set("avatarLastChanged", "1970-01-01T00:00:00.000Z") 49 | f = ET.SubElement(u, 'folder') 50 | f.text = "0" 51 | 52 | return Response(xml_to_string(root), mimetype='text/xml') 53 | -------------------------------------------------------------------------------- /beetsplug/beetstream/utils.py: -------------------------------------------------------------------------------- 1 | from beetsplug.beetstream import ALBUM_ID_PREFIX, ARTIST_ID_PREFIX, SONG_ID_PREFIX 2 | import unicodedata 3 | from datetime import datetime 4 | import flask 5 | import json 6 | import base64 7 | import mimetypes 8 | import os 9 | import posixpath 10 | import xml.etree.cElementTree as ET 11 | from math import ceil 12 | from xml.dom import minidom 13 | 14 | DEFAULT_MIME_TYPE = 'application/octet-stream' 15 | EXTENSION_TO_MIME_TYPE_FALLBACK = { 16 | '.aac' : 'audio/aac', 17 | '.flac' : 'audio/flac', 18 | '.mp3' : 'audio/mpeg', 19 | '.mp4' : 'audio/mp4', 20 | '.m4a' : 'audio/mp4', 21 | '.ogg' : 'audio/ogg', 22 | '.opus' : 'audio/opus', 23 | } 24 | 25 | def strip_accents(s): 26 | return ''.join(c for c in unicodedata.normalize('NFD', s) if unicodedata.category(c) != 'Mn') 27 | 28 | def timestamp_to_iso(timestamp): 29 | return datetime.fromtimestamp(int(timestamp)).isoformat() 30 | 31 | def is_json(res_format): 32 | return res_format == 'json' or res_format == 'jsonp' 33 | 34 | def wrap_res(key, json): 35 | return { 36 | "subsonic-response": { 37 | "status": "ok", 38 | "version": "1.16.1", 39 | key: json 40 | } 41 | } 42 | 43 | def jsonpify(request, data): 44 | if request.values.get("f") == "jsonp": 45 | callback = request.values.get("callback") 46 | return f"{callback}({json.dumps(data)});" 47 | else: 48 | return flask.jsonify(data) 49 | 50 | def get_xml_root(): 51 | root = ET.Element('subsonic-response') 52 | root.set('xmlns', 'http://subsonic.org/restapi') 53 | root.set('status', 'ok') 54 | root.set('version', '1.16.1') 55 | return root 56 | 57 | def xml_to_string(xml): 58 | # Add declaration: 59 | return minidom.parseString(ET.tostring(xml, encoding='unicode', method='xml', xml_declaration=True)).toprettyxml() 60 | 61 | def map_album(album): 62 | album = dict(album) 63 | return { 64 | "id": album_beetid_to_subid(str(album["id"])), 65 | "name": album["album"], 66 | "title": album["album"], 67 | "album": album["album"], 68 | "artist": album["albumartist"], 69 | "artistId": artist_name_to_id(album["albumartist"]), 70 | "parent": artist_name_to_id(album["albumartist"]), 71 | "isDir": True, 72 | "coverArt": album_beetid_to_subid(str(album["id"])) or "", 73 | "songCount": 1, # TODO 74 | "duration": 1, # TODO 75 | "playCount": 1, # TODO 76 | "created": timestamp_to_iso(album["added"]), 77 | "year": album["year"], 78 | "genre": album["genre"], 79 | "starred": "1970-01-01T00:00:00.000Z", # TODO 80 | "averageRating": 0 # TODO 81 | } 82 | 83 | def map_album_xml(xml, album): 84 | album = dict(album) 85 | xml.set("id", album_beetid_to_subid(str(album["id"]))) 86 | xml.set("name", album["album"]) 87 | xml.set("title", album["album"]) 88 | xml.set("album", album["album"]) 89 | xml.set("artist", album["albumartist"]) 90 | xml.set("artistId", artist_name_to_id(album["albumartist"])) 91 | xml.set("parent", artist_name_to_id(album["albumartist"])) 92 | xml.set("isDir", "true") 93 | xml.set("coverArt", album_beetid_to_subid(str(album["id"])) or "") 94 | xml.set("songCount", str(1)) # TODO 95 | xml.set("duration", str(1)) # TODO 96 | xml.set("playCount", str(1)) # TODO 97 | xml.set("created", timestamp_to_iso(album["added"])) 98 | xml.set("year", str(album["year"])) 99 | xml.set("genre", album["genre"]) 100 | xml.set("starred", "1970-01-01T00:00:00.000Z") # TODO 101 | xml.set("averageRating", "0") # TODO 102 | 103 | def map_album_list(album): 104 | album = dict(album) 105 | return { 106 | "id": album_beetid_to_subid(str(album["id"])), 107 | "parent": artist_name_to_id(album["albumartist"]), 108 | "isDir": True, 109 | "title": album["album"], 110 | "album": album["album"], 111 | "artist": album["albumartist"], 112 | "year": album["year"], 113 | "genre": album["genre"], 114 | "coverArt": album_beetid_to_subid(str(album["id"])) or "", 115 | "userRating": 5, # TODO 116 | "averageRating": 5, # TODO 117 | "playCount": 1, # TODO 118 | "created": timestamp_to_iso(album["added"]), 119 | "starred": "" 120 | } 121 | 122 | def map_album_list_xml(xml, album): 123 | album = dict(album) 124 | xml.set("id", album_beetid_to_subid(str(album["id"]))) 125 | xml.set("parent", artist_name_to_id(album["albumartist"])) 126 | xml.set("isDir", "true") 127 | xml.set("title", album["album"]) 128 | xml.set("album", album["album"]) 129 | xml.set("artist", album["albumartist"]) 130 | xml.set("year", str(album["year"])) 131 | xml.set("genre", album["genre"]) 132 | xml.set("coverArt", album_beetid_to_subid(str(album["id"])) or "") 133 | xml.set("userRating", "5") # TODO 134 | xml.set("averageRating", "5") # TODO 135 | xml.set("playCount", "1") # TODO 136 | xml.set("created", timestamp_to_iso(album["added"])) 137 | xml.set("starred", "") 138 | 139 | def map_song(song): 140 | song = dict(song) 141 | path = song["path"].decode('utf-8') 142 | return { 143 | "id": song_beetid_to_subid(str(song["id"])), 144 | "parent": album_beetid_to_subid(str(song["album_id"])), 145 | "isDir": False, 146 | "title": song["title"], 147 | "name": song["title"], 148 | "album": song["album"], 149 | "artist": song["albumartist"], 150 | "track": song["track"], 151 | "year": song["year"], 152 | "genre": song["genre"], 153 | "coverArt": _cover_art_id(song), 154 | "size": os.path.getsize(path), 155 | "contentType": path_to_content_type(path), 156 | "suffix": song["format"].lower(), 157 | "duration": ceil(song["length"]), 158 | "bitRate": ceil(song["bitrate"]/1000), 159 | "path": path, 160 | "playCount": 1, #TODO 161 | "created": timestamp_to_iso(song["added"]), 162 | # "starred": "2019-10-23T04:41:17.107Z", 163 | "albumId": album_beetid_to_subid(str(song["album_id"])), 164 | "artistId": artist_name_to_id(song["albumartist"]), 165 | "type": "music", 166 | "discNumber": song["disc"] 167 | } 168 | 169 | def map_song_xml(xml, song): 170 | song = dict(song) 171 | path = song["path"].decode('utf-8') 172 | xml.set("id", song_beetid_to_subid(str(song["id"]))) 173 | xml.set("parent", album_beetid_to_subid(str(song["album_id"]))) 174 | xml.set("isDir", "false") 175 | xml.set("title", song["title"]) 176 | xml.set("name", song["title"]) 177 | xml.set("album", song["album"]) 178 | xml.set("artist", song["albumartist"]) 179 | xml.set("track", str(song["track"])) 180 | xml.set("year", str(song["year"])) 181 | xml.set("genre", song["genre"]) 182 | xml.set("coverArt", _cover_art_id(song)), 183 | xml.set("size", str(os.path.getsize(path))) 184 | xml.set("contentType", path_to_content_type(path)) 185 | xml.set("suffix", song["format"].lower()) 186 | xml.set("duration", str(ceil(song["length"]))) 187 | xml.set("bitRate", str(ceil(song["bitrate"]/1000))) 188 | xml.set("path", path) 189 | xml.set("playCount", str(1)) #TODO 190 | xml.set("created", timestamp_to_iso(song["added"])) 191 | xml.set("albumId", album_beetid_to_subid(str(song["album_id"]))) 192 | xml.set("artistId", artist_name_to_id(song["albumartist"])) 193 | xml.set("type", "music") 194 | if song["disc"]: 195 | xml.set("discNumber", str(song["disc"])) 196 | 197 | def _cover_art_id(song): 198 | if song['album_id']: 199 | return album_beetid_to_subid(str(song['album_id'])) 200 | return song_beetid_to_subid(str(song['id'])) 201 | 202 | def map_artist(artist_name): 203 | return { 204 | "id": artist_name_to_id(artist_name), 205 | "name": artist_name, 206 | # TODO 207 | # "starred": "2021-07-03T06:15:28.757Z", # nothing if not starred 208 | "coverArt": "", 209 | "albumCount": 1, 210 | "artistImageUrl": "https://t4.ftcdn.net/jpg/00/64/67/63/360_F_64676383_LdbmhiNM6Ypzb3FM4PPuFP9rHe7ri8Ju.jpg" 211 | } 212 | 213 | def map_artist_xml(xml, artist_name): 214 | xml.set("id", artist_name_to_id(artist_name)) 215 | xml.set("name", artist_name) 216 | xml.set("coverArt", "") 217 | xml.set("albumCount", "1") 218 | xml.set("artistImageUrl", "https://t4.ftcdn.net/jpg/00/64/67/63/360_F_64676383_LdbmhiNM6Ypzb3FM4PPuFP9rHe7ri8Ju.jpg") 219 | 220 | def map_playlist(playlist): 221 | return { 222 | 'id': playlist.id, 223 | 'name': playlist.name, 224 | 'songCount': playlist.count, 225 | 'duration': playlist.duration, 226 | 'comment': playlist.artists, 227 | 'created': timestamp_to_iso(playlist.modified), 228 | } 229 | 230 | def map_playlist_xml(xml, playlist): 231 | xml.set('id', playlist.id) 232 | xml.set('name', playlist.name) 233 | xml.set('songCount', str(playlist.count)) 234 | xml.set('duration', str(ceil(playlist.duration))) 235 | xml.set('comment', playlist.artists) 236 | xml.set('created', timestamp_to_iso(playlist.modified)) 237 | 238 | def artist_name_to_id(name): 239 | base64_name = base64.b64encode(name.encode('utf-8')).decode('utf-8') 240 | return f"{ARTIST_ID_PREFIX}{base64_name}" 241 | 242 | def artist_id_to_name(id): 243 | base64_id = id[len(ARTIST_ID_PREFIX):] 244 | return base64.b64decode(base64_id.encode('utf-8')).decode('utf-8') 245 | 246 | def album_beetid_to_subid(id): 247 | return f"{ALBUM_ID_PREFIX}{id}" 248 | 249 | def album_subid_to_beetid(id): 250 | return id[len(ALBUM_ID_PREFIX):] 251 | 252 | def song_beetid_to_subid(id): 253 | return f"{SONG_ID_PREFIX}{id}" 254 | 255 | def song_subid_to_beetid(id): 256 | return id[len(SONG_ID_PREFIX):] 257 | 258 | def path_to_content_type(path): 259 | result = mimetypes.guess_type(path)[0] 260 | 261 | if result: 262 | return result 263 | 264 | # our mimetype database didn't have information about this file extension. 265 | base, ext = posixpath.splitext(path) 266 | result = EXTENSION_TO_MIME_TYPE_FALLBACK.get(ext) 267 | 268 | if result: 269 | return result 270 | 271 | flask.current_app.logger.warning(f"No mime type mapped for {ext} extension: {path}") 272 | 273 | return DEFAULT_MIME_TYPE 274 | 275 | def handleSizeAndOffset(collection, size, offset): 276 | if size is not None: 277 | if offset is not None: 278 | return collection[offset:offset + size] 279 | else: 280 | return collection[0:size] 281 | else: 282 | return collection 283 | -------------------------------------------------------------------------------- /missing-endpoints.md: -------------------------------------------------------------------------------- 1 | # Missing Endpoints 2 | 3 | To be implemented: 4 | - `getArtistInfo` 5 | - `getAlbumInfo` 6 | - `getAlbumInfo2` 7 | - `getSimilarSongs` 8 | - `getSimilarSongs2` 9 | - `search` 10 | 11 | Could be fun to implement: 12 | - `createPlaylist` 13 | - `updatePlaylist` 14 | - `deletePlaylist` 15 | - `getLyrics` 16 | - `getAvatar` 17 | - `star` 18 | - `unstar` 19 | - `setRating` 20 | - `getBookmarks` 21 | - `createBookmark` 22 | - `deleteBookmark` 23 | - `getPlayQueue` 24 | - `savePlayQueue` 25 | - `getScanStatus` 26 | - `startScan` 27 | 28 | Video/Radio/Podcast stuff. Not related to this project 29 | - `getVideos` 30 | - `getVideoInfo` 31 | - `hls` 32 | - `getCaptions` 33 | - `getPodcasts` 34 | - `getNewestPodcasts` 35 | - `refreshPodcasts` 36 | - `createPodcastChannel` 37 | - `deletePodcastChannel` 38 | - `deletePodcastEpisode` 39 | - `downloadPodcastEpisode` 40 | - `getInternetRadioStations` 41 | - `createInternetRadioStation` 42 | - `updateInternetRadioStation` 43 | - `deleteInternetRadioStation` 44 | 45 | Social stuff. Some could be fun to implement but I'm still not sure: 46 | - `getNowPlaying` 47 | - `getShares` 48 | - `createShare` 49 | - `updateShare` 50 | - `deleteShare` 51 | - `jukeboxControl` 52 | - `getChatMessages` 53 | - `addChatMessage` 54 | 55 | Handling users is annoying but may be useful: 56 | - `getUsers` 57 | - `createUser` 58 | - `updateUser` 59 | - `deleteUser` 60 | - `changePassword` 61 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=42"] 3 | build-backend = "setuptools.build_meta" -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | . 2 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = Beetstream 3 | version = 1.4.0 4 | author = Binary Brain 5 | author_email = me@sachabron.ch 6 | description = Beets.io plugin that expose SubSonic API endpoints, allowing you to stream your music everywhere. 7 | long_description = file: README.md 8 | long_description_content_type = text/markdown 9 | url = https://github.com/BinaryBrain/Beetstream 10 | project_urls = 11 | Bug Tracker = https://github.com/BinaryBrain/Beetstream/issues 12 | classifiers = 13 | Environment :: No Input/Output (Daemon) 14 | Intended Audience :: End Users/Desktop 15 | Programming Language :: Python :: 3 16 | Programming Language :: Python :: 3.8 17 | Programming Language :: Python :: 3.9 18 | Operating System :: OS Independent 19 | Topic :: Multimedia :: Sound/Audio :: Players 20 | License :: OSI Approved :: MIT License 21 | Framework :: Flask 22 | 23 | [options] 24 | packages = find: 25 | python_requires = >=3.8 26 | install_requires = 27 | flask >= 1.1.2 28 | flask_cors >= 3.0.10 29 | Pillow >= 8.4.0 30 | ffmpeg-python >= 0.2.0 31 | 32 | [options.packages.find] 33 | --------------------------------------------------------------------------------