diff --git a/.gitignore b/.gitignore index b6e4761..cc6e0c5 100644 --- a/.gitignore +++ b/.gitignore @@ -127,3 +127,6 @@ dmypy.json # Pyre type checker .pyre/ + +Jetbrains stuff +.idea/ \ No newline at end of file diff --git a/LICENSE b/LICENSE index 1d9e5ff..a7f04db 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2020 Sacha Bron +Copyright (c) 2025 Florent Le Moël Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +SOFTWARE. \ No newline at end of file diff --git a/NOTICE.md b/NOTICE.md new file mode 100644 index 0000000..7eac4e7 --- /dev/null +++ b/NOTICE.md @@ -0,0 +1,29 @@ +# Dependency Licenses + +This fork includes code from the parent project. The original license and copyright notices are preserved below. + +--- + +## beetsream + +MIT License + +Copyright (c) 2020 Sacha Bron + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index b3d70e1..6ff18da 100644 --- a/README.md +++ b/README.md @@ -1,87 +1,226 @@ -# Beetstream +
-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. +
-## Motivation + + Logo + -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. -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). +

BeetstreamNext

+

+ A modern, feature-rich OpenSubsonic API server for your Beets.io music library. +
-## Install & Run +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -Requires Python 3.8 or newer. +

+
-1) First of all, you need to [install Beets](https://beets.readthedocs.io/en/stable/guides/main.html): -2) Install the dependancies with: +BeetstreamNext is a fork of Beetstream, a [Beets.io](https://beets.io) plugin that exposes [OpenSubsonic API endpoints](https://opensubsonic.netlify.app/docs/opensubsonic-api/), allowing you to stream your Beets library. -``` -$ pip install beetstream -``` +I started implementing new features to Beetstream but ended up rewriting a significant part of it, so I figured it'd make more sense to keep it as a distinct project. +The goal is to cover all of the modern OpenSubsonic API, with some additions. I'm getting there :) -3) Enable the plugin for Beets in your config file `~/.config/beets/config.yaml`: -```yaml -plugins: beetstream -``` +Personally, I use Beets to manage my music library but I don't like to write metadata to the files. So with this, I can have the best of both worlds. + +## Key Features + +- **Extensive API coverage**: Implements a wide range of OpenSubsonic endpoints, soon to cover everything. +- **On-the-fly transcoding**: As in the original project, this uses **FFmpeg** to transcode audio in real-time to your desired bitrate. Direct streaming is also supported. +- **Full cover art support**: Serves artwork from multiple sources: + - Local album art path from your Beets library + - Fallback to the [Cover Art Archive](https://coverartarchive.org/) using MusicBrainz IDs + - Fetches and caches artist images from the [Deezer API](https://developers.deezer.com/api) + - Extracts embedded artwork directly from media files +- **Dynamic Playlist management**: + - Reads `.m3u` playlists from specified directories + - Supports creating and deleting playlists directly through the API + - Integrates with Beets' native `playlist` and `smartplaylist` plugins +- **Rich metadata integration**: Fetches optional artist info like biographies, top tracks, and similar artists from the [Last.fm API](https://www.last.fm/api) + +- **COMING SOON: Multi-user support**: Dedicated SQLite database to manage users, ratings, bookmarks, and starred content (full multi-user endpoints are in development) +- **COMING SOON: Actual authentication**: User credentials, encryption at rest + +## Installation + +Requires Python 3.9.1 or newer. + +> [!NOTE] +> BeetstreamNext is not yet available on PyPI. Installation currently requires cloning the source code from GitHub. + +[//]: # (### For Users) + +1. **Install Beets**: If you haven't already, [install and configure Beets](https://beets.readthedocs.io/en/stable/guides/main.html). You will also need `git` installed on your system. + +2. **Clone the BeetstreamNext Repository**: + ```bash + git clone https://github.com/FlorentLM/BeetstreamNext.git + cd BeetstreamNext + ``` + +3. **Install the Plugin**: + From inside the `BeetstreamNext` directory, run the installation command. You can use `pip` or any modern installer like `uv`. + ```bash + pip install . + ``` + For transcoding, you will also need to have **FFmpeg** installed and available in your system's PATH. + +4. **Enable the Plugin**: Add `beetstreamnext` to the `plugins` section of your Beets config file (`~/.config/beets/config.yaml`): + ```yaml + plugins: beetstreamnext + ``` + +5. **Run the Server**: + ```bash + beet beetstreamnext + ``` + +[//]: # (### For Developers) + +[//]: # () +[//]: # (If you want to contribute to BeetstreamNext, the setup process uses [Poetry](https://python-poetry.org/) for dependency management.) + +[//]: # () +[//]: # (1. **Clone the repository**:) + +[//]: # ( ```bash) + +[//]: # ( git clone https://github.com/FlorentLM/BeetstreamNext.git) + +[//]: # ( cd BeetstreamNext) + +[//]: # ( ```) + +[//]: # () +[//]: # (2. **Install dependencies**:) + +[//]: # ( This will create a virtual environment and install all required packages for development and testing.) + +[//]: # ( ```bash) + +[//]: # ( poetry install) -4) **Optional** You can change the host and port in your config file `~/.config/beets/config.yaml`. -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. +[//]: # ( ```) + +[//]: # () +[//]: # (3. **Activate the environment**:) + +[//]: # ( ```bash) + +[//]: # ( poetry shell) + +[//]: # ( ```) + +[//]: # () +[//]: # (4. **Run the server**:) + +[//]: # ( From inside the activated shell, you can run the plugin directly.) + +[//]: # ( ```bash) + +[//]: # ( beet beetstreamnext) + +[//]: # ( ```) + +[//]: # (See the [**CONTRIBUTING.md**](./CONTRIBUTING.md) file for more details on running tests and submitting changes.) + +## Configuration + +You can configure BeetstreamNext in your Beets `config.yaml` file. Here are the available options with their default values: -Here are the default values: ```yaml -beetstream: +beetstreamnext: host: 0.0.0.0 port: 8080 - never_transcode: False + never_transcode: False # Never re-encode files, even if a client requests it. + + # Artist Image Handling + fetch_artists_images: True # Fetch artist photos from Deezer when a client requests them. + save_artists_images: True # Save fetched artist photos to their respective folders. + + # Playlist Configuration + playlist_dirs: # A list of directories to scan for .m3u playlists. + - '/path/to/my/playlists' + - '/another/path/for/playlists' ``` -5) Run with: -``` -$ beet beetstream -``` +### Environment Variables + +Some features require API keys or secrets, which should be configured as environment variables for security. You can place these in a `.env` file in the directory where you run the `beet` command. + +[//]: # (- **`BEETSTREAMNEXT_KEY` (Required for User features)**: A unique encryption key for storing user data. You can generate one by running this Python command:) + +[//]: # ( ```bash) + +[//]: # ( python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())") + +[//]: # ( ```) + +- **`LASTFM_API_KEY` (Optional)**: Your API key from Last.fm to enable fetching artist bios, top tracks, and similar songs. + ``` + LASTFM_API_KEY="your_lastfm_api_key_here" + ``` -## Clients Configuration +## Client Configuration ### Authentication -There is currently no security whatsoever. You can put whatever user and password you want in your favorite app. +The backend for a full, multi-user authentication system is under active development. For now, **you can use any username and password** in your client to connect to the server. The server will respond as a default "admin" user. ### Server and Port -Currently runs on port `8080`. i.e: `https://192.168.1.10:8080`. You can configure it in `~/.config/beets/config.yaml`. Defaults are: -```yaml -beetstream: - host: 0.0.0.0 - port: 8080 -``` +Connect your client to the server's IP address and the configured port (default is `8080`). For example: `http://192.168.1.10:8080`. ## Supported Clients -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! +BeetstreamNext aims for broad compatibility with any Subsonic-compliant player. It has been successfully tested with the following clients: + +#### Android +- [Symfonium](https://symfonium.app/) +- [Tempo](https://github.com/CappielloAntonio/tempo) +- [SubTune](https://github.com/TaylorKunZhang/SubTune) +- [substreamer](https://substreamerapp.com/) +- [Ultrasonic](https://gitlab.com/ultrasonic/ultrasonic) + +#### Desktop +- [Supersonic](https://github.com/dweymouth/supersonic) -### Android +## Roadmap -- [Subsonic](https://play.google.com/store/apps/details?id=net.sourceforge.subsonic.androidapp) (official app) -- [DSub](https://play.google.com/store/apps/details?id=github.daneren2005.dsub) -- [Audinaut](https://play.google.com/store/apps/details?id=net.nullsum.audinaut) -- [Ultrasonic](https://play.google.com/store/apps/details?id=org.moire.ultrasonic) -- [GoSONIC](https://play.google.com/store/apps/details?id=com.readysteadygosoftware.gosonic) -- [Subtracks](https://play.google.com/store/apps/details?id=com.subtracks) -- [Music Stash](https://play.google.com/store/apps/details?id=com.ghenry22.mymusicstash) -- [substreamer](https://play.google.com/store/apps/details?id=com.ghenry22.substream2) +This project is under active development. Here is a high-level overview of planned features and missing endpoints. -### Desktop +#### Core Features (In Progress) +- [ ] Finalize BeetstreamNext's database storage for multi-user data +- [ ] Implement a complete, secure authentication and user management system +- [ ] Create a Docker image -- [Clementine](https://www.clementine-player.org) +#### Missing API Endpoints +These endpoints require the database and user management systems to be fully operational: +- `getUsers`, `createUser`, `updateUser`, `deleteUser` +- `changePassword` +- `updatePlaylist` +- `getAvatar` +- `star`, `unstar`, `setRating` +- `getBookmarks`, `createBookmark`, `deleteBookmark` -### Web +#### Future API Endpoints +- `getLyrics` +- `getPlayQueue`, `savePlayQueue` +- `getScanStatus`, `startScan` -- [Jamstash](http://jamstash.com) ([Chrome App](https://chrome.google.com/webstore/detail/jamstash/jccdpflnecheidefpofmlblgebobbloc)) -- [SubFire](http://subfireplayer.net) +#### Video, Radio & Podcast Endpoints +These are lower priority but may be considered in the future. +- `getVideos`, `getVideoInfo`, `hls`, `getCaptions` +- `getPodcasts`, `getNewestPodcasts`, etc. +- `getInternetRadioStations` and related endpoints. -_Currently supports a subset of API v1.16.1, avaiable as Json, Jsonp and XML._ +#### Social Endpoints +- `getNowPlaying` +- `getShares`, `createShare`, `updateShare`, `deleteShare` +- `jukeboxControl` +- `getChatMessages`, `addChatMessage` -## Contributing +## License -There is still some [missing endpoints](missing-endpoints.md) and `TODO` in the code. -Feel free to create some PR! +This project is licensed under the MIT License. See the `LICENSE` file for details. \ No newline at end of file diff --git a/beetsplug/beetstream/__init__.py b/beetsplug/beetstream/__init__.py deleted file mode 100644 index 0e798e0..0000000 --- a/beetsplug/beetstream/__init__.py +++ /dev/null @@ -1,149 +0,0 @@ -# -*- coding: utf-8 -*- -# This file is part of beets. -# Copyright 2016, Adrian Sampson. -# -# Permission is hereby granted, free of charge, to any person obtaining -# a copy of this software and associated documentation files (the -# "Software"), to deal in the Software without restriction, including -# without limitation the rights to use, copy, modify, merge, publish, -# distribute, sublicense, and/or sell copies of the Software, and to -# permit persons to whom the Software is furnished to do so, subject to -# the following conditions: -# -# The above copyright notice and this permission notice shall be -# included in all copies or substantial portions of the Software. - -"""Beetstream is a Beets.io plugin that exposes SubSonic API endpoints.""" -from beets.plugins import BeetsPlugin -from beets import config -from beets import ui -import flask -from flask import g -from flask_cors import CORS - -ARTIST_ID_PREFIX = "1" -ALBUM_ID_PREFIX = "2" -SONG_ID_PREFIX = "3" - -# Flask setup. -app = flask.Flask(__name__) - -@app.before_request -def before_request(): - g.lib = app.config['lib'] - -@app.route('/') -def home(): - return "Beetstream server running" - -from beetsplug.beetstream.utils import * -import beetsplug.beetstream.albums -import beetsplug.beetstream.artists -import beetsplug.beetstream.coverart -import beetsplug.beetstream.dummy -import beetsplug.beetstream.playlists -import beetsplug.beetstream.search -import beetsplug.beetstream.songs -import beetsplug.beetstream.users - -# Plugin hook. -class BeetstreamPlugin(BeetsPlugin): - def __init__(self): - super(BeetstreamPlugin, self).__init__() - self.config.add({ - 'host': u'0.0.0.0', - 'port': 8080, - 'cors': '*', - 'cors_supports_credentials': True, - 'reverse_proxy': False, - 'include_paths': False, - 'never_transcode': False, - 'playlist_dir': '', - }) - - def commands(self): - cmd = ui.Subcommand('beetstream', help=u'run Beetstream server, exposing SubSonic API') - cmd.parser.add_option(u'-d', u'--debug', action='store_true', - default=False, help=u'debug mode') - - def func(lib, opts, args): - args = ui.decargs(args) - if args: - self.config['host'] = args.pop(0) - if args: - self.config['port'] = int(args.pop(0)) - - app.config['lib'] = lib - # Normalizes json output - app.config['JSONIFY_PRETTYPRINT_REGULAR'] = False - - app.config['INCLUDE_PATHS'] = self.config['include_paths'] - app.config['never_transcode'] = self.config['never_transcode'] - playlist_dir = self.config['playlist_dir'] - if not playlist_dir: - try: - playlist_dir = config['smartplaylist']['playlist_dir'].get() - except: - pass - app.config['playlist_dir'] = playlist_dir - - # Enable CORS if required. - if self.config['cors']: - self._log.info(u'Enabling CORS with origin: {0}', - self.config['cors']) - app.config['CORS_ALLOW_HEADERS'] = "Content-Type" - app.config['CORS_RESOURCES'] = { - r"/*": {"origins": self.config['cors'].get(str)} - } - CORS( - app, - supports_credentials=self.config[ - 'cors_supports_credentials' - ].get(bool) - ) - - # Allow serving behind a reverse proxy - if self.config['reverse_proxy']: - app.wsgi_app = ReverseProxied(app.wsgi_app) - - # Start the web application. - app.run(host=self.config['host'].as_str(), - port=self.config['port'].get(int), - debug=opts.debug, threaded=True) - cmd.func = func - return [cmd] - -class ReverseProxied(object): - '''Wrap the application in this middleware and configure the - front-end server to add these headers, to let you quietly bind - this to a URL other than / and to an HTTP scheme that is - different than what is used locally. - - In nginx: - location /myprefix { - proxy_pass http://192.168.0.1:5001; - proxy_set_header Host $host; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Scheme $scheme; - proxy_set_header X-Script-Name /myprefix; - } - - From: http://flask.pocoo.org/snippets/35/ - - :param app: the WSGI application - ''' - def __init__(self, app): - self.app = app - - def __call__(self, environ, start_response): - script_name = environ.get('HTTP_X_SCRIPT_NAME', '') - if script_name: - environ['SCRIPT_NAME'] = script_name - path_info = environ['PATH_INFO'] - if path_info.startswith(script_name): - environ['PATH_INFO'] = path_info[len(script_name):] - - scheme = environ.get('HTTP_X_SCHEME', '') - if scheme: - environ['wsgi.url_scheme'] = scheme - return self.app(environ, start_response) diff --git a/beetsplug/beetstream/albums.py b/beetsplug/beetstream/albums.py deleted file mode 100644 index 951cd74..0000000 --- a/beetsplug/beetstream/albums.py +++ /dev/null @@ -1,227 +0,0 @@ -from beetsplug.beetstream.utils import * -from beetsplug.beetstream import app -import flask -from flask import g, request, Response -import xml.etree.cElementTree as ET -from PIL import Image -import io -from random import shuffle - -@app.route('/rest/getAlbum', methods=["GET", "POST"]) -@app.route('/rest/getAlbum.view', methods=["GET", "POST"]) -def get_album(): - res_format = request.values.get('f') or 'xml' - id = int(album_subid_to_beetid(request.values.get('id'))) - - album = g.lib.get_album(id) - songs = sorted(album.items(), key=lambda song: song.track) - - if (is_json(res_format)): - res = wrap_res("album", { - **map_album(album), - **{ "song": list(map(map_song, songs)) } - }) - return jsonpify(request, res) - else: - root = get_xml_root() - albumXml = ET.SubElement(root, 'album') - map_album_xml(albumXml, album) - - for song in songs: - s = ET.SubElement(albumXml, 'song') - map_song_xml(s, song) - - return Response(xml_to_string(root), mimetype='text/xml') - -@app.route('/rest/getAlbumList', methods=["GET", "POST"]) -@app.route('/rest/getAlbumList.view', methods=["GET", "POST"]) -def album_list(): - return get_album_list(1) - - -@app.route('/rest/getAlbumList2', methods=["GET", "POST"]) -@app.route('/rest/getAlbumList2.view', methods=["GET", "POST"]) -def album_list_2(): - return get_album_list(2) - -def get_album_list(version): - res_format = request.values.get('f') or 'xml' - # TODO type == 'starred' and type == 'frequent' - sort_by = request.values.get('type') or 'alphabeticalByName' - size = int(request.values.get('size') or 10) - offset = int(request.values.get('offset') or 0) - fromYear = int(request.values.get('fromYear') or 0) - toYear = int(request.values.get('toYear') or 3000) - genre = request.values.get('genre') - - albums = list(g.lib.albums()) - - if sort_by == 'newest': - albums.sort(key=lambda album: int(dict(album)['added']), reverse=True) - elif sort_by == 'alphabeticalByName': - albums.sort(key=lambda album: strip_accents(dict(album)['album']).upper()) - elif sort_by == 'alphabeticalByArtist': - albums.sort(key=lambda album: strip_accents(dict(album)['albumartist']).upper()) - elif sort_by == 'alphabeticalByArtist': - albums.sort(key=lambda album: strip_accents(dict(album)['albumartist']).upper()) - elif sort_by == 'recent': - albums.sort(key=lambda album: dict(album)['year'], reverse=True) - elif sort_by == 'byGenre': - albums = list(filter(lambda album: dict(album)['genre'].lower() == genre.lower(), albums)) - elif sort_by == 'byYear': - # TODO use month and day data to sort - if fromYear <= toYear: - albums = list(filter(lambda album: dict(album)['year'] >= fromYear and dict(album)['year'] <= toYear, albums)) - albums.sort(key=lambda album: int(dict(album)['year'])) - else: - albums = list(filter(lambda album: dict(album)['year'] >= toYear and dict(album)['year'] <= fromYear, albums)) - albums.sort(key=lambda album: int(dict(album)['year']), reverse=True) - elif sort_by == 'random': - shuffle(albums) - - albums = handleSizeAndOffset(albums, size, offset) - - if version == 1: - if (is_json(res_format)): - return jsonpify(request, wrap_res("albumList", { - "album": list(map(map_album_list, albums)) - })) - else: - root = get_xml_root() - album_list_xml = ET.SubElement(root, 'albumList') - - for album in albums: - a = ET.SubElement(album_list_xml, 'album') - map_album_list_xml(a, album) - - return Response(xml_to_string(root), mimetype='text/xml') - - elif version == 2: - if (is_json(res_format)): - return jsonpify(request, wrap_res("albumList2", { - "album": list(map(map_album, albums)) - })) - else: - root = get_xml_root() - album_list_xml = ET.SubElement(root, 'albumList2') - - for album in albums: - a = ET.SubElement(album_list_xml, 'album') - map_album_xml(a, album) - - return Response(xml_to_string(root), mimetype='text/xml') - -@app.route('/rest/getGenres', methods=["GET", "POST"]) -@app.route('/rest/getGenres.view', methods=["GET", "POST"]) -def genres(): - res_format = request.values.get('f') or 'xml' - with g.lib.transaction() as tx: - mixed_genres = list(tx.query(""" - SELECT genre, COUNT(*) AS n_song, "" AS n_album FROM items GROUP BY genre - UNION ALL - SELECT genre, "" AS n_song, COUNT(*) AS n_album FROM albums GROUP BY genre - """)) - - genres = {} - for genre in mixed_genres: - key = genre[0] - if (not key in genres.keys()): - genres[key] = (genre[1], 0) - if (genre[2]): - genres[key] = (genres[key][0], genre[2]) - - genres = [(k, v[0], v[1]) for k, v in genres.items()] - # genres.sort(key=lambda genre: strip_accents(genre[0]).upper()) - genres.sort(key=lambda genre: genre[1]) - genres.reverse() - genres = filter(lambda genre: genre[0] != u"", genres) - - if (is_json(res_format)): - def map_genre(genre): - return { - "value": genre[0], - "songCount": genre[1], - "albumCount": genre[2] - } - - return jsonpify(request, wrap_res("genres", { - "genre": list(map(map_genre, genres)) - })) - else: - root = get_xml_root() - genres_xml = ET.SubElement(root, 'genres') - - for genre in genres: - genre_xml = ET.SubElement(genres_xml, 'genre') - genre_xml.text = genre[0] - genre_xml.set("songCount", str(genre[1])) - genre_xml.set("albumCount", str(genre[2])) - - return Response(xml_to_string(root), mimetype='text/xml') - -@app.route('/rest/getMusicDirectory', methods=["GET", "POST"]) -@app.route('/rest/getMusicDirectory.view', methods=["GET", "POST"]) -def musicDirectory(): - # Works pretty much like a file system - # Usually Artist first, than Album, than Songs - res_format = request.values.get('f') or 'xml' - id = request.values.get('id') - - if id.startswith(ARTIST_ID_PREFIX): - artist_id = id - artist_name = artist_id_to_name(artist_id) - albums = g.lib.albums(artist_name.replace("'", "\\'")) - albums = filter(lambda album: album.albumartist == artist_name, albums) - - if (is_json(res_format)): - return jsonpify(request, wrap_res("directory", { - "id": artist_id, - "name": artist_name, - "child": list(map(map_album, albums)) - })) - else: - root = get_xml_root() - artist_xml = ET.SubElement(root, 'directory') - artist_xml.set("id", artist_id) - artist_xml.set("name", artist_name) - - for album in albums: - a = ET.SubElement(artist_xml, 'child') - map_album_xml(a, album) - - return Response(xml_to_string(root), mimetype='text/xml') - elif id.startswith(ALBUM_ID_PREFIX): - # Album - id = int(album_subid_to_beetid(id)) - album = g.lib.get_album(id) - songs = sorted(album.items(), key=lambda song: song.track) - - if (is_json(res_format)): - res = wrap_res("directory", { - **map_album(album), - **{ "child": list(map(map_song, songs)) } - }) - return jsonpify(request, res) - else: - root = get_xml_root() - albumXml = ET.SubElement(root, 'directory') - map_album_xml(albumXml, album) - - for song in songs: - s = ET.SubElement(albumXml, 'child') - map_song_xml(s, song) - - return Response(xml_to_string(root), mimetype='text/xml') - elif id.startswith(SONG_ID_PREFIX): - # Song - id = int(song_subid_to_beetid(id)) - song = g.lib.get_item(id) - - if (is_json(res_format)): - return jsonpify(request, wrap_res("directory", map_song(song))) - else: - root = get_xml_root() - s = ET.SubElement(root, 'directory') - map_song_xml(s, song) - - return Response(xml_to_string(root), mimetype='text/xml') diff --git a/beetsplug/beetstream/artists.py b/beetsplug/beetstream/artists.py deleted file mode 100644 index ef88382..0000000 --- a/beetsplug/beetstream/artists.py +++ /dev/null @@ -1,127 +0,0 @@ -import time -from beetsplug.beetstream.utils import * -from beetsplug.beetstream import app -from flask import g, request, Response -import xml.etree.cElementTree as ET - -@app.route('/rest/getArtists', methods=["GET", "POST"]) -@app.route('/rest/getArtists.view', methods=["GET", "POST"]) -def all_artists(): - return get_artists("artists") - -@app.route('/rest/getIndexes', methods=["GET", "POST"]) -@app.route('/rest/getIndexes.view', methods=["GET", "POST"]) -def indexes(): - return get_artists("indexes") - -def get_artists(version): - res_format = request.values.get('f') or 'xml' - with g.lib.transaction() as tx: - rows = tx.query("SELECT DISTINCT albumartist FROM albums") - all_artists = [row[0] for row in rows] - all_artists.sort(key=lambda name: strip_accents(name).upper()) - all_artists = filter(lambda name: len(name) > 0, all_artists) - - indicies_dict = {} - - for name in all_artists: - index = strip_accents(name[0]).upper() - if index not in indicies_dict: - indicies_dict[index] = [] - indicies_dict[index].append(name) - - if (is_json(res_format)): - indicies = [] - for index, artist_names in indicies_dict.items(): - indicies.append({ - "name": index, - "artist": list(map(map_artist, artist_names)) - }) - - return jsonpify(request, wrap_res(version, { - "ignoredArticles": "", - "lastModified": int(time.time() * 1000), - "index": indicies - })) - else: - root = get_xml_root() - indexes_xml = ET.SubElement(root, version) - indexes_xml.set('ignoredArticles', "") - - indicies = [] - for index, artist_names in indicies_dict.items(): - indicies.append({ - "name": index, - "artist": artist_names - }) - - for index in indicies: - index_xml = ET.SubElement(indexes_xml, 'index') - index_xml.set('name', index["name"]) - - for a in index["artist"]: - artist = ET.SubElement(index_xml, 'artist') - map_artist_xml(artist, a) - - return Response(xml_to_string(root), mimetype='text/xml') - -@app.route('/rest/getArtist', methods=["GET", "POST"]) -@app.route('/rest/getArtist.view', methods=["GET", "POST"]) -def artist(): - res_format = request.values.get('f') or 'xml' - artist_id = request.values.get('id') - artist_name = artist_id_to_name(artist_id) - albums = g.lib.albums(artist_name.replace("'", "\\'")) - albums = filter(lambda album: album.albumartist == artist_name, albums) - - if (is_json(res_format)): - return jsonpify(request, wrap_res("artist", { - "id": artist_id, - "name": artist_name, - "album": list(map(map_album, albums)) - })) - else: - root = get_xml_root() - artist_xml = ET.SubElement(root, 'artist') - artist_xml.set("id", artist_id) - artist_xml.set("name", artist_name) - - for album in albums: - a = ET.SubElement(artist_xml, 'album') - map_album_xml(a, album) - - return Response(xml_to_string(root), mimetype='text/xml') - -@app.route('/rest/getArtistInfo2', methods=["GET", "POST"]) -@app.route('/rest/getArtistInfo2.view', methods=["GET", "POST"]) -def artistInfo2(): - res_format = request.values.get('f') or 'xml' - artist_name = artist_id_to_name(request.values.get('id')) - - if (is_json(res_format)): - return jsonpify(request, wrap_res("artistInfo2", { - "biography": f"wow. much artist. very {artist_name}", - "musicBrainzId": "", - "lastFmUrl": "", - "smallImageUrl": "", - "mediumImageUrl": "", - "largeImageUrl": "" - })) - else: - root = get_xml_root() - artist_xml = ET.SubElement(root, 'artistInfo2') - - biography = ET.SubElement(artist_xml, "biography") - biography.text = f"wow. much artist very {artist_name}." - musicBrainzId = ET.SubElement(artist_xml, "musicBrainzId") - musicBrainzId.text = "" - lastFmUrl = ET.SubElement(artist_xml, "lastFmUrl") - lastFmUrl.text = "" - smallImageUrl = ET.SubElement(artist_xml, "smallImageUrl") - smallImageUrl.text = "" - mediumImageUrl = ET.SubElement(artist_xml, "mediumImageUrl") - mediumImageUrl.text = "" - largeImageUrl = ET.SubElement(artist_xml, "largeImageUrl") - largeImageUrl.text = "" - - return Response(xml_to_string(root), mimetype='text/xml') diff --git a/beetsplug/beetstream/coverart.py b/beetsplug/beetstream/coverart.py deleted file mode 100644 index 220145c..0000000 --- a/beetsplug/beetstream/coverart.py +++ /dev/null @@ -1,62 +0,0 @@ -from beetsplug.beetstream.utils import * -from beetsplug.beetstream import app -from flask import g, request -from io import BytesIO -from PIL import Image -import flask -import os -import subprocess -import tempfile - -@app.route('/rest/getCoverArt', methods=["GET", "POST"]) -@app.route('/rest/getCoverArt.view', methods=["GET", "POST"]) -def cover_art_file(): - id = request.values.get('id') - size = request.values.get('size') - album = None - - if id[:len(ALBUM_ID_PREFIX)] == ALBUM_ID_PREFIX: - album_id = int(album_subid_to_beetid(id) or -1) - album = g.lib.get_album(album_id) - else: - item_id = int(song_subid_to_beetid(id) or -1) - item = g.lib.get_item(item_id) - - if item is not None: - if item.album_id is not None: - album = g.lib.get_album(item.album_id) - if not album or not album.artpath: - tmp_file = tempfile.NamedTemporaryFile(prefix='beetstream-cover-', suffix='.png') - tmp_file_name = tmp_file.name - try: - tmp_file.close() - subprocess.run(['ffmpeg', '-i', item.path, '-an', '-c:v', - 'copy', tmp_file_name, - '-hide_banner', '-loglevel', 'error',]) - - return _send_image(tmp_file_name, size) - finally: - os.remove(tmp_file_name) - - if album and album.artpath: - image_path = album.artpath.decode('utf-8') - - if size is not None and int(size) > 0: - return _send_image(image_path, size) - - return flask.send_file(image_path) - else: - return flask.abort(404) - -def _send_image(path_or_bytesio, size): - converted = BytesIO() - img = Image.open(path_or_bytesio) - - if size is not None and int(size) > 0: - size = int(size) - img = img.resize((size, size)) - - img.convert('RGB').save(converted, 'PNG') - converted.seek(0) - - return flask.send_file(converted, mimetype='image/png') diff --git a/beetsplug/beetstream/dummy.py b/beetsplug/beetstream/dummy.py deleted file mode 100644 index 62504ec..0000000 --- a/beetsplug/beetstream/dummy.py +++ /dev/null @@ -1,61 +0,0 @@ -from beetsplug.beetstream.utils import * -from beetsplug.beetstream import app -from flask import request, Response -import xml.etree.cElementTree as ET - -# Fake endpoint to avoid some apps errors -@app.route('/rest/scrobble', methods=["GET", "POST"]) -@app.route('/rest/scrobble.view', methods=["GET", "POST"]) -@app.route('/rest/ping', methods=["GET", "POST"]) -@app.route('/rest/ping.view', methods=["GET", "POST"]) -def ping(): - res_format = request.values.get('f') or 'xml' - - if (is_json(res_format)): - return jsonpify(request, { - "subsonic-response": { - "status": "ok", - "version": "1.16.1" - } - }) - else: - root = get_xml_root() - return Response(xml_to_string(root), mimetype='text/xml') - -@app.route('/rest/getLicense', methods=["GET", "POST"]) -@app.route('/rest/getLicense.view', methods=["GET", "POST"]) -def getLicense(): - res_format = request.values.get('f') or 'xml' - - if (is_json(res_format)): - return jsonpify(request, wrap_res("license", { - "valid": True, - "email": "foo@example.com", - "trialExpires": "3000-01-01T00:00:00.000Z" - })) - else: - root = get_xml_root() - l = ET.SubElement(root, 'license') - l.set("valid", "true") - l.set("email", "foo@example.com") - l.set("trialExpires", "3000-01-01T00:00:00.000Z") - return Response(xml_to_string(root), mimetype='text/xml') - -@app.route('/rest/getMusicFolders', methods=["GET", "POST"]) -@app.route('/rest/getMusicFolders.view', methods=["GET", "POST"]) -def music_folder(): - res_format = request.values.get('f') or 'xml' - if (is_json(res_format)): - return jsonpify(request, wrap_res("musicFolders", { - "musicFolder": [{ - "id": 0, - "name": "Music" - }] - })) - else: - root = get_xml_root() - folder = ET.SubElement(root, 'musicFolders') - folder.set("id", "0") - folder.set("name", "Music") - - return Response(xml_to_string(root), mimetype='text/xml') diff --git a/beetsplug/beetstream/playlistprovider.py b/beetsplug/beetstream/playlistprovider.py deleted file mode 100644 index dc8db67..0000000 --- a/beetsplug/beetstream/playlistprovider.py +++ /dev/null @@ -1,140 +0,0 @@ -import glob -import os -import pathlib -import re -import sys -from beetsplug.beetstream.utils import strip_accents -from flask import current_app as app -from werkzeug.utils import safe_join - -extinf_regex = re.compile(r'^#EXTINF:([0-9]+)( [^,]+)?,[\s]*(.*)') -highint32 = 1<<31 - -class PlaylistProvider: - def __init__(self, dir): - self.dir = dir - self._playlists = {} - - def _refresh(self): - self._playlists = {p.id: p for p in self._load_playlists()} - app.logger.debug(f"Loaded {len(self._playlists)} playlists") - - def _load_playlists(self): - if not self.dir: - return - paths = glob.glob(os.path.join(self.dir, "**.m3u8")) - paths += glob.glob(os.path.join(self.dir, "**.m3u")) - paths.sort() - for path in paths: - try: - yield self._playlist(path) - except Exception as e: - app.logger.error(f"Failed to load playlist {filepath}: {e}") - - def playlists(self): - self._refresh() - playlists = self._playlists - ids = [k for k, v in playlists.items() if v] - ids.sort() - return [playlists[id] for id in ids] - - def playlist(self, id): - filepath = safe_join(self.dir, id) - playlist = self._playlist(filepath) - if playlist.id not in self._playlists: # add to cache - playlists = self._playlists.copy() - playlists[playlist.id] = playlist - self._playlists = playlists - return playlist - - def _playlist(self, filepath): - id = self._path2id(filepath) - name = pathlib.Path(os.path.basename(filepath)).stem - playlist = self._playlists.get(id) - mtime = pathlib.Path(filepath).stat().st_mtime - if playlist and playlist.modified == mtime: - return playlist # cached metadata - app.logger.debug(f"Loading playlist {filepath}") - return Playlist(id, name, mtime, filepath) - - def _path2id(self, filepath): - return os.path.relpath(filepath, self.dir) - -class Playlist: - def __init__(self, id, name, modified, path): - self.id = id - self.name = name - self.modified = modified - self.path = path - self.count = 0 - self.duration = 0 - artists = {} - max_artists = 10 - for item in self.items(): - self.count += 1 - self.duration += item.duration - artist = Artist(item.title.split(' - ')[0]) - found = artists.get(artist.key) - if found: - found.count += 1 - else: - if len(artists) > max_artists: - l = _sortedartists(artists)[:max_artists] - artists = {a.key: a for a in l} - artists[artist.key] = artist - self.artists = ', '.join([a.name for a in _sortedartists(artists)]) - - def items(self): - return parse_m3u_playlist(self.path) - -def _sortedartists(artists): - l = [a for _,a in artists.items()] - l.sort(key=lambda a: (highint32-a.count, a.name)) - return l - -class Artist: - def __init__(self, name): - self.key = strip_accents(name.lower()) - self.name = name - self.count = 1 - -def parse_m3u_playlist(filepath): - ''' - Parses an M3U playlist and yields its items, one at a time. - CAUTION: Attribute values that contain ',' or ' ' are not supported! - ''' - with open(filepath, 'r', encoding='UTF-8') as file: - linenum = 0 - item = PlaylistItem() - while line := file.readline(): - line = line.rstrip() - linenum += 1 - if linenum == 1: - assert line == '#EXTM3U', f"File {filepath} is not an EXTM3U playlist!" - continue - if len(line.strip()) == 0: - continue - m = extinf_regex.match(line) - if m: - item = PlaylistItem() - duration = m.group(1) - item.duration = int(duration) - attrs = m.group(2) - if attrs: - item.attrs = {k: v.strip('"') for k,v in [kv.split('=') for kv in attrs.strip().split(' ')]} - else: - item.attrs = {} - item.title = m.group(3) - continue - if line.startswith('#'): - continue - item.uri = line - yield item - item = PlaylistItem() - -class PlaylistItem(): - def __init__(self): - self.title = None - self.duration = None - self.uri = None - self.attrs = None diff --git a/beetsplug/beetstream/playlists.py b/beetsplug/beetstream/playlists.py deleted file mode 100644 index 4cffd89..0000000 --- a/beetsplug/beetstream/playlists.py +++ /dev/null @@ -1,56 +0,0 @@ -import xml.etree.cElementTree as ET -from beetsplug.beetstream.utils import * -from beetsplug.beetstream import app -from flask import g, request, Response -from .playlistprovider import PlaylistProvider - -_playlist_provider = PlaylistProvider('') - -# TODO link with https://beets.readthedocs.io/en/stable/plugins/playlist.html -@app.route('/rest/getPlaylists', methods=['GET', 'POST']) -@app.route('/rest/getPlaylists.view', methods=['GET', 'POST']) -def playlists(): - res_format = request.values.get('f') or 'xml' - playlists = playlist_provider().playlists() - if (is_json(res_format)): - return jsonpify(request, wrap_res('playlists', { - 'playlist': [map_playlist(p) for p in playlists] - })) - else: - root = get_xml_root() - playlists_el = ET.SubElement(root, 'playlists') - for p in playlists: - playlist_el = ET.SubElement(playlists_el, 'playlist') - map_playlist_xml(playlist_el, p) - return Response(xml_to_string(root), mimetype='text/xml') - -@app.route('/rest/getPlaylist', methods=['GET', 'POST']) -@app.route('/rest/getPlaylist.view', methods=['GET', 'POST']) -def playlist(): - res_format = request.values.get('f') or 'xml' - id = request.values.get('id') - playlist = playlist_provider().playlist(id) - items = playlist.items() - if (is_json(res_format)): - p = map_playlist(playlist) - p['entry'] = [_song(item.attrs['id']) for item in items] - return jsonpify(request, wrap_res('playlist', p)) - else: - root = get_xml_root() - playlist_xml = ET.SubElement(root, 'playlist') - map_playlist_xml(playlist_xml, playlist) - for item in items: - song = g.lib.get_item(item.attrs['id']) - entry = ET.SubElement(playlist_xml, 'entry') - map_song_xml(entry, song) - return Response(xml_to_string(root), mimetype='text/xml') - -def _song(id): - return map_song(g.lib.get_item(int(id))) - -def playlist_provider(): - if 'playlist_dir' in app.config: - _playlist_provider.dir = app.config['playlist_dir'] - if not _playlist_provider.dir: - app.logger.warning('No playlist_dir configured') - return _playlist_provider diff --git a/beetsplug/beetstream/search.py b/beetsplug/beetstream/search.py deleted file mode 100644 index ed13598..0000000 --- a/beetsplug/beetstream/search.py +++ /dev/null @@ -1,58 +0,0 @@ -from beetsplug.beetstream.utils import * -from beetsplug.beetstream import app -from flask import g, request, Response -import xml.etree.cElementTree as ET - -@app.route('/rest/search2', methods=["GET", "POST"]) -@app.route('/rest/search2.view', methods=["GET", "POST"]) -def search2(): - return search(2) - -@app.route('/rest/search3', methods=["GET", "POST"]) -@app.route('/rest/search3.view', methods=["GET", "POST"]) -def search3(): - return search(3) - -def search(version): - res_format = request.values.get('f') or 'xml' - query = request.values.get('query') or "" - artistCount = int(request.values.get('artistCount') or 20) - artistOffset = int(request.values.get('artistOffset') or 0) - albumCount = int(request.values.get('albumCount') or 20) - albumOffset = int(request.values.get('albumOffset') or 0) - songCount = int(request.values.get('songCount') or 20) - songOffset = int(request.values.get('songOffset') or 0) - - songs = handleSizeAndOffset(list(g.lib.items("title:{}".format(query.replace("'", "\\'")))), songCount, songOffset) - albums = handleSizeAndOffset(list(g.lib.albums("album:{}".format(query.replace("'", "\\'")))), albumCount, albumOffset) - - with g.lib.transaction() as tx: - rows = tx.query("SELECT DISTINCT albumartist FROM albums") - artists = [row[0] for row in rows] - artists = list(filter(lambda artist: strip_accents(query).lower() in strip_accents(artist).lower(), artists)) - artists.sort(key=lambda name: strip_accents(name).upper()) - artists = handleSizeAndOffset(artists, artistCount, artistOffset) - - if (is_json(res_format)): - return jsonpify(request, wrap_res("searchResult{}".format(version), { - "artist": list(map(map_artist, artists)), - "album": list(map(map_album, albums)), - "song": list(map(map_song, songs)) - })) - else: - root = get_xml_root() - search_result = ET.SubElement(root, 'searchResult{}'.format(version)) - - for artist in artists: - a = ET.SubElement(search_result, 'artist') - map_artist_xml(a, artist) - - for album in albums: - a = ET.SubElement(search_result, 'album') - map_album_xml(a, album) - - for song in songs: - s = ET.SubElement(search_result, 'song') - map_song_xml(s, song) - - return Response(xml_to_string(root), mimetype='text/xml') diff --git a/beetsplug/beetstream/songs.py b/beetsplug/beetstream/songs.py deleted file mode 100644 index 75eb0f1..0000000 --- a/beetsplug/beetstream/songs.py +++ /dev/null @@ -1,130 +0,0 @@ -from beetsplug.beetstream.utils import * -from beetsplug.beetstream import app, stream -from flask import g, request, Response -from beets.random import random_objs -import xml.etree.cElementTree as ET - -@app.route('/rest/getSong', methods=["GET", "POST"]) -@app.route('/rest/getSong.view', methods=["GET", "POST"]) -def song(): - res_format = request.values.get('f') or 'xml' - id = int(song_subid_to_beetid(request.values.get('id'))) - song = g.lib.get_item(id) - - if (is_json(res_format)): - return jsonpify(request, wrap_res("song", map_song(song))) - else: - root = get_xml_root() - s = ET.SubElement(root, 'song') - map_song_xml(s, song) - - return Response(xml_to_string(root), mimetype='text/xml') - -@app.route('/rest/getSongsByGenre', methods=["GET", "POST"]) -@app.route('/rest/getSongsByGenre.view', methods=["GET", "POST"]) -def songs_by_genre(): - res_format = request.values.get('f') or 'xml' - genre = request.values.get('genre') - count = int(request.values.get('count') or 10) - offset = int(request.values.get('offset') or 0) - - songs = handleSizeAndOffset(list(g.lib.items('genre:' + genre.replace("'", "\\'"))), count, offset) - - if (is_json(res_format)): - return jsonpify(request, wrap_res("songsByGenre", { - "song": list(map(map_song, songs)) - })) - else: - root = get_xml_root() - songs_by_genre = ET.SubElement(root, 'songsByGenre') - - for song in songs: - s = ET.SubElement(songs_by_genre, 'song') - map_song_xml(s, song) - - return Response(xml_to_string(root), mimetype='text/xml') - -@app.route('/rest/stream', methods=["GET", "POST"]) -@app.route('/rest/stream.view', methods=["GET", "POST"]) -def stream_song(): - maxBitrate = int(request.values.get('maxBitRate') or 0) - format = request.values.get('format') - - id = int(song_subid_to_beetid(request.values.get('id'))) - item = g.lib.get_item(id) - - itemPath = item.path.decode('utf-8') - - if app.config['never_transcode'] or format == 'raw' or maxBitrate <= 0 or item.bitrate <= maxBitrate * 1000: - return stream.send_raw_file(itemPath) - else: - return stream.try_to_transcode(itemPath, maxBitrate) - -@app.route('/rest/download', methods=["GET", "POST"]) -@app.route('/rest/download.view', methods=["GET", "POST"]) -def download_song(): - id = int(song_subid_to_beetid(request.values.get('id'))) - item = g.lib.get_item(id) - - return stream.send_raw_file(item.path.decode('utf-8')) - -@app.route('/rest/getRandomSongs', methods=["GET", "POST"]) -@app.route('/rest/getRandomSongs.view', methods=["GET", "POST"]) -def random_songs(): - res_format = request.values.get('f') or 'xml' - size = int(request.values.get('size') or 10) - songs = list(g.lib.items()) - songs = random_objs(songs, -1, size) - - if (is_json(res_format)): - return jsonpify(request, wrap_res("randomSongs", { - "song": list(map(map_song, songs)) - })) - else: - root = get_xml_root() - album = ET.SubElement(root, 'randomSongs') - - for song in songs: - s = ET.SubElement(album, 'song') - map_song_xml(s, song) - - return Response(xml_to_string(root), mimetype='text/xml') - -# TODO link with Last.fm or ListenBrainz -@app.route('/rest/getTopSongs', methods=["GET", "POST"]) -@app.route('/rest/getTopSongs.view', methods=["GET", "POST"]) -def top_songs(): - res_format = request.values.get('f') or 'xml' - if (is_json(res_format)): - return jsonpify(request, wrap_res("topSongs", {})) - else: - root = get_xml_root() - ET.SubElement(root, 'topSongs') - return Response(xml_to_string(root), mimetype='text/xml') - - -@app.route('/rest/getStarred', methods=["GET", "POST"]) -@app.route('/rest/getStarred.view', methods=["GET", "POST"]) -def starred_songs(): - res_format = request.values.get('f') or 'xml' - if (is_json(res_format)): - return jsonpify(request, wrap_res("starred", { - "song": [] - })) - else: - root = get_xml_root() - ET.SubElement(root, 'starred') - return Response(xml_to_string(root), mimetype='text/xml') - -@app.route('/rest/getStarred2', methods=["GET", "POST"]) -@app.route('/rest/getStarred2.view', methods=["GET", "POST"]) -def starred2_songs(): - res_format = request.values.get('f') or 'xml' - if (is_json(res_format)): - return jsonpify(request, wrap_res("starred2", { - "song": [] - })) - else: - root = get_xml_root() - ET.SubElement(root, 'starred2') - return Response(xml_to_string(root), mimetype='text/xml') diff --git a/beetsplug/beetstream/stream.py b/beetsplug/beetstream/stream.py deleted file mode 100644 index 9af1bd9..0000000 --- a/beetsplug/beetstream/stream.py +++ /dev/null @@ -1,31 +0,0 @@ -from beetsplug.beetstream.utils import path_to_content_type -from flask import send_file, Response - -import importlib -have_ffmpeg = importlib.util.find_spec("ffmpeg") is not None - -if have_ffmpeg: - import ffmpeg - -def send_raw_file(filePath): - return send_file(filePath, mimetype=path_to_content_type(filePath)) - -def transcode_and_stream(filePath, maxBitrate): - if not have_ffmpeg: - raise RuntimeError("Can't transcode, ffmpeg-python is not available") - - outputStream = ( - ffmpeg - .input(filePath) - .audio - .output('pipe:', format="mp3", audio_bitrate=maxBitrate * 1000) - .run_async(pipe_stdout=True, quiet=True) - ) - - return Response(outputStream.stdout, mimetype='audio/mpeg') - -def try_to_transcode(filePath, maxBitrate): - if have_ffmpeg: - return transcode_and_stream(filePath, maxBitrate) - else: - return send_raw_file(filePath) diff --git a/beetsplug/beetstream/users.py b/beetsplug/beetstream/users.py deleted file mode 100644 index 669e70a..0000000 --- a/beetsplug/beetstream/users.py +++ /dev/null @@ -1,52 +0,0 @@ -from beetsplug.beetstream.utils import * -from beetsplug.beetstream import app -from flask import g, request, Response -import xml.etree.cElementTree as ET - -@app.route('/rest/getUser', methods=["GET", "POST"]) -@app.route('/rest/getUser.view', methods=["GET", "POST"]) -def user(): - res_format = request.values.get('f') or 'xml' - if (is_json(res_format)): - return jsonpify(request, wrap_res("user", { - "username" : "admin", - "email" : "foo@example.com", - "scrobblingEnabled" : True, - "adminRole" : True, - "settingsRole" : True, - "downloadRole" : True, - "uploadRole" : True, - "playlistRole" : True, - "coverArtRole" : True, - "commentRole" : True, - "podcastRole" : True, - "streamRole" : True, - "jukeboxRole" : True, - "shareRole" : True, - "videoConversionRole" : True, - "avatarLastChanged" : "1970-01-01T00:00:00.000Z", - "folder" : [ 0 ] - })) - else: - root = get_xml_root() - u = ET.SubElement(root, 'user') - u.set("username", "admin") - u.set("email", "foo@example.com") - u.set("scrobblingEnabled", "true") - u.set("adminRole", "true") - u.set("settingsRole", "true") - u.set("downloadRole", "true") - u.set("uploadRole", "true") - u.set("playlistRole", "true") - u.set("coverArtRole", "true") - u.set("commentRole", "true") - u.set("podcastRole", "true") - u.set("streamRole", "true") - u.set("jukeboxRole", "true") - u.set("shareRole", "true") - u.set("videoConversionRole", "true") - u.set("avatarLastChanged", "1970-01-01T00:00:00.000Z") - f = ET.SubElement(u, 'folder') - f.text = "0" - - return Response(xml_to_string(root), mimetype='text/xml') diff --git a/beetsplug/beetstream/utils.py b/beetsplug/beetstream/utils.py deleted file mode 100644 index 86649af..0000000 --- a/beetsplug/beetstream/utils.py +++ /dev/null @@ -1,282 +0,0 @@ -from beetsplug.beetstream import ALBUM_ID_PREFIX, ARTIST_ID_PREFIX, SONG_ID_PREFIX -import unicodedata -from datetime import datetime -import flask -import json -import base64 -import mimetypes -import os -import posixpath -import xml.etree.cElementTree as ET -from math import ceil -from xml.dom import minidom - -DEFAULT_MIME_TYPE = 'application/octet-stream' -EXTENSION_TO_MIME_TYPE_FALLBACK = { - '.aac' : 'audio/aac', - '.flac' : 'audio/flac', - '.mp3' : 'audio/mpeg', - '.mp4' : 'audio/mp4', - '.m4a' : 'audio/mp4', - '.ogg' : 'audio/ogg', - '.opus' : 'audio/opus', -} - -def strip_accents(s): - return ''.join(c for c in unicodedata.normalize('NFD', s) if unicodedata.category(c) != 'Mn') - -def timestamp_to_iso(timestamp): - return datetime.fromtimestamp(int(timestamp)).isoformat() - -def is_json(res_format): - return res_format == 'json' or res_format == 'jsonp' - -def wrap_res(key, json): - return { - "subsonic-response": { - "status": "ok", - "version": "1.16.1", - key: json - } - } - -def jsonpify(request, data): - if request.values.get("f") == "jsonp": - callback = request.values.get("callback") - return f"{callback}({json.dumps(data)});" - else: - return flask.jsonify(data) - -def get_xml_root(): - root = ET.Element('subsonic-response') - root.set('xmlns', 'http://subsonic.org/restapi') - root.set('status', 'ok') - root.set('version', '1.16.1') - return root - -def xml_to_string(xml): - # Add declaration: - return minidom.parseString(ET.tostring(xml, encoding='unicode', method='xml', xml_declaration=True)).toprettyxml() - -def map_album(album): - album = dict(album) - return { - "id": album_beetid_to_subid(str(album["id"])), - "name": album["album"], - "title": album["album"], - "album": album["album"], - "artist": album["albumartist"], - "artistId": artist_name_to_id(album["albumartist"]), - "parent": artist_name_to_id(album["albumartist"]), - "isDir": True, - "coverArt": album_beetid_to_subid(str(album["id"])) or "", - "songCount": 1, # TODO - "duration": 1, # TODO - "playCount": 1, # TODO - "created": timestamp_to_iso(album["added"]), - "year": album["year"], - "genre": album["genre"], - "starred": "1970-01-01T00:00:00.000Z", # TODO - "averageRating": 0 # TODO - } - -def map_album_xml(xml, album): - album = dict(album) - xml.set("id", album_beetid_to_subid(str(album["id"]))) - xml.set("name", album["album"]) - xml.set("title", album["album"]) - xml.set("album", album["album"]) - xml.set("artist", album["albumartist"]) - xml.set("artistId", artist_name_to_id(album["albumartist"])) - xml.set("parent", artist_name_to_id(album["albumartist"])) - xml.set("isDir", "true") - xml.set("coverArt", album_beetid_to_subid(str(album["id"])) or "") - xml.set("songCount", str(1)) # TODO - xml.set("duration", str(1)) # TODO - xml.set("playCount", str(1)) # TODO - xml.set("created", timestamp_to_iso(album["added"])) - xml.set("year", str(album["year"])) - xml.set("genre", album["genre"]) - xml.set("starred", "1970-01-01T00:00:00.000Z") # TODO - xml.set("averageRating", "0") # TODO - -def map_album_list(album): - album = dict(album) - return { - "id": album_beetid_to_subid(str(album["id"])), - "parent": artist_name_to_id(album["albumartist"]), - "isDir": True, - "title": album["album"], - "album": album["album"], - "artist": album["albumartist"], - "year": album["year"], - "genre": album["genre"], - "coverArt": album_beetid_to_subid(str(album["id"])) or "", - "userRating": 5, # TODO - "averageRating": 5, # TODO - "playCount": 1, # TODO - "created": timestamp_to_iso(album["added"]), - "starred": "" - } - -def map_album_list_xml(xml, album): - album = dict(album) - xml.set("id", album_beetid_to_subid(str(album["id"]))) - xml.set("parent", artist_name_to_id(album["albumartist"])) - xml.set("isDir", "true") - xml.set("title", album["album"]) - xml.set("album", album["album"]) - xml.set("artist", album["albumartist"]) - xml.set("year", str(album["year"])) - xml.set("genre", album["genre"]) - xml.set("coverArt", album_beetid_to_subid(str(album["id"])) or "") - xml.set("userRating", "5") # TODO - xml.set("averageRating", "5") # TODO - xml.set("playCount", "1") # TODO - xml.set("created", timestamp_to_iso(album["added"])) - xml.set("starred", "") - -def map_song(song): - song = dict(song) - path = song["path"].decode('utf-8') - return { - "id": song_beetid_to_subid(str(song["id"])), - "parent": album_beetid_to_subid(str(song["album_id"])), - "isDir": False, - "title": song["title"], - "name": song["title"], - "album": song["album"], - "artist": song["albumartist"], - "track": song["track"], - "year": song["year"], - "genre": song["genre"], - "coverArt": _cover_art_id(song), - "size": os.path.getsize(path), - "contentType": path_to_content_type(path), - "suffix": song["format"].lower(), - "duration": ceil(song["length"]), - "bitRate": ceil(song["bitrate"]/1000), - "path": path, - "playCount": 1, #TODO - "created": timestamp_to_iso(song["added"]), - # "starred": "2019-10-23T04:41:17.107Z", - "albumId": album_beetid_to_subid(str(song["album_id"])), - "artistId": artist_name_to_id(song["albumartist"]), - "type": "music", - "discNumber": song["disc"] - } - -def map_song_xml(xml, song): - song = dict(song) - path = song["path"].decode('utf-8') - xml.set("id", song_beetid_to_subid(str(song["id"]))) - xml.set("parent", album_beetid_to_subid(str(song["album_id"]))) - xml.set("isDir", "false") - xml.set("title", song["title"]) - xml.set("name", song["title"]) - xml.set("album", song["album"]) - xml.set("artist", song["albumartist"]) - xml.set("track", str(song["track"])) - xml.set("year", str(song["year"])) - xml.set("genre", song["genre"]) - xml.set("coverArt", _cover_art_id(song)), - xml.set("size", str(os.path.getsize(path))) - xml.set("contentType", path_to_content_type(path)) - xml.set("suffix", song["format"].lower()) - xml.set("duration", str(ceil(song["length"]))) - xml.set("bitRate", str(ceil(song["bitrate"]/1000))) - xml.set("path", path) - xml.set("playCount", str(1)) #TODO - xml.set("created", timestamp_to_iso(song["added"])) - xml.set("albumId", album_beetid_to_subid(str(song["album_id"]))) - xml.set("artistId", artist_name_to_id(song["albumartist"])) - xml.set("type", "music") - if song["disc"]: - xml.set("discNumber", str(song["disc"])) - -def _cover_art_id(song): - if song['album_id']: - return album_beetid_to_subid(str(song['album_id'])) - return song_beetid_to_subid(str(song['id'])) - -def map_artist(artist_name): - return { - "id": artist_name_to_id(artist_name), - "name": artist_name, - # TODO - # "starred": "2021-07-03T06:15:28.757Z", # nothing if not starred - "coverArt": "", - "albumCount": 1, - "artistImageUrl": "https://t4.ftcdn.net/jpg/00/64/67/63/360_F_64676383_LdbmhiNM6Ypzb3FM4PPuFP9rHe7ri8Ju.jpg" - } - -def map_artist_xml(xml, artist_name): - xml.set("id", artist_name_to_id(artist_name)) - xml.set("name", artist_name) - xml.set("coverArt", "") - xml.set("albumCount", "1") - xml.set("artistImageUrl", "https://t4.ftcdn.net/jpg/00/64/67/63/360_F_64676383_LdbmhiNM6Ypzb3FM4PPuFP9rHe7ri8Ju.jpg") - -def map_playlist(playlist): - return { - 'id': playlist.id, - 'name': playlist.name, - 'songCount': playlist.count, - 'duration': playlist.duration, - 'comment': playlist.artists, - 'created': timestamp_to_iso(playlist.modified), - } - -def map_playlist_xml(xml, playlist): - xml.set('id', playlist.id) - xml.set('name', playlist.name) - xml.set('songCount', str(playlist.count)) - xml.set('duration', str(ceil(playlist.duration))) - xml.set('comment', playlist.artists) - xml.set('created', timestamp_to_iso(playlist.modified)) - -def artist_name_to_id(name): - base64_name = base64.b64encode(name.encode('utf-8')).decode('utf-8') - return f"{ARTIST_ID_PREFIX}{base64_name}" - -def artist_id_to_name(id): - base64_id = id[len(ARTIST_ID_PREFIX):] - return base64.b64decode(base64_id.encode('utf-8')).decode('utf-8') - -def album_beetid_to_subid(id): - return f"{ALBUM_ID_PREFIX}{id}" - -def album_subid_to_beetid(id): - return id[len(ALBUM_ID_PREFIX):] - -def song_beetid_to_subid(id): - return f"{SONG_ID_PREFIX}{id}" - -def song_subid_to_beetid(id): - return id[len(SONG_ID_PREFIX):] - -def path_to_content_type(path): - result = mimetypes.guess_type(path)[0] - - if result: - return result - - # our mimetype database didn't have information about this file extension. - base, ext = posixpath.splitext(path) - result = EXTENSION_TO_MIME_TYPE_FALLBACK.get(ext) - - if result: - return result - - flask.current_app.logger.warning(f"No mime type mapped for {ext} extension: {path}") - - return DEFAULT_MIME_TYPE - -def handleSizeAndOffset(collection, size, offset): - if size is not None: - if offset is not None: - return collection[offset:offset + size] - else: - return collection[0:size] - else: - return collection diff --git a/beetsplug/beetstreamnext/__init__.py b/beetsplug/beetstreamnext/__init__.py new file mode 100644 index 0000000..e1888c1 --- /dev/null +++ b/beetsplug/beetstreamnext/__init__.py @@ -0,0 +1,211 @@ +# -*- coding: utf-8 -*- +# This file is part of beets. +# Copyright 2016, Adrian Sampson. +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. + +"""BeetstreamNext is a Beets.io plugin that exposes SubSonic API endpoints.""" + +from beets.plugins import BeetsPlugin +from beets.dbcore import types +from beets.library import DateType +from beets import config +from beets import ui +import flask +from flask import g +from flask_cors import CORS + +# Flask setup +app = flask.Flask(__name__) + +@app.before_request +def before_request(): + g.lib = app.config['lib'] + +@app.route('/') +def home(): + return "BeetstreamNext server running" + +from beetsplug.beetstreamnext.utils import * +import beetsplug.beetstreamnext.albums +import beetsplug.beetstreamnext.artists +import beetsplug.beetstreamnext.coverart +import beetsplug.beetstreamnext.dummy +import beetsplug.beetstreamnext.playlists +import beetsplug.beetstreamnext.search +import beetsplug.beetstreamnext.songs +import beetsplug.beetstreamnext.users +import beetsplug.beetstreamnext.general +import beetsplug.beetstreamnext.authentication + + +# Plugin hook +class BeetstreamNextPlugin(BeetsPlugin): + def __init__(self): + super(BeetstreamNextPlugin, self).__init__() + self.config.add({ + 'host': '0.0.0.0', + 'port': 8080, + 'cors': '*', + 'cors_supports_credentials': True, + 'reverse_proxy': False, + 'include_paths': False, + 'never_transcode': False, + 'fetch_artists_images': False, + 'save_artists_images': True, + 'lastfm_api_key': '', + 'playlist_dir': '', + 'users_storage': Path(config['library'].get()).parent / 'beetstreamnext_users.bin', + }) + self.config['lastfm_api_key'].redact = True + + item_types = { + # We use the same fields as the MPDStats plugin for interoperability + 'play_count': types.INTEGER, + 'last_played': DateType(), + 'last_liked': DateType(), + } + + # album_types = { + # 'last_liked_album': DateType(), + # 'stars_rating_album': types.INTEGER + # } + + def commands(self): + cmd = ui.Subcommand('beetstreamnext', help='run BeetstreamNext server, exposing OpenSubsonic API') + cmd.parser.add_option('-d', '--debug', action='store_true', default=False, help='Debug mode') + cmd.parser.add_option('-k', '--key', action='store_true', default=False, help='Generate a key to store passwords') + + def func(lib, opts, args): + if opts.key: + users_storage = Path(self.config['users_storage'].get()) + + if not users_storage.is_file(): + key = authentication.generate_key() + print(f'Here is your new key (store it safely): {key}') + yn_input = input('No existing users, create one? [y/n]: ') + if 'y' in yn_input.lower(): + username = input('Username: ') + password = input('Password: ') + success = authentication.update_user(users_storage, key, {username: password}) + if success: + print('User created.') + else: + yn_input = input('Users storage file exists, update key? [y/n]: ') + if 'y' in yn_input.lower(): + current_key = input('Current key: ').encode() + new_key = authentication.generate_key() + success = authentication.update_key(users_storage, current_key, new_key) + if success: + print(f'Key updated (store it safely): {new_key.decode()}') + return + + args = ui.decargs(args) + if args: + self.config['host'] = args.pop(0) + if args: + self.config['port'] = int(args.pop(0)) + + app.config['lastfm_api_key'] = self.config['lastfm_api_key'].get(None) + + app.config['fetch_artists_images'] = self.config['fetch_artists_images'].get(False) + app.config['save_artists_images'] = self.config['save_artists_images'].get(False) + + app.config['root_directory'] = Path(config['directory'].get()) + app.config['users_storage'] = Path(self.config['users_storage'].get()) + + # Total number of items in the Beets database (only used to detect deletions in getIndexes endpoint) + # We initialise to +inf at BeetstreamNext start, so the real count is set the first time a client queries + # the getIndexes endpoint + app.config['nb_items'] = float('inf') + + app.config['lib'] = lib + app.config['JSONIFY_PRETTYPRINT_REGULAR'] = False + app.config['INCLUDE_PATHS'] = self.config['include_paths'] + app.config['never_transcode'] = self.config['never_transcode'].get(False) + + possible_paths = [ + (0, self.config['playlist_dir'].get(None)), # BeetstreamNext's own + (1, config['playlist']['playlist_dir'].get(None)), # Playlist plugin + (2, config['smartplaylist']['playlist_dir'].get(None)) # Smartplaylist plugin + ] + + playlist_dirs = {} + used_paths = set() + for k, path in possible_paths: + if path not in used_paths: + playlist_dirs[k] = path + used_paths.add(path) + else: + playlist_dirs[k] = None + app.config['playlist_dirs'] = playlist_dirs + + # Enable CORS if required + if self.config['cors']: + self._log.info(f'Enabling CORS with origin: {self.config["cors"]}') + app.config['CORS_ALLOW_HEADERS'] = "Content-Type" + app.config['CORS_RESOURCES'] = { + r"/*": {"origins": self.config['cors'].get(str)} + } + CORS( + app, + supports_credentials=self.config[ + 'cors_supports_credentials' + ].get(bool) + ) + + # Allow serving behind a reverse proxy + if self.config['reverse_proxy']: + app.wsgi_app = ReverseProxied(app.wsgi_app) + + # Start the web application + app.run(host=self.config['host'].as_str(), + port=self.config['port'].get(int), + debug=opts.debug, threaded=True) + cmd.func = func + return [cmd] + + +class ReverseProxied: + """ Wrap the application in this middleware and configure the + front-end server to add these headers, to let you quietly bind + this to a URL other than / and to an HTTP scheme that is + different than what is used locally. + + In nginx: + location /myprefix { + proxy_pass http://192.168.0.1:5001; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Scheme $scheme; + proxy_set_header X-Script-Name /myprefix; + } + + From: http://flask.pocoo.org/snippets/35/ + + :param app: the WSGI application + """ + def __init__(self, app): + self.app = app + + def __call__(self, environ, start_response): + script_name = environ.get('HTTP_X_SCRIPT_NAME', '') + if script_name: + environ['SCRIPT_NAME'] = script_name + path_info = environ['PATH_INFO'] + if path_info.startswith(script_name): + environ['PATH_INFO'] = path_info[len(script_name):] + + scheme = environ.get('HTTP_X_SCHEME', '') + if scheme: + environ['wsgi.url_scheme'] = scheme + return self.app(environ, start_response) diff --git a/beetsplug/beetstreamnext/albums.py b/beetsplug/beetstreamnext/albums.py new file mode 100644 index 0000000..7813397 --- /dev/null +++ b/beetsplug/beetstreamnext/albums.py @@ -0,0 +1,123 @@ +from beetsplug.beetstreamnext.utils import * +from beetsplug.beetstreamnext import authentication +from beetsplug.beetstreamnext import app +import flask +import urllib.parse +from functools import partial + + +def album_payload(subsonic_album_id: str, with_songs=True) -> dict: + + beets_album_id = sub_to_beets_album(subsonic_album_id) + album_object = flask.g.lib.get_album(beets_album_id) + + payload = { + "album": { + **map_album(album_object, with_songs=with_songs) + } + } + return payload + +@app.route('/rest/getAlbum', methods=["GET", "POST"]) +@app.route('/rest/getAlbum.view', methods=["GET", "POST"]) +def get_album(): + r = flask.request.values + album_id = r.get('id') + payload = album_payload(album_id, with_songs=True) + return subsonic_response(payload, r.get('f', 'xml')) + +@app.route('/rest/getAlbumInfo', methods=["GET", "POST"]) +@app.route('/rest/getAlbumInfo.view', methods=["GET", "POST"]) + +@app.route('/rest/getAlbumInfo2', methods=["GET", "POST"]) +@app.route('/rest/getAlbumInfo2.view', methods=["GET", "POST"]) +def get_album_info(ver=None): + r = flask.request.values + + req_id = r.get('id') + album_id = sub_to_beets_album(req_id) + album = flask.g.lib.get_album(album_id) + + artist_quot = urllib.parse.quote(album.get('albumartist', '')) + album_quot = urllib.parse.quote(album.get('album', '')) + lastfm_url = f'https://www.last.fm/music/{artist_quot}/{album_quot}' if artist_quot and album_quot else '' + + tag = 'albumInfo2' if flask.request.path.rsplit('.', 1)[0].endswith('2') else 'albumInfo' + payload = { + tag: { + 'musicBrainzId': album.get('mb_albumid', ''), + 'lastFmUrl': lastfm_url, + 'largeImageUrl': flask.url_for('get_cover_art', id=album_id, size=1200, _external=False), + 'mediumImageUrl': flask.url_for('get_cover_art', id=album_id, size=500, _external=False), + 'smallImageUrl': flask.url_for('get_cover_art', id=album_id, size=250, _external=False) + } + } + return subsonic_response(payload, r.get('f', 'xml')) + +@app.route('/rest/getAlbumList', methods=["GET", "POST"]) +@app.route('/rest/getAlbumList.view', methods=["GET", "POST"]) + +@app.route('/rest/getAlbumList2', methods=["GET", "POST"]) +@app.route('/rest/getAlbumList2.view', methods=["GET", "POST"]) +def get_album_list(ver=None): + + r = flask.request.values + authentication.authenticate(r) + + sort_by = r.get('type', 'alphabeticalByName') + size = int(r.get('size', 10)) + offset = int(r.get('offset', 0)) + from_year = int(r.get('fromYear', 0)) + to_year = int(r.get('toYear', 3000)) + genre_filter = r.get('genre') + + # Start building the base query + query = "SELECT * FROM albums" + conditions = [] + params = [] + + # Apply filtering conditions: + if sort_by == 'byYear': + conditions.append("year BETWEEN ? AND ?") + params.extend([min(from_year, to_year), max(from_year, to_year)]) + + if sort_by == 'byGenre' and genre_filter: + conditions.append("lower(genre) LIKE ?") + params.append(f"%{genre_filter.strip().lower()}%") + + if conditions: + query += " WHERE " + " AND ".join(conditions) + + # ordering based on sort_by parameter + if sort_by == 'newest': + query += " ORDER BY added DESC" + elif sort_by == 'alphabeticalByName': + query += " ORDER BY album COLLATE NOCASE" + elif sort_by == 'alphabeticalByArtist': + query += " ORDER BY albumartist COLLATE NOCASE" + elif sort_by == 'recent': + query += " ORDER BY year DESC" + elif sort_by == 'byYear': + # Order by year, then by month and day + sort_dir = 'ASC' if from_year <= to_year else 'DESC' + query += f" ORDER BY year {sort_dir}, month {sort_dir}, day {sort_dir}" + elif sort_by == 'random': + query += " ORDER BY RANDOM()" + + # TODO - sort_by: highest, frequent + + # Add LIMIT and OFFSET for pagination + query += " LIMIT ? OFFSET ?" + params.extend([size, offset]) + + # Execute the query within a transaction + with flask.g.lib.transaction() as tx: + albums = tx.query(query, params) + + tag = 'albumList2' if flask.request.path.rsplit('.', 1)[0].endswith('2') else 'albumList' + payload = { + tag: { # albumList response does not include songs + "album": list(map(partial(map_album, with_songs=False), albums)) + } + } + return subsonic_response(payload, r.get('f', 'xml')) \ No newline at end of file diff --git a/beetsplug/beetstreamnext/artists.py b/beetsplug/beetstreamnext/artists.py new file mode 100644 index 0000000..8acd2e2 --- /dev/null +++ b/beetsplug/beetstreamnext/artists.py @@ -0,0 +1,121 @@ +from beetsplug.beetstreamnext.utils import * +from beetsplug.beetstreamnext import app +import time +import urllib.parse +from collections import defaultdict +from functools import partial +import flask + + +def artist_payload(subsonic_artist_id: str, with_albums=True) -> dict: + + artist_name = sub_to_beets_artist(subsonic_artist_id) + + payload = { + 'artist': { + **map_artist(artist_name, with_albums=with_albums) + } + } + + # When part of a directory response or a ArtistWithAlbumsID3 response + if with_albums: + albums = flask.g.lib.albums(f'albumartist:{artist_name}') + # I don't think there is any endpoint that returns an artist with albums AND songs? + payload['artist']['album'] = list(map(partial(map_album, with_songs=False), albums)) + + return payload + + +@app.route('/rest/getArtists', methods=["GET", "POST"]) +@app.route('/rest/getArtists.view', methods=["GET", "POST"]) + +@app.route('/rest/getIndexes', methods=["GET", "POST"]) +@app.route('/rest/getIndexes.view', methods=["GET", "POST"]) +def get_artists_or_indexes(): + r = flask.request.values + + modified_since = r.get('ifModifiedSince', '') + + with flask.g.lib.transaction() as tx: + artists = [row[0] for row in tx.query("SELECT DISTINCT albumartist FROM albums WHERE albumartist is NOT NULL")] + + alphanum_dict = defaultdict(list) + for artist in artists: + alphanum_dict[strip_accents(artist[0]).upper()].append(artist) + + tag = 'indexes' if flask.request.path.rsplit('.', 1)[0].endswith('Indexes') else 'artists' + payload = { + tag: { + 'ignoredArticles': '', # TODO - include config from 'the' plugin?? + 'index': [ + {'name': char, 'artist': list(map(map_artist, artists))} + for char, artists in sorted(alphanum_dict.items()) + ] + } + } + + if tag == 'indexes': + with flask.g.lib.transaction() as tx: + latest = int(tx.query('SELECT added FROM items ORDER BY added DESC LIMIT 1')[0][0]) + # TODO - 'mtime' field? + nb_items = tx.query('SELECT COUNT(*) FROM items')[0][0] + + if nb_items < app.config['nb_items']: + app.logger.warning('Media deletion detected (or very first time getIndexes is queried)') + # Deletion of items (or very first check since BeetstreamNext started) + latest = int(time.time() * 1000) + app.config['nb_items'] = nb_items + + payload[tag]['lastModified'] = latest + + return subsonic_response(payload, r.get('f', 'xml')) + +@app.route('/rest/getArtist', methods=["GET", "POST"]) +@app.route('/rest/getArtist.view', methods=["GET", "POST"]) +def get_artist(): + r = flask.request.values + + artist_id = r.get('id') + payload = artist_payload(artist_id, with_albums=True) # getArtist endpoint needs to include albums + + return subsonic_response(payload, r.get('f', 'xml')) + +@app.route('/rest/getArtistInfo', methods=["GET", "POST"]) +@app.route('/rest/getArtistInfo.view', methods=["GET", "POST"]) + +@app.route('/rest/getArtistInfo2', methods=["GET", "POST"]) +@app.route('/rest/getArtistInfo2.view', methods=["GET", "POST"]) +def artistInfo2(): + + r = flask.request.values + + artist_name = sub_to_beets_artist(r.get('id')) + first_item = flask.g.lib.items(f'albumartist:{artist_name}')[0] + artist_mbid = first_item.get('mb_albumartistid', '') + + if app.config['lastfm_api_key']: + data_lastfm = query_lastfm(artist_mbid, 'artist') + bio = data_lastfm.get('artist', {}).get('bio', {}).get('content', '') + short_bio = trim_text(bio, char_limit=300) + else: + short_bio = f'wow. much artist. very {artist_name}' + + tag = 'artistInfo2' if flask.request.path.rsplit('.', 1)[0].endswith('2') else 'artistInfo' + payload = { + tag: { + 'biography': short_bio, + 'musicBrainzId': artist_mbid, + 'lastFmUrl': f"https://www.last.fm/music/{urllib.parse.quote_plus(artist_name.replace(' ', '+'))}", + } + } + + if app.config['fetch_artists_images']: + # TODO - this is not fetching the actual images, maybe we keep it as always on? + dz_query = urllib.parse.quote_plus(artist_name.replace(' ', '-')) + dz_data = query_deezer(dz_query, 'artist') + if dz_data: + payload[tag]['smallImageUrl'] = dz_data.get('picture_medium', ''), + payload[tag]['mediumImageUrl'] = dz_data.get('picture_big', ''), + payload[tag]['largeImageUrl'] = dz_data.get('picture_xl', '') + + return subsonic_response(payload, r.get('f', 'xml')) \ No newline at end of file diff --git a/beetsplug/beetstreamnext/authentication.py b/beetsplug/beetstreamnext/authentication.py new file mode 100644 index 0000000..1748f06 --- /dev/null +++ b/beetsplug/beetstreamnext/authentication.py @@ -0,0 +1,100 @@ +from beetsplug.beetstreamnext import app +import hashlib +import os +from cryptography.fernet import Fernet +import json +from urllib.parse import unquote + + + +def generate_key(): + return Fernet.generate_key() + + +def update_key(path, old_key, new_key): + if not os.path.exists(path): + return False + + data = load_credentials(path, old_key) + if data is None: + return False + + cipher = Fernet(new_key) + reencrypted = cipher.encrypt(json.dumps(data).encode("utf-8")) + + # Write the encrypted data back to the file + with open(path, "wb") as f: + f.write(reencrypted) + + return True + + +def update_user(path, key, new_data): + cipher = Fernet(key) + + if os.path.exists(path): + data = load_credentials(path, key) + if data is None: + return False + else: + data = {} + + data.update(new_data) + new_encrypted_users_data = cipher.encrypt(json.dumps(data).encode("utf-8")) + + # Write the encrypted data back to the file + with open(path, "wb") as f: + f.write(new_encrypted_users_data) + + return True + + +def load_credentials(path, key): + if not os.path.exists(path): + return None + with open(path, 'rb') as f: + encrypted_data = f.read() + try: + cipher = Fernet(key) + decrypted_data = cipher.decrypt(encrypted_data) + except: + print('Wrong key.') + return None + return json.loads(decrypted_data.decode('utf-8')) + + +def authenticate(req_values): + user = unquote(req_values.get('u', '')) + token = unquote(req_values.get('t', '')) + salt = unquote(req_values.get('s', '')) + clearpass = unquote(req_values.get('p', '')) + + if not user: + app.logger.warning('No username provided.') + return False + + if (not token or not salt) and not clearpass: + app.logger.warning('No authentication data provided.') + return False + + key = os.environ.get('BEETSTREAMNEXT_KEY', '') + if not key: + app.logger.warning('Decryption key not found.') + return False + + users_data = load_credentials(app.config['users_storage'], key) + if not users_data: + app.logger.warning("Can't load saved users.") + return False + + if user and token and salt: + pw_digest = hashlib.md5(f"{users_data.get(user, '')}{salt}".encode('utf-8')).hexdigest().lower() + is_auth = token == pw_digest + elif clearpass: + pw = clearpass.lstrip('enc:') + is_auth = pw == users_data.get(user, '') + else: + is_auth = False + + print(f"User {user} {'is' if is_auth else 'isn not'} authenticated.") + return is_auth \ No newline at end of file diff --git a/beetsplug/beetstreamnext/coverart.py b/beetsplug/beetstreamnext/coverart.py new file mode 100644 index 0000000..411069a --- /dev/null +++ b/beetsplug/beetstreamnext/coverart.py @@ -0,0 +1,179 @@ +from beetsplug.beetstreamnext.utils import * +from beetsplug.beetstreamnext import app +import os +from typing import Union +import requests +from io import BytesIO +from PIL import Image +import flask + + +have_ffmpeg = FFMPEG_PYTHON or FFMPEG_BIN + + +def extract_cover(path) -> Union[BytesIO, None]: + + if FFMPEG_PYTHON: + img_bytes, _ = ( + ffmpeg + .input(path) + # extract only 1 frame, format image2pipe, jpeg in quality 2 (lower is better) + .output('pipe:', vframes=1, format='image2pipe', vcodec='mjpeg', **{'q:v': 2}) + .run(capture_stdout=True, capture_stderr=False, quiet=True) + ) + + elif FFMPEG_BIN: + command = [ + 'ffmpeg', + '-i', path, + # extract only 1 frame, format image2pipe, jpeg in quality 2 (lower is better) + '-vframes', '1', '-f', 'image2pipe', '-c:v', 'mjpeg', '-q:v', '2', + 'pipe:1' + ] + process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) + img_bytes, _ = process.communicate() + + else: + img_bytes = b'' + + return BytesIO(img_bytes) if img_bytes else None + + +def resize_image(data: BytesIO, size: int) -> BytesIO: + img = Image.open(data) + img.thumbnail((size, size)) + buf = BytesIO() + img.save(buf, format='JPEG') + buf.seek(0) + return buf + + +def send_album_art(album_id, size=None): + """ Generates a response with the album art for the given album ID and (optional) size + Uses the local file first, then falls back to coverartarchive.org """ + + album = flask.g.lib.get_album(album_id) + if album: + art_path = album.get('artpath', b'').decode('utf-8') + if os.path.isfile(art_path): + if size: + cover = resize_image(art_path, size) + return flask.send_file(cover, mimetype='image/jpeg') + return flask.send_file(art_path, mimetype=get_mimetype(art_path)) + + mbid = album.get('mb_albumid') + if mbid: + art_url = f'https://coverartarchive.org/release/{mbid}/front' + available_sizes = [250, 500, 1200] + if size: + # If requested size is one of coverarchive's available sizes, query it directly + if size in available_sizes: + return flask.redirect(f'{art_url}-{size}') + # Otherwise, get the smallest available size that is greater than the requested size + next_size = next((s for s in sorted(available_sizes) if s > size), None) + if next_size is None: + next_size = max(available_sizes) + response = requests.get(f'{art_url}-{next_size}') + cover = resize_image(BytesIO(response.content), size) + return flask.send_file(cover, mimetype='image/jpeg') + return flask.redirect(art_url) + return None + + +def send_artist_image(artist_id, size=None): + + # TODO - Maybe make a separate plugin to save deezer data permanently to disk / beets db? + + artist_name = sub_to_beets_artist(artist_id) + + local_folder = app.config['root_directory'] / artist_name + local_image_path = local_folder / f'{artist_name}.jpg' + + # First, check if we have the image already downloaded + if app.config['fetch_artists_images'] and app.config['save_artists_images'] and not local_image_path.is_file(): + dz_data = query_deezer(artist_name, 'artist') + if dz_data: + artist_image_url = dz_data.get('picture_xl', '') or dz_data.get('picture_big', '') + if artist_image_url: + response = requests.get(artist_image_url) + if response.ok: + img = Image.open(BytesIO(response.content)) + img.save(local_image_path) + + # If we have the image locally, serve it + if os.path.isfile(local_image_path): + if size: + cover = resize_image(local_image_path, size) + return flask.send_file(cover, mimetype='image/jpeg') + return flask.send_file(local_image_path, mimetype=get_mimetype(local_image_path)) + + if app.config['fetch_artists_images']: + # No local image, and no need to cache - Query deezer + dz_data = query_deezer(artist_name, 'artist') + if dz_data: + artist_image_url = dz_data.get('picture_small', '') + available_sizes = [56, 250, 500, 1000] + if artist_image_url: + if size: + # If requested size is one of coverarchive's available sizes, query it directly + if size in available_sizes: + return flask.redirect(artist_image_url.replace('56x56', f'{size}x{size}')) + # Otherwise, get the smallest available size that is greater than the requested size + next_size = next((s for s in sorted(available_sizes) if s >= size), None) + if next_size is None: + next_size = max(available_sizes) + response = requests.get(artist_image_url.replace('56x56', f'{next_size}x{next_size}')) + cover = resize_image(BytesIO(response.content), size) + return flask.send_file(cover, mimetype='image/jpeg') + return flask.redirect(artist_image_url) + + +@app.route('/rest/getCoverArt', methods=["GET", "POST"]) +@app.route('/rest/getCoverArt.view', methods=["GET", "POST"]) +def get_cover_art(): + r = flask.request.values + + req_id = r.get('id') + size = int(r.get('size')) if r.get('size') else None + + # album requests + if req_id.startswith(ALB_ID_PREF): + album_id = sub_to_beets_album(req_id) + response = send_album_art(album_id, size) + if response is not None: + return response + + # song requests + elif req_id.startswith(SNG_ID_PREF): + item_id = sub_to_beets_song(req_id) + item = flask.g.lib.get_item(item_id) + album_id = item.get('album_id') + if album_id: + response = send_album_art(album_id, size) + if response is not None: + return response + + # Fallback: try to extract cover from the song file + if have_ffmpeg: + cover = extract_cover(item.path) + if cover is not None: + if size: + cover = resize_image(cover, size) + return flask.send_file(cover, mimetype='image/jpeg') + + # artist requests + elif req_id.startswith(ART_ID_PREF): + response = send_artist_image(req_id, size=size) + if response is not None: + return response + + # root folder ID or name: serve BeetstreamNext's logo + elif req_id == app.config['root_directory'].name or req_id == 'm-0': + module_dir = os.path.dirname(os.path.abspath(__file__)) + beetstreamnext_icon = os.path.join(module_dir, '../../beetstreamnext.png') + return flask.send_file(beetstreamnext_icon, mimetype=get_mimetype(beetstreamnext_icon)) + + # TODO - We mighe want to serve artists images when a client requests an artist folder by name (for instance Tempo does this) + + # Fallback: return empty XML document on error + return subsonic_error(70, message='Covert art not found.', resp_fmt='xml') \ No newline at end of file diff --git a/beetsplug/beetstreamnext/db.py b/beetsplug/beetstreamnext/db.py new file mode 100644 index 0000000..2797c09 --- /dev/null +++ b/beetsplug/beetstreamnext/db.py @@ -0,0 +1,228 @@ +import sqlite3 +import os +import base64 +import hashlib +from pathlib import Path +from typing import Union +from cryptography.fernet import Fernet + +# TODO - handle these correctly in the init and in a flask.g attribute + +DB_PATH = './beetstreamnext-dev.db' + + +def load_env_file(filepath: Union[Path, str] = ".env") -> None: + env_file = Path(filepath) + + if not env_file.is_file(): + return + + for line in env_file.read_text().splitlines(): + line = line.strip() + + if not line or line.startswith("#") or '=' not in line: + continue + + var, value = line.split('=', 1) + os.environ[var.strip()] = value.strip().strip('"').strip("'") + + +def get_cipher() -> Union[Fernet, None]: + load_env_file() + key = os.environ.get('BEETSTREAMNEXT_KEY') + try: + cipher = Fernet(key) + except ValueError: + cipher = None + return cipher + + +def get_key_hash() -> Union[str, None]: + load_env_file() + key = os.environ.get('BEETSTREAMNEXT_KEY') + if not key: + return None + decoded_key = base64.urlsafe_b64decode(key) + return hashlib.sha256(decoded_key).hexdigest() + + +def verify_key(): + conn = sqlite3.connect(DB_PATH) + cur = conn.cursor() + result = cur.execute("SELECT value FROM encryption WHERE key = 'key_hash'").fetchone() + conn.close() + + stored_hash = result[0] if result else None + current_hash = get_key_hash() + + return current_hash == stored_hash + + +def initialise_db(): + conn = sqlite3.connect(DB_PATH) + cur = conn.cursor() + + cur.execute("PRAGMA foreign_keys = ON;") + + cur.execute(""" + CREATE TABLE IF NOT EXISTS encryption ( + key TEXT PRIMARY KEY, + value TEXT) + """) + + cipher = get_cipher() + + if cipher is not None: + key_hash = get_key_hash() + + cur.execute(f""" + INSERT INTO encryption (key, value) VALUES ('enabled', 'true'); + """) + + cur.execute(""" + INSERT OR REPLACE INTO encryption (key, value) VALUES (?, ?) + """, ('key_hash', key_hash)) + else: + cur.execute(f""" + INSERT INTO encryption (key, value) VALUES ('enabled', 'false'); + """) + + cur.execute(""" + INSERT OR REPLACE INTO encryption (key, value) VALUES (?, ?) + """, ('key_hash', None)) + + cur.execute(""" + CREATE TABLE IF NOT EXISTS users ( + username TEXT PRIMARY KEY, + password BLOB NOT NULL, + email TEXT, + avatar BLOB, + avatarLastChanged REAL, + scrobblingEnabled INTEGER DEFAULT 0, + adminRole INTEGER DEFAULT 0, + settingsRole INTEGER DEFAULT 1, + streamRole INTEGER DEFAULT 0, + jukeboxRole INTEGER DEFAULT 0, + downloadRole INTEGER DEFAULT 0, + uploadRole INTEGER DEFAULT 0, + coverArtRole INTEGER DEFAULT 0, + playlistRole INTEGER DEFAULT 1, + commentRole INTEGER DEFAULT 1, + podcastRole INTEGER DEFAULT 0, + shareRole INTEGER DEFAULT 0, + videoConversionRole INTEGER DEFAULT 0, + folder INTEGER DEFAULT 0, + maxBitRate INTEGER DEFAULT 0 -- 0 (no limit), 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320 + ) + """) + + cur.execute(""" + CREATE TABLE IF NOT EXISTS likes ( + username TEXT NOT NULL, + item_type TEXT NOT NULL, + item_id INTEGER NOT NULL, + PRIMARY KEY (username, item_type, item_id), + FOREIGN KEY (username) REFERENCES users (username) + ) + """) + + cur.execute(""" + CREATE TABLE IF NOT EXISTS bookmarks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL, + song_id INTEGER NOT NULL, + position REAL NOT NULL, + comments TEXT, + FOREIGN KEY (username) REFERENCES users (username) + ) + """) + + conn.commit() + conn.close() + + +def store_userdata(user_dict): + + username = user_dict.pop("username", None) + if not username: + raise ValueError('User dict must have the "username" key!') + + columns = ['username'] + placeholders = ['?'] + updates = [] + values = [username] + + cipher = get_cipher() + + for key, val in user_dict.items(): + if cipher: + val = cipher.encrypt(val.encode("utf-8")) + columns.append(key) + placeholders.append('?') + updates.append(f"{key} = excluded.{key}") + values.append(1 if val else 0) + + columns_str = ', '.join(columns) + placeholders_str = ', '.join(['?'] * len(columns)) + updates_str = ', '.join(updates) + + sql = f""" + INSERT INTO users ({columns_str}) + VALUES ({placeholders_str}) + ON CONFLICT (username) + DO UPDATE SET + {updates_str} + """ + + conn = sqlite3.connect(DB_PATH) + conn.execute(sql, values) + conn.commit() + conn.close() + + +def load_userdata(username: str, fields: Union[list[str], tuple[str], set[str], str, None] = None) -> Union[dict, None]: + + if fields is None: + return None + + elif isinstance(fields, str): + fields = {fields} + else: + fields = set(fields) + + # We don't really want SQL injection :) + safe_fields = list(set(fields).intersection( + {'password', 'email', 'avatar', 'avatarLastChanged', 'scrobblingEnabled', 'adminRole', 'settingsRole', + 'streamRole', 'jukeboxRole', 'downloadRole', 'uploadRole', 'coverArtRole', 'playlistRole', 'commentRole', + 'podcastRole', 'shareRole', 'videoConversionRole', 'folder', 'maxBitRate'} + )) + + if not safe_fields: + return None + + columns_str = "username, " + ", ".join(safe_fields) + + conn = sqlite3.connect(DB_PATH) + row = conn.execute(f""" + SELECT {columns_str} + FROM users + WHERE username = ? + """, (username,)).fetchone() + conn.close() + + if not row: + return None + + user_dict = {k: v for k, v in zip(columns_str, row)} + + cipher = get_cipher() + + if 'password' in user_dict.keys(): + password = user_dict.pop('password') + + if cipher: + user_dict['password'] = cipher.decrypt(password).decode("utf-8") + else: + user_dict['password'] = password + + return user_dict \ No newline at end of file diff --git a/beetsplug/beetstreamnext/dummy.py b/beetsplug/beetstreamnext/dummy.py new file mode 100644 index 0000000..fb78301 --- /dev/null +++ b/beetsplug/beetstreamnext/dummy.py @@ -0,0 +1,18 @@ +from beetsplug.beetstreamnext.utils import * +from beetsplug.beetstreamnext import app +from beetsplug.beetstreamnext import authentication +import flask + + +# Fake endpoint to avoid some apps errors +@app.route('/rest/scrobble', methods=["GET", "POST"]) +@app.route('/rest/scrobble.view', methods=["GET", "POST"]) + + +@app.route('/rest/ping', methods=["GET", "POST"]) +@app.route('/rest/ping.view', methods=["GET", "POST"]) +def ping(): + r = flask.request.values + # authentication.authenticate(r) + + return subsonic_response({}, r.get('f', 'xml')) diff --git a/beetsplug/beetstreamnext/general.py b/beetsplug/beetstreamnext/general.py new file mode 100644 index 0000000..00a4abd --- /dev/null +++ b/beetsplug/beetstreamnext/general.py @@ -0,0 +1,131 @@ +from beetsplug.beetstreamnext.utils import * +from beetsplug.beetstreamnext import app +from beetsplug.beetstreamnext.artists import artist_payload +from beetsplug.beetstreamnext.albums import album_payload +from beetsplug.beetstreamnext.songs import song_payload +import flask + + +def musicdirectory_payload(subsonic_musicdirectory_id: str, with_artists=True) -> dict: + + # Only one possible root directory in beets (?), so just return its name + directory_name = app.config['root_directory'].name + + payload = { + 'musicFolders': { + 'musicFolder': [{ + 'id': subsonic_musicdirectory_id, + 'name': directory_name + }] + } + } + return payload + + +@app.route('/rest/getOpenSubsonicExtensions', methods=["GET", "POST"]) +@app.route('/rest/getOpenSubsonicExtensions.view', methods=["GET", "POST"]) +def get_open_subsonic_extensions(): + r = flask.request.values + + payload = { + 'openSubsonicExtensions': [ + { + 'name': 'transcodeOffset', + 'versions': [1] + }, + ] + } + return subsonic_response(payload, r.get('f', 'xml')) + + +@app.route('/rest/getGenres', methods=["GET", "POST"]) +@app.route('/rest/getGenres.view', methods=["GET", "POST"]) +def get_genres(): + r = flask.request.values + + with flask.g.lib.transaction() as tx: + mixed_genres = list(tx.query( + """ + SELECT genre, COUNT(*) AS n_song, '' AS n_album FROM items GROUP BY genre + UNION ALL + SELECT genre, '' AS n_song, COUNT(*) AS n_album FROM albums GROUP BY genre + """)) + + g_dict = {} + for row in mixed_genres: + genre_field, n_song, n_album = row + for key in genres_formatter(genre_field): + if key not in g_dict: + g_dict[key] = [0, 0] + if n_song: # Update song count if present + g_dict[key][0] += int(n_song) + if n_album: # Update album count if present + g_dict[key][1] += int(n_album) + + # And convert to list of tuples (only non-empty genres) + g_list = [(k, *v) for k, v in g_dict.items() if k] + g_list.sort(key=lambda g: g[1], reverse=True) + + payload = { + "genres": { + "genre": [dict(zip(["value", "songCount", "albumCount"], g)) for g in g_list] + } + } + return subsonic_response(payload, r.get('f', 'xml')) + + +@app.route('/rest/getLicense', methods=["GET", "POST"]) +@app.route('/rest/getLicense.view', methods=["GET", "POST"]) +def get_license(): + r = flask.request.values + + payload = { + 'license': { + "valid": True + } + } + return subsonic_response(payload, r.get('f', 'xml')) + +@app.route('/rest/getMusicFolders', methods=["GET", "POST"]) +@app.route('/rest/getMusicFolders.view', methods=["GET", "POST"]) +def get_music_folders(): + r = flask.request.values + + payload = musicdirectory_payload(subsonic_musicdirectory_id='m-0', with_artists=False) + + return subsonic_response(payload, r.get('f', 'xml')) + + +@app.route('/rest/getMusicDirectory', methods=["GET", "POST"]) +@app.route('/rest/getMusicDirectory.view', methods=["GET", "POST"]) +def get_music_directory(): + # Works pretty much like a file system + # Usually Artist first, then Album, then Songs + r = flask.request.values + + req_id = r.get('id') + + if req_id.startswith(ART_ID_PREF): + payload = artist_payload(req_id, with_albums=True) # make sure to include albums + payload['directory'] = payload.pop('artist') + payload['directory']['child'] = payload['directory'].pop('album') + + elif req_id.startswith(ALB_ID_PREF): + payload = album_payload(req_id, with_songs=True) # make sure to include songs + payload['directory'] = payload.pop('album') + payload['directory']['child'] = payload['directory'].pop('song') + + elif req_id.startswith(SNG_ID_PREF): + payload = song_payload(req_id) + payload['directory'] = payload.pop('song') + + else: + payload = musicdirectory_payload('m-0', with_artists=True) + + # TODO - Add missing fields to artist mapper so we can return a directory with artist children + # payload['directory'] = payload.pop('artist') + # payload['directory']['child'] = payload['directory'].pop('album') + + return subsonic_response(payload, r.get('f', 'xml')) + + diff --git a/beetsplug/beetstreamnext/playlistprovider.py b/beetsplug/beetstreamnext/playlistprovider.py new file mode 100644 index 0000000..fbfbc6d --- /dev/null +++ b/beetsplug/beetstreamnext/playlistprovider.py @@ -0,0 +1,226 @@ +from beetsplug.beetstreamnext.utils import PLY_ID_PREF, genres_formatter, creation_date, map_song +from beetsplug.beetstreamnext import app +import flask +from typing import Union, List +from pathlib import Path +import os + + +class Playlist: + def __init__(self, dir_id, path): + self.id = f'{PLY_ID_PREF}{dir_id}-{path.name}' + self.name = path.stem + self.ctime = creation_date(path) + self.mtime = path.stat().st_mtime + self.path = path + self.songs = [] + self.duration = 0 + for entry in self.from_m3u(path): + entry_path = (path.parent / Path(entry['uri'])).resolve() + entry_id = entry.get('props', {}).get('id', None) + + if entry_id: + song = [flask.g.lib.get_item(entry_id)] + else: + with flask.g.lib.transaction() as tx: + song = tx.query("SELECT * FROM items WHERE (path) LIKE (?) LIMIT 1", (entry_path.as_posix(),)) + + if song: + self.songs.append(map_song(song[0])) + self.duration += int(song[0]['length'] or 0) + + @classmethod + def from_songs(cls, name, songs): + instance = cls.__new__(cls) + + instance.name = name.rsplit(".", 1)[0] + instance.id = f'{PLY_ID_PREF}0-{instance.name}.m3u' + instance.path = Path(flask.g.playlist_provider.playlist_dirs[0]) / f'{instance.name}.m3u' + if instance.path.is_file(): + err = f"Playlist {instance.name}.m3u already exists in BeetstreamNext's folder!" + app.logger.warning(err) + raise FileExistsError(err) + instance.songs = [dict(song) for song in songs] + instance.duration = sum([int(song['length'] or 0) for song in songs]) + + # Save the new playlist + instance.to_m3u() + + # And read it back from the file so we can use the real __init__ + return cls(0, instance.path) + + @classmethod + def from_m3u(cls, filepath): + """ Parses a playlist (m3u, m3u8 or m3a) and yields its entries """ + + with open(filepath, 'r', encoding='UTF-8') as f: + curr_entry = {} + + for line in f: + line = line.strip() + + if not line: + continue + + if line.startswith('#EXTM3U'): + continue + + if line.startswith('#EXTINF:'): + left_part, info = line[8:].split(",", 1) + duration_and_props = left_part.split() + curr_entry['info'] = info.strip() + curr_entry['runtime'] = int(duration_and_props[0].strip()) + curr_entry['props'] = {k.strip(): v.strip('"').strip() + for k, v in (p.split('=', 1) for p in duration_and_props[1:])} + continue + + # Add content from any additional m3u directives + elif line.startswith('#PLAYLIST:'): + curr_entry['name'] = line[10:].strip() + continue + + elif line.startswith('#EXTGRP:'): + curr_entry['group'] = line[8:].strip() + continue + + elif line.startswith('#EXTALB:'): + curr_entry['album'] = line[8:].strip() + continue + + elif line.startswith('#EXTART:'): + curr_entry['artist'] = line[8:].strip() + continue + + elif line.startswith('#EXTGENRE:'): + curr_entry['genres'] = genres_formatter(line[10:]) + continue + + elif line.startswith('#EXTM3A'): + curr_entry['m3a'] = True + continue + + elif line.startswith('#EXTBYT:'): + curr_entry['size'] = int(line[8:].strip()) + continue + + elif line.startswith('#EXTBIN:'): + # Skip the binary mp3 content + continue + + elif line.startswith('#EXTALBUMARTURL:'): + curr_entry['artpath'] = line[16:].strip() + continue + + elif line.startswith('#EXT-X-'): + # We ignore HLS M3U fields + continue + + curr_entry['uri'] = line + yield curr_entry + curr_entry = {} + + def to_m3u(self): + + content = ['#EXTM3U'] + + for song in self.songs: + path = song.get('path').decode('utf-8') + if not path: + continue + info = f"#EXTINF:{round(song.get('length', 0))} id={song.get('id')}" + artist = song.get('artist') + title = song.get('title') + album = song.get('album') + year = song.get('year') + if artist and title: + info += f',{artist} - {title}' + elif artist: + info += f',{artist}' + elif title: + info += f',{title}' + content.append(info) + if album: + albuminfo = f'#EXTALB:{album}' + albuminfo += f' ({year})' if year else '' + content.append(albuminfo) + content.append(Path(path).relative_to(app.config['root_directory']).as_posix()) + + with open(self.path.with_suffix('.m3u'), 'w', encoding='UTF-8') as f: + f.write('\n'.join(content)) + + +class PlaylistProvider: + def __init__(self): + + self.playlist_dirs = app.config.get('playlist_dirs', {}) + self._playlists = {} + + if not self.playlist_dirs or all(v is None for v in self.playlist_dirs.values()): + app.logger.warning('No playlist directories could be found.') + else: + for dir_id, dir_path in self.playlist_dirs.items(): + if dir_path is not None: + dir_path = Path(dir_path) + for path in dir_path.glob('*.m3u*'): + try: + self._load_playlist(dir_id, path) + except Exception as e: + app.logger.error(f"Failed to load playlist {path.name}: {e}") + + app.logger.debug(f"Loaded {len(self._playlists)} playlists.") + + def _load_playlist(self, dir_id, filepath): + """ Load playlist data from a file, or from cache if it exists """ + + file_mtime = filepath.stat().st_mtime + playlist_id = f"{PLY_ID_PREF}{dir_id}-{filepath.name.lower()}" + + # Get potential cached version + playlist = self._playlists.get(playlist_id) + + # If the playlist is not found in cache, or if the cached version is outdated + if not playlist or playlist.mtime < file_mtime: + # Load new data from file + playlist = Playlist(dir_id, filepath) + # And cache it + self.register(playlist) + + return playlist + + def get(self, playlist_id: str) -> Union[Playlist, None]: + """ Get a playlist by its id """ + + if not playlist_id.startswith(PLY_ID_PREF): + return None + + dir_key, file_name = playlist_id.lstrip(PLY_ID_PREF).split('-', 1) + dir_id = int(dir_key) + + dir_path = self.playlist_dirs.get(dir_id) + if not dir_path: + return None + + filepath = Path(dir_path) / file_name + + if filepath.is_file(): + return self._load_playlist(dir_id, filepath) + else: + return None + + def getall(self) -> List[Playlist]: + """ Get all playlists """ + return list(self._playlists.values()) + + def register(self, playlist: Playlist) -> None: + self._playlists[playlist.id] = playlist + + def delete(self, playlist_id: str) -> None: + playlist = self._playlists.get(playlist_id) + if playlist: + path = Path(playlist.path) + try: + os.remove(path) + except FileNotFoundError: + err = f"Playlist {path.name} does not exist in {path.parent}." + app.logger.warning(err) + raise FileExistsError(err) \ No newline at end of file diff --git a/beetsplug/beetstreamnext/playlists.py b/beetsplug/beetstreamnext/playlists.py new file mode 100644 index 0000000..4cb686b --- /dev/null +++ b/beetsplug/beetstreamnext/playlists.py @@ -0,0 +1,104 @@ +from beetsplug.beetstreamnext.utils import * +import os +import flask +from beetsplug.beetstreamnext import app +from .playlistprovider import PlaylistProvider, Playlist + + +@app.route('/rest/getPlaylists', methods=['GET', 'POST']) +@app.route('/rest/getPlaylists.view', methods=['GET', 'POST']) +def get_playlists(): + + r = flask.request.values + + # Lazily initialize the playlist provider the first time it's needed + if not hasattr(flask.g, 'playlist_provider'): + flask.g.playlist_provider = PlaylistProvider() + + playlists = flask.g.playlist_provider.getall() + + payload = { + 'playlists': { + 'playlist': [map_playlist(p) for p in playlists] + } + } + return subsonic_response(payload, r.get('f', 'xml')) + + +@app.route('/rest/getPlaylist', methods=['GET', 'POST']) +@app.route('/rest/getPlaylist.view', methods=['GET', 'POST']) +def get_playlist(): + r = flask.request.values + + playlist_id = r.get('id') + if playlist_id: + + # Lazily initialize the playlist provider the first time it's needed + if not hasattr(flask.g, 'playlist_provider'): + flask.g.playlist_provider = PlaylistProvider() + + playlist = flask.g.playlist_provider.get(playlist_id) + + if playlist is not None: + payload = { + 'playlist': map_playlist(playlist) + } + return subsonic_response(payload, r.get('f', 'xml')) + + return subsonic_error(70, r.get('f', 'xml')) + + +@app.route('/rest/createPlaylist', methods=['GET', 'POST']) +@app.route('/rest/createPlaylist.view', methods=['GET', 'POST']) +def create_playlist(): + + r = flask.request.values + + playlist_id = r.get('playlistId') + name = r.get('name') + songs_ids = r.getlist('songId') + + # Lazily initialize the playlist provider the first time it's needed + if not hasattr(flask.g, 'playlist_provider'): + flask.g.playlist_provider = PlaylistProvider() + + if playlist_id: + # Update mode: API documentation is unclear so we just return an error; probably better to use updatePlaylist + return subsonic_error(0, r.get('f', 'xml')) + elif name and songs_ids: + # Create mode + songs = list(flask.g.lib.items('id:' + ' , id:'.join(songs_ids))) + try: + playlist = Playlist.from_songs(name, songs) + except FileExistsError as e: + return subsonic_error(10, message=str(e), resp_fmt=r.get('f', 'xml')) + + flask.g.playlist_provider.register(playlist) + + payload = { + 'playlist': map_playlist(playlist) + } + return subsonic_response(payload, r.get('f', 'xml')) + + return subsonic_error(10, r.get('f', 'xml')) + + +@app.route('/rest/deletePlaylist', methods=['GET', 'POST']) +@app.route('/rest/deletePlaylist.view', methods=['GET', 'POST']) +def delete_playlist(): + + r = flask.request.values + + playlist_id = r.get('id') + + if playlist_id: + # Lazily initialize the playlist provider the first time it's needed + if not hasattr(flask.g, 'playlist_provider'): + flask.g.playlist_provider = PlaylistProvider() + try: + flask.g.playlist_provider.delete(playlist_id) + return subsonic_response({}, r.get('f', 'xml')) + except FileExistsError as e: + subsonic_error(70, message=str(e), resp_fmt=r.get('f', 'xml')) + + subsonic_error(70, resp_fmt=r.get('f', 'xml')) diff --git a/beetsplug/beetstreamnext/search.py b/beetsplug/beetstreamnext/search.py new file mode 100644 index 0000000..a5052a3 --- /dev/null +++ b/beetsplug/beetstreamnext/search.py @@ -0,0 +1,69 @@ +from beetsplug.beetstreamnext.utils import * +from beetsplug.beetstreamnext import app +from functools import partial + + +@app.route('/rest/search2', methods=["GET", "POST"]) +@app.route('/rest/search2.view', methods=["GET", "POST"]) + +@app.route('/rest/search3', methods=["GET", "POST"]) +@app.route('/rest/search3.view', methods=["GET", "POST"]) + +def search(ver=None): + r = flask.request.values + + song_count = int(r.get('songCount', 20)) + song_offset = int(r.get('songOffset', 0)) + album_count = int(r.get('albumCount', 20)) + album_offset = int(r.get('albumOffset', 0)) + artist_count = int(r.get('artistCount', 20)) + artist_offset = int(r.get('artistOffset', 0)) + + query = r.get('query', '') + # Remove surrounding quotes if present + if query.startswith('"') and query.endswith('"'): + query = query[1:-1] + + if not query: + if ver == 2: + # search2 does not support empty queries: return an empty response + return subsonic_error(10, r.get('f', 'xml')) + + # search3 "must support an empty query and return all the data" + # https://opensubsonic.netlify.app/docs/endpoints/search3/ + pattern = "%" + else: + pattern = f"%{query.lower()}%" + + with flask.g.lib.transaction() as tx: + songs = list(tx.query( + "SELECT * FROM items WHERE lower(title) LIKE ? ORDER BY title LIMIT ? OFFSET ?", + (pattern, song_count, song_offset) + )) + albums = list(tx.query( + "SELECT * FROM albums WHERE lower(album) LIKE ? ORDER BY album LIMIT ? OFFSET ?", + (pattern, album_count, album_offset) + )) + artists = [row[0] for row in tx.query( + """SELECT DISTINCT albumartist FROM albums WHERE lower(albumartist) LIKE ? + and albumartist is NOT NULL LIMIT ? OFFSET ?""", + (pattern, artist_count, artist_offset) + )] + + # TODO - do the sort in the SQL query instead? + artists.sort(key=lambda name: strip_accents(name).upper()) + + if flask.request.path.rsplit('.', 1)[0][6:] == 'search2': + tag = 'searchResult2' + elif flask.request.path.rsplit('.', 1)[0][6:] == 'search3': + tag = 'searchResult3' + else: + tag = 'searchResult' + payload = { + tag: { + 'artist': list(map(partial(map_artist, with_albums=False), artists)), # no need to include albums twice + 'album': list(map(partial(map_album, with_songs=False), albums)), # no need to include songs twice + 'song': list(map(map_song, songs)) + } + } + return subsonic_response(payload, r.get('f', 'xml')) diff --git a/beetsplug/beetstreamnext/songs.py b/beetsplug/beetstreamnext/songs.py new file mode 100644 index 0000000..ca78d7f --- /dev/null +++ b/beetsplug/beetstreamnext/songs.py @@ -0,0 +1,276 @@ +from beetsplug.beetstreamnext.utils import * +from beetsplug.beetstreamnext import app, stream +import flask +import re + + +artists_separators = re.compile(r', | & ') + + +def song_payload(subsonic_song_id: str) -> dict: + beets_song_id = sub_to_beets_song(subsonic_song_id) + song_item = flask.g.lib.get_item(beets_song_id) + + payload = { + 'song': map_song(song_item) + } + return payload + + +@app.route('/rest/getSong', methods=["GET", "POST"]) +@app.route('/rest/getSong.view', methods=["GET", "POST"]) +def get_song(): + r = flask.request.values + song_id = r.get('id') + + payload = song_payload(song_id) + return subsonic_response(payload, r.get('f', 'xml')) + +@app.route('/rest/getSongsByGenre', methods=["GET", "POST"]) +@app.route('/rest/getSongsByGenre.view', methods=["GET", "POST"]) +def songs_by_genre(): + r = flask.request.values + + genre = r.get('genre').replace("'", "\\'") + count = int(r.get('count') or 10) + offset = int(r.get('offset') or 0) + + genre_pattern = f"%{genre}%" + with flask.g.lib.transaction() as tx: + songs = list(tx.query( + "SELECT * FROM items WHERE lower(genre) LIKE lower(?) ORDER BY title LIMIT ? OFFSET ?", + (genre_pattern, count, offset) + )) + + payload = { + "songsByGenre": { + "song": list(map(map_song, songs)) + } + } + return subsonic_response(payload, r.get('f', 'xml')) + + +@app.route('/rest/getRandomSongs', methods=["GET", "POST"]) +@app.route('/rest/getRandomSongs.view', methods=["GET", "POST"]) +def get_random_songs(): + r = flask.request.values + + size = int(r.get('size') or 10) + + with flask.g.lib.transaction() as tx: + # Advance the SQL random generator state + _ = list(tx.query("SELECT RANDOM()")) + # Now fetch the random songs + songs = list(tx.query( + "SELECT * FROM items ORDER BY RANDOM() LIMIT ?", + (size,) + )) + + payload = { + "randomSongs": { + "song": list(map(map_song, songs)) + } + } + return subsonic_response(payload, r.get('f', 'xml')) + + +@app.route('/rest/stream', methods=["GET", "POST"]) +@app.route('/rest/stream.view', methods=["GET", "POST"]) +def stream_song(): + r = flask.request.values + + max_bitrate = int(r.get('maxBitRate', 0)) + req_format = r.get('format') + time_offset = float(r.get('timeOffset', 0.0)) + estimate_content_length = r.get('estimateContentLength', 'false').lower() == 'true' + + song_id = sub_to_beets_song(r.get('id')) + song = flask.g.lib.get_item(song_id) + song_path = song.get('path', b'').decode('utf-8') if song else '' + + if song_path: + if app.config['never_transcode'] or req_format == 'raw' or max_bitrate <= 0 or song.bitrate <= max_bitrate * 1000: + response = stream.direct(song_path) + est_size = os.path.getsize(song_path) or round(song.get('bitrate', 0) * song.get('length', 0) / 8) + else: + response = stream.try_transcode(song_path, start_at=time_offset, max_bitrate=max_bitrate) + est_size = int(((max_bitrate * 1000) / 8) * song.get('length', 0)) + + if response is not None: + if estimate_content_length and est_size: + response.headers['Content-Length'] = est_size + return response + + subsonic_error(70, message="Song not found.", resp_fmt=r.get('f', 'xml')) + +@app.route('/rest/download', methods=["GET", "POST"]) +@app.route('/rest/download.view', methods=["GET", "POST"]) +def download_song(): + r = flask.request.values + + song_id = sub_to_beets_song(r.get('id')) + item = flask.g.lib.get_item(song_id) + + return stream.direct(item.path.decode('utf-8')) + + +@app.route('/rest/getTopSongs', methods=["GET", "POST"]) +@app.route('/rest/getTopSongs.view', methods=["GET", "POST"]) +def get_top_songs(): + + r = flask.request.values + + req_id = r.get('id', '') + + payload = {'topSongs': {'song': []}} + + if req_id.startswith(ART_ID_PREF): + artist_name = sub_to_beets_artist(req_id) + # grab the artist's mbid + with flask.g.lib.transaction() as tx: + mbid_artist = tx.query(f""" SELECT mb_artistid FROM items WHERE albumartist LIKE '{artist_name}' LIMIT 1 """) + + if app.config['lastfm_api_key']: + # Query last.fm for top tracks for this artist and parse the response + if mbid_artist: + lastfm_resp = query_lastfm(query=mbid_artist[0][0], type='artist', method='TopTracks', mbid=True) + else: + lastfm_resp = query_lastfm(query=artist_name, type='artist', method='TopTracks', mbid=False) + + if lastfm_resp: + beets_results = [flask.g.lib.items(f"""title:{t.get('name', '').replace("'", "")}""") + for t in lastfm_resp.get('toptracks', {}).get('track', [])] + top_tracks_available = [track[0] for track in beets_results if track] + + payload = { + 'topSongs': { + 'song': list(map(map_song, top_tracks_available)) + } + } + else: + # TODO - Use the local play_count in this case + pass + + return subsonic_response(payload, r.get('f', 'xml')) + + +@app.route('/rest/getStarred', methods=["GET", "POST"]) +@app.route('/rest/getStarred.view', methods=["GET", "POST"]) + +@app.route('/rest/getStarred2', methods=["GET", "POST"]) +@app.route('/rest/getStarred2.view', methods=["GET", "POST"]) +def get_starred_songs(ver=None): + # TODO + + r = flask.request.values + + tag = 'starred2' if flask.request.path.rsplit('.', 1)[0].endswith('2') else 'starred' + payload = { + tag: { + 'song': [] + } + } + return subsonic_response(payload, r.get('f', 'xml')) + + +@app.route('/rest/getSimilarSongs', methods=["GET", "POST"]) +@app.route('/rest/getSimilarSongs.view', methods=["GET", "POST"]) + +@app.route('/rest/getSimilarSongs2', methods=["GET", "POST"]) +@app.route('/rest/getSimilarSongs2.view', methods=["GET", "POST"]) +def get_similar_songs(): + + r = flask.request.values + + req_id = r.get('id') + limit = r.get('count', 50) + + if req_id.startswith(ART_ID_PREF): + artist_name = sub_to_beets_artist(req_id) + # grab the artist's mbid + with flask.g.lib.transaction() as tx: + mbid_artist = tx.query(f""" SELECT mb_artistid FROM items WHERE albumartist LIKE '{artist_name}' LIMIT 1 """) + elif req_id.startswith(SNG_ID_PREF): + # TODO - Maybe query the track.getSimilar endpoint on lastfm instead of using the artist? + beets_song_id = sub_to_beets_song(req_id) + song_item = flask.g.lib.get_item(beets_song_id) + if not song_item: + flask.abort(404) + artist_name = song_item.get('albumartist', '') + mbid_artist = [[song_item.get('mb_artistid', '')]] + elif req_id.startswith(ALB_ID_PREF): + beets_album_id = sub_to_beets_album(req_id) + album_object = flask.g.lib.get_album(beets_album_id) + if not album_object: + flask.abort(404) + artist_name = album_object.get('albumartist', '') + mbid_artist = [[album_object.get('mb_artistid', '')]] + else: + flask.abort(404) # just for now + + similar_artists = {} + + # If we can ask lastfm + if app.config['lastfm_api_key']: + # Query last.fm for similar artists and parse the response + if mbid_artist: + lastfm_resp = query_lastfm(query=mbid_artist[0][0], type='artist', method='similar', mbid=True) + else: + lastfm_resp = query_lastfm(query=artist_name, type='artist', method='similar', mbid=False) + + if lastfm_resp: + + similar_artists = { + artist.get('name'): artist.get('mbid', '') + for artist in lastfm_resp.get('similarartists', {}).get('artist', []) + } + + # Add the requested artist (will be the only fallback if no lastfm key available) + similar_artists[artist_name] = mbid_artist[0][0] if mbid_artist else '' + + # Build up a humongous SQL query to get everything with related artists + mbid_fields = ['mb_artistid', 'mb_artistids'] + name_fields = ['artist', 'artists', 'composer', 'lyricist'] + conditions = [] + params = [] + for name, mbid in similar_artists.items(): + if mbid: + # When we have an mbid, match against all relevant mbid fields + sub_conditions = [] + # Check each mbid field for an exact match + for field in mbid_fields: + sub_conditions.append(f"{field} = ?") + params.append(mbid) + # Also check each name field with a LIKE condition + for field in name_fields: + sub_conditions.append(f"{field} LIKE ?") + params.append(f"%{name}%") + conditions.append("(" + " OR ".join(sub_conditions) + ")") + else: + # no mbid: typically with lastfm responses that's bc the entry is several artists in a collab + parts = re.split(artists_separators, name) + sub_conditions_outer = [] + for part in parts: + sub_conditions_inner = [] + for field in name_fields: + sub_conditions_inner.append(f"{field} LIKE ?") + params.append(f"%{part}%") + sub_conditions_outer.append("(" + " OR ".join(sub_conditions_inner) + ")") + conditions.append("(" + " OR ".join(sub_conditions_outer) + ")") + + # we also let SQL remove duplicate rows using DISTINCT, and apply the limit there directly + query = "SELECT DISTINCT * FROM items WHERE " + " OR ".join(conditions) + " LIMIT ?" + params.append(limit) + + # Run the single big SQL query + with flask.g.lib.transaction() as tx: + beets_results = list(tx.query(query, params)) + + # and finally reply to the client + tag = 'similarSongs2' if flask.request.path.rsplit('.', 1)[0].endswith('2') else 'similarSongs' + payload = { + tag: { + 'song': list(map(map_song, beets_results)) + } + } + return subsonic_response(payload, r.get('f', 'xml')) diff --git a/beetsplug/beetstreamnext/stream.py b/beetsplug/beetstreamnext/stream.py new file mode 100644 index 0000000..40a1929 --- /dev/null +++ b/beetsplug/beetstreamnext/stream.py @@ -0,0 +1,44 @@ +from beetsplug.beetstreamnext.utils import * +import subprocess +import flask + +have_ffmpeg = FFMPEG_PYTHON or FFMPEG_BIN + + +def direct(file_path): + if os.path.isfile(file_path): + return flask.send_file(file_path, mimetype=get_mimetype(file_path)) + else: + return None + +def transcode(file_path, start_at: float = 0.0, max_bitrate: int = 128): + if FFMPEG_PYTHON: + input_stream = ffmpeg.input(file_path, ss=start_at) if start_at else ffmpeg.input(file_path) + + output_stream = ( + input_stream + .audio + .output('pipe:', format="mp3", audio_bitrate=max_bitrate * 1000) + .run_async(pipe_stdout=True, quiet=True) + ) + elif FFMPEG_BIN: + command = [ + "ffmpeg", + f"-ss {start_at:.2f}" if start_at else "", + "-i", file_path, + "-f", "mp3", + "-b:a", f"{max_bitrate}k", + "pipe:1" + ] + output_stream = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) + else: + return None + + return flask.Response(output_stream.stdout, mimetype='audio/mpeg') + + +def try_transcode(file_path, start_at: float = 0.0, max_bitrate: int = 128): + if have_ffmpeg: + return transcode(file_path, start_at, max_bitrate) + else: + return direct(file_path) \ No newline at end of file diff --git a/beetsplug/beetstreamnext/users.py b/beetsplug/beetstreamnext/users.py new file mode 100644 index 0000000..81679eb --- /dev/null +++ b/beetsplug/beetstreamnext/users.py @@ -0,0 +1,35 @@ +from beetsplug.beetstreamnext.utils import * +from beetsplug.beetstreamnext import app +import flask + + +@app.route('/rest/getUser', methods=["GET", "POST"]) +@app.route('/rest/getUser.view', methods=["GET", "POST"]) +def get_user(): + r = flask.request.values + + # TODO - Proper user management + + payload = { + 'user': { + "username" : "admin", + "email" : "foo@example.com", + "scrobblingEnabled" : True, + "adminRole" : True, + "settingsRole" : True, + "downloadRole" : True, + "uploadRole" : True, + "playlistRole" : True, + "coverArtRole" : True, + "commentRole" : True, + "podcastRole" : True, + "streamRole" : True, + "jukeboxRole" : True, + "shareRole" : True, + "videoConversionRole" : True, + "avatarLastChanged" : "1970-01-01T00:00:00.000Z", + "folder" : [ 0 ] + } + } + return subsonic_response(payload, r.get('f', 'xml')) + diff --git a/beetsplug/beetstreamnext/utils.py b/beetsplug/beetstreamnext/utils.py new file mode 100644 index 0000000..32f6f93 --- /dev/null +++ b/beetsplug/beetstreamnext/utils.py @@ -0,0 +1,591 @@ +import unicodedata +from datetime import datetime +import platform +from pathlib import Path +from typing import Union +import flask +import json +import base64 +import mimetypes +import os +import re +from beets import library +import xml.etree.cElementTree as ET +from xml.dom import minidom +import shutil +import importlib +from functools import partial +import requests +import urllib.parse +from beetsplug.beetstreamnext import app + + + +API_VERSION = '1.16.1' +BEETSTREAMNEXT_VERSION = '1.4.5' + +# Prefixes for BeetstreamNext's internal IDs +ART_ID_PREF = 'ar-' +ALB_ID_PREF = 'al-' +SNG_ID_PREF = 'sg-' +PLY_ID_PREF = 'pl-' + + +FFMPEG_BIN = shutil.which("ffmpeg") is not None +FFMPEG_PYTHON = importlib.util.find_spec("ffmpeg") is not None + +if FFMPEG_PYTHON: + import ffmpeg +elif FFMPEG_BIN: + import subprocess + + +# === BeetstreamNext internal IDs makers/readers === +# These IDs are sent to the client once (when it accesses endpoints such as getArtists or getAlbumList +# and the client will then use these to access a specific item via endpoints that need an ID + +def beets_to_sub_artist(beet_artist_name): + base64_name = base64.urlsafe_b64encode(str(beet_artist_name).encode('utf-8')) + return f"{ART_ID_PREF}{base64_name.rstrip(b'=').decode('utf-8')}" + +def sub_to_beets_artist(subsonic_artist_id): + subsonic_artist_id = str(subsonic_artist_id)[len(ART_ID_PREF):] + padding = 4 - (len(subsonic_artist_id) % 4) + return base64.urlsafe_b64decode(subsonic_artist_id + ('=' * padding)).decode('utf-8') + +def beets_to_sub_album(beet_album_id): + return f'{ALB_ID_PREF}{beet_album_id}' + +def sub_to_beets_album(subsonic_album_id): + return int(str(subsonic_album_id)[len(ALB_ID_PREF):]) + +def beets_to_sub_song(beet_song_id): + return f'{SNG_ID_PREF}{beet_song_id}' + +def sub_to_beets_song(subsonic_song_id): + return int(str(subsonic_song_id)[len(SNG_ID_PREF):]) + + +# === Mapping functions to translate Beets to Subsonic dict-like structures === + +# TODO - Support multiartists lists!!! See https://opensubsonic.netlify.app/docs/responses/child/ + +def map_media(beets_object: Union[dict, library.LibModel]): + beets_object = dict(beets_object) + + artist_name = beets_object.get('albumartist', '') + + # Common fields to albums and songs + subsonic_media = { + 'artist': artist_name, + 'artistId': beets_to_sub_artist(artist_name), + 'displayArtist': artist_name, + 'displayAlbumArtist': artist_name, + 'album': beets_object.get('album', ''), + 'year': beets_object.get('year', 0), + 'genre': beets_object.get('genre', ''), + 'genres': [{'name': g} for g in genres_formatter(beets_object.get('genre', ''))], + 'created': timestamp_to_iso(beets_object.get('added')) or datetime.now().isoformat(), # default to now? + 'originalReleaseDate': { + 'year': beets_object.get('original_year', 0) or beets_object.get('year', 0), + 'month': beets_object.get('original_month', 0) or beets_object.get('month', 0), + 'day': beets_object.get('original_day', 0) or beets_object.get('day', 0) + }, + 'releaseDate': { + 'year': beets_object.get('year', 0), + 'month': beets_object.get('month', 0), + 'day': beets_object.get('day', 0) + }, + } + return subsonic_media + +def map_album(album_object: Union[dict, library.Album], with_songs=True) -> dict: + album = dict(album_object) + + subsonic_album = map_media(album) + + beets_album_id = album.get('id', 0) + subsonic_album_id = beets_to_sub_album(beets_album_id) + album_name = album.get('album', '') + + album_specific = { + 'id': subsonic_album_id, + 'musicBrainzId': album.get('mb_albumid', ''), + 'name': album_name, + 'sortName': album_name, + # 'version': 'Deluxe Edition', # TODO - Use the 'media' field maybe? + 'coverArt': subsonic_album_id, + + # 'starred': timestamp_to_iso(album.get('last_liked_album', 0)), + 'userRating': album.get('stars_rating_album', 0), + + # 'recordLabels': [{'name': l for l in stringlist_splitter(album.get('label', ''))}], + 'isCompilation': bool(album.get('comp', False)), + + # These are only needed when part of a directory response + 'isDir': True, + 'parent': subsonic_album['artistId'], + + # Title field is required for Child responses (also used in albumList or albumList2 responses) + 'title': album_name, + + # This is only needed when part of a Child response + 'mediaType': 'album' + } + subsonic_album.update(album_specific) + + # Add release types if possible + release_types = album.get('albumtypes', '') or album.get('albumtype', '') + if isinstance(release_types, str): + subsonic_album['releaseTypes'] = stringlist_splitter(release_types) + else: + subsonic_album['releaseTypes'] = [r.strip().title() for r in release_types] + + # Add multi-disc info if needed + nb_discs = album.get('disctotal', 1) + if nb_discs > 1: + subsonic_album["discTitles"] = [ + {'disc': d, 'title': ' - '.join(filter(None, [album.get('album', None), f'Disc {d + 1}']))} + for d in range(nb_discs) + ] + + # Songs should be included when part of either: + # - an AlbumID3WithSongs response + # - a directory response (in which case the 'song' key needs to be renamed to 'child') + + # Even if not including songs in the response, we still need to have their count and duration... + if not isinstance(album_object, library.Album): + # ...so in case album_object comes from a direct SQL transaction, we need to query once more + songs = list(flask.g.lib.items(f'album_id:{beets_album_id}')) + else: + # ...if it is a beets.library.Album object, we already have them + songs = list(album_object.items()) + + if with_songs: + songs.sort(key=lambda s: s.track) # Is it really necessary to sort them? + subsonic_album['song'] = list(map(map_song, songs)) + + # Add remaining required fields + subsonic_album['duration'] = round(sum(s.get('length', 0) for s in songs)) + subsonic_album['songCount'] = len(songs) + + # Optional field + songs_ratings = [s.get('stars_rating', 0) for s in subsonic_album.get('song', []) if s.get('stars_rating', 0)] + subsonic_album['averageRating'] = sum(songs_ratings) / len(songs_ratings) if songs_ratings else 0 + + return subsonic_album + +def map_song(song_object): + song = dict(song_object) + + subsonic_song = map_media(song) + + song_id = beets_to_sub_song(song.get('id', 0)) + song_name = song.get('title', '') + song_filepath = song.get('path', b'').decode('utf-8') + + album_id = beets_to_sub_album(song.get('album_id', 0)) + + song_specific = { + 'id': song_id, + 'musicBrainzId': song.get('mb_albumid', ''), + 'name': song_name, + 'sortName': song_name, + 'albumId': album_id, + 'coverArt': album_id or song_id, + + 'track': song.get('track', 1), + 'path': song_filepath if os.path.isfile(song_filepath) else '', + + 'played': timestamp_to_iso(song.get('last_played', 0)), + # 'starred': timestamp_to_iso(song.get('last_liked', 0)), + 'playCount': song.get('play_count', 0), + 'userRating': song.get('stars_rating', 0), + + 'duration': round(song.get('length', 0)), + 'bpm': song.get('bpm', 0), + 'bitRate': round(song.get('bitrate', 0) / 1000) or 0, + 'bitDepth': song.get('bitdepth', 0), + 'samplingRate': song.get('samplerate', 0), + 'channelCount': song.get('channels', 2), + 'discNumber': song.get('disc', 0), + 'comment': song.get('comment', ''), + + # These are only needed when part of a directory response + 'isDir': False, + 'parent': album_id or subsonic_song['artistId'], + + # TODO - is there really no chance to have videos in beets' database? + 'isVideo': False, + 'type': 'music', + + # Title field is required for Child responses + 'title': song_name, + + # This is only needed when part of a Child response + 'mediaType': 'song' + } + subsonic_song.update(song_specific) + + # subsonic_song['replayGain'] = { + # 'trackGain': (song.get('rg_track_gain') or 0) or ((song.get('r128_track_gain') or 107) - 107), + # 'albumGain': (song.get('rg_album_gain') or 0) or ((song.get('r128_album_gain') or 107) - 107), + # 'trackPeak': song.get('rg_track_peak', 0), + # 'albumPeak': song.get('rg_album_peak', 0) + # } + + # Add remaining filetype-related elements with fallbacks + subsonic_song['suffix'] = song.get('format').lower() or subsonic_song['path'].rsplit('.', 1)[-1].lower() + subsonic_song['size'] = os.path.getsize(subsonic_song['path']) or round(song.get('bitrate', 0) * song.get('length', 0) / 8) + subsonic_song['contentType'] = get_mimetype(subsonic_song.get('path', None) or subsonic_song.get('suffix', None)) + + return subsonic_song + + +def map_artist(artist_name, with_albums=True): + subsonic_artist_id = beets_to_sub_artist(artist_name) + + subsonic_artist = { + 'id': subsonic_artist_id, + 'name': artist_name, + 'sortName': artist_name, + 'title': artist_name, + # "starred": "2021-07-03T06:15:28.757Z", # nothing if not starred + 'coverArt': subsonic_artist_id, + "userRating": 0, + + # "roles": [ + # "artist", + # "albumartist", + # "composer" + # ], + + # This is only needed when part of a Child response + 'mediaType': 'artist' + } + + # dz_data = query_deezer(artist_name, 'artist') + # if dz_data: + # subsonic_artist['artistImageUrl'] = dz_data.get('picture_big', '') + + albums = list(flask.g.lib.albums(f'albumartist:{artist_name}')) + subsonic_artist['albumCount'] = len(albums) + if albums: + subsonic_artist['musicBrainzId'] = albums[0].get('mb_albumartistid', '') + + if with_albums: + subsonic_artist['album'] = list(map(partial(map_album, with_songs=False), albums)) + + return subsonic_artist + + +def map_playlist(playlist): + subsonic_playlist = { + 'id': playlist.id, + 'name': playlist.name, + 'songCount': len(playlist.songs), + 'duration': playlist.duration, + 'created': timestamp_to_iso(playlist.ctime), + 'changed': timestamp_to_iso(playlist.mtime), + 'entry': playlist.songs + + # 'owner': 'userA', # TODO + # 'public': True, + } + return subsonic_playlist + + +# === Core response-formatting functions === + + +def dict_to_xml(tag: str, data): + """ + Converts a json-like dict to an XML tree where every key/value pair + with a simple value is mapped as an attribute.... unless if adding the attribute + would create a duplicate, in which case a new element with that tag is created instead + """ + elem = ET.Element(tag) + + if isinstance(data, dict): + for key, val in data.items(): + if not isinstance(val, (dict, list)): + # If the attribute already exists, create a child element + if key in elem.attrib: + child = ET.Element(key) + child.text = str(val).lower() if isinstance(val, bool) else str(val) + elem.append(child) + else: + elem.set(key, str(val).lower() if isinstance(val, bool) else str(val)) + elif isinstance(val, list): + for item in val: + # For each item in the list, process depending on type + if not isinstance(item, (dict, list)): + if key in elem.attrib: + child = ET.Element(key) + child.text = str(item).lower() if isinstance(item, bool) else str(item) + elem.append(child) + else: + elem.set(key, str(item).lower() if isinstance(item, bool) else str(item)) + else: + child = dict_to_xml(key, item) + elem.append(child) + elif isinstance(val, dict): + child = dict_to_xml(key, val) + elem.append(child) + + elif isinstance(data, list): + # when data is a list, each item becomes a new child + for item in data: + if not isinstance(item, (dict, list)): + if tag in elem.attrib: + child = ET.Element(tag) + child.text = str(item).lower() if isinstance(item, bool) else str(item) + elem.append(child) + else: + elem.set(tag, str(item).lower() if isinstance(item, bool) else str(item)) + else: + child = dict_to_xml(tag, item) + elem.append(child) + else: + elem.text = str(data).lower() if isinstance(data, bool) else str(data) + + return elem + + +def jsonpify(format: str, data: dict): + if format == 'jsonp': + callback = flask.request.values.get("callback") + return f"{callback}({json.dumps(data)});" + else: + return flask.jsonify(data) + + +def subsonic_response(data: dict = {}, resp_fmt: str = 'xml'): + """ Wrap any json-like dict with the subsonic response elements + and output the appropriate 'format' (json or xml) """ + + if resp_fmt.startswith('json'): + wrapped = { + 'subsonic-response': { + 'status': 'ok', + 'version': API_VERSION, + 'type': 'BeetstreamNext', + 'serverVersion': BEETSTREAMNEXT_VERSION, + 'openSubsonic': True, + **data + } + } + return jsonpify(resp_fmt, wrapped) + + else: + root = dict_to_xml("subsonic-response", data) + root.set("xmlns", "http://subsonic.org/restapi") + root.set("status", 'ok') + root.set("version", API_VERSION) + root.set("type", 'BeetstreamNext') + root.set("serverVersion", BEETSTREAM_VERSION) + root.set("openSubsonic", 'true') + + xml_bytes = ET.tostring(root, encoding='UTF-8', method='xml', xml_declaration=True) + pretty_xml = minidom.parseString(xml_bytes).toprettyxml(encoding='UTF-8') + xml_str = pretty_xml.decode('UTF-8') + + return flask.Response(xml_str, mimetype="text/xml") + + +def subsonic_error(code: int = 0, message: str = '', resp_fmt: str = 'xml'): + + subsonic_errors = { + 0: 'A generic error.', + 10: 'Required parameter is missing.', + 20: 'Incompatible Subsonic REST protocol version. Client must upgrade.', + 30: 'Incompatible Subsonic REST protocol version. Server must upgrade.', + 40: 'Wrong username or password.', + 41: 'Token authentication not supported.', + 42: 'Provided authentication mechanism not supported.', + 43: 'Multiple conflicting authentication mechanisms provided.', + 44: 'Invalid API key.', + 50: 'User is not authorized for the given operation.', + # 60: 'The trial period for the Subsonic server is over.', + 70: 'The requested data was not found.' + } + + err_payload = { + 'error': { + 'code': code, + 'message': message if message else subsonic_errors[code], + # 'helpUrl': '' + } + } + + if resp_fmt.startswith('json'): + wrapped = { + 'subsonic-response': { + 'status': 'failed', + 'version': API_VERSION, + 'type': 'BeetstreamNext', + 'serverVersion': BEETSTREAMNEXT_VERSION, + 'openSubsonic': True, + **err_payload + } + } + return jsonpify(resp_fmt, wrapped) + + else: + root = dict_to_xml("subsonic-response", err_payload) + root.set("xmlns", "http://subsonic.org/restapi") + root.set("status", 'failed') + root.set("version", API_VERSION) + root.set("type", 'BeetstreamNext') + root.set("serverVersion", BEETSTREAMNEXT_VERSION) + root.set("openSubsonic", 'true') + + xml_bytes = ET.tostring(root, encoding='UTF-8', method='xml', xml_declaration=True) + pretty_xml = minidom.parseString(xml_bytes).toprettyxml(encoding='UTF-8') + xml_str = pretty_xml.decode('UTF-8') + + return flask.Response(xml_str, mimetype="text/xml") + + +# === Various other utility functions === + + +def strip_accents(s): + return ''.join(c for c in unicodedata.normalize('NFD', s) if unicodedata.category(c) != 'Mn') + +def timestamp_to_iso(timestamp): + return datetime.fromtimestamp(timestamp if timestamp else 0).isoformat() + +def get_mimetype(path): + + if isinstance(path, (bytes, bytearray)): + path = path.decode('utf-8') + elif isinstance(path, Path): + path = path.as_posix() + if not '.' in path: # Assume the passed arg is just an extension + path = f'file.{path}' + + mimetype_fallback = { + '.aac': 'audio/aac', + '.flac': 'audio/flac', + '.mp3': 'audio/mpeg', + '.mp4': 'audio/mp4', + '.m4a': 'audio/mp4', + '.ogg': 'audio/ogg', + '.opus': 'audio/opus', + None: 'application/octet-stream' + } + return mimetypes.guess_type(path)[0] or mimetype_fallback.get(path.rsplit('.', 1)[-1], 'application/octet-stream') + +def stringlist_splitter(delimiter_separated_string: str): + delimiters = re.compile('|'.join([';', ',', '/', '\\|'])) + return re.split(delimiters, delimiter_separated_string) + +def genres_formatter(genres): + """ Additional cleaning for common genres formatting issues """ + if isinstance(genres, str): + genres = stringlist_splitter(genres) + return [g.strip().title() + .replace('Post ', 'Post-') + .replace('Prog ', 'Progressive ') + .replace('Rnb', 'R&B') + .replace("R'N'B", 'R&B') + .replace("R 'N' B", 'R&B') + .replace('Rock & ', 'Rock and ') + .replace("Rock'N'", 'Rock and') + .replace("Rock 'N'", 'Rock and') + .replace('.', ' ') + for g in genres] + +def creation_date(filepath): + """ Get a file's creation date + See: http://stackoverflow.com/a/39501288/1709587 """ + if platform.system() == 'Windows': + return os.path.getctime(filepath) + elif platform.system() == 'Darwin': + stat = os.stat(filepath) + return stat.st_birthtime + else: + stat = os.stat(filepath) + try: + # On some Unix systems, st_birthtime is available so try it + return stat.st_birthtime + except AttributeError: + try: + # Run stat twice because it's faster and easier than parsing the %W format... + ret = subprocess.run(['stat', '--format=%W', filepath], stdout=subprocess.PIPE) + timestamp = ret.stdout.decode('utf-8').strip() + + # ...but we still want millisecond precision :) + ret = subprocess.run(['stat', '--format=%w', filepath], stdout=subprocess.PIPE) + millis = ret.stdout.decode('utf-8').rsplit('.', 1)[1].split()[0].strip() + + return float(f'{timestamp}.{millis}') + except: + # If that did not work, settle for last modification time + return stat.st_mtime + + +def query_musicbrainz(mbid: str, type: str): + + types_mb = {'track': 'recording', 'album': 'release', 'artist': 'artist'} + endpoint = f'https://musicbrainz.org/ws/2/{types_mb[type]}/{mbid}' + + headers = {'User-Agent': f'BeetstreamNext/{BEETSTREAMNEXT_VERSION} ( https://github.com/FlorentLM/BeetstreamNext )'} + params = {'fmt': 'json'} + + if types_mb[type] == 'artist': + params['inc'] = 'annotation' + + response = requests.get(endpoint, headers=headers, params=params) + return response.json() if response.ok else {} + + +def query_deezer(query: str, type: str): + + query_urlsafe = urllib.parse.quote_plus(query.replace(' ', '-')) + endpoint = f'https://api.deezer.com/{type}/{query_urlsafe}' + + headers = {'User-Agent': f'BeetstreamNext/{BEETSTREAMNEXT_VERSION} ( https://github.com/FlorentLM/BeetstreamNext )'} + + response = requests.get(endpoint, headers=headers) + + return response.json() if response.ok else {} + + +def query_lastfm(query: str, type: str, method: str = 'info', mbid=True): + if not app.config['lastfm_api_key']: + return {} + + query_lastfm = query.replace(' ', '+') + endpoint = 'http://ws.audioscrobbler.com/2.0/' + + params = { + 'format': 'json', + 'method': f'{type}.get{method.title()}', + 'api_key': app.config['lastfm_api_key'], + } + + if mbid: + params['mbid'] = query + elif query_lastfm and type != 'user': + params[type] = query_lastfm + + + headers = {'User-Agent': f'BeetstreamNext/{BEETSTREAMNEXT_VERSION} ( https://github.com/FlorentLM/BeetstreamNext )'} + response = requests.get(endpoint, headers=headers, params=params) + + return response.json() if response.ok else {} + + +def trim_text(text, char_limit=300): + if len(text) <= char_limit: + return text + + snippet = text[:char_limit] + period_index = text.find(".", char_limit) + + if period_index != -1: + snippet = text[:period_index + 1] + + return snippet \ No newline at end of file diff --git a/beetstreamnext.png b/beetstreamnext.png new file mode 100644 index 0000000..57a3f04 Binary files /dev/null and b/beetstreamnext.png differ diff --git a/beetstreamnext.svg b/beetstreamnext.svg new file mode 100644 index 0000000..402cee9 --- /dev/null +++ b/beetstreamnext.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/missing-endpoints.md b/missing-endpoints.md index e356ed9..557c685 100644 --- a/missing-endpoints.md +++ b/missing-endpoints.md @@ -1,18 +1,12 @@ # Missing Endpoints -To be implemented: -- `getArtistInfo` -- `getAlbumInfo` -- `getAlbumInfo2` -- `getSimilarSongs` -- `getSimilarSongs2` -- `search` - -Could be fun to implement: -- `createPlaylist` +These need the BeetstreamNext internal database first: +- `getUsers` +- `createUser` +- `updateUser` +- `deleteUser` +- `changePassword` - `updatePlaylist` -- `deletePlaylist` -- `getLyrics` - `getAvatar` - `star` - `unstar` @@ -20,12 +14,15 @@ Could be fun to implement: - `getBookmarks` - `createBookmark` - `deleteBookmark` + +Could be fun to implement: +- `getLyrics` - `getPlayQueue` - `savePlayQueue` - `getScanStatus` - `startScan` -Video/Radio/Podcast stuff. Not related to this project +Video/Radio/Podcast stuff: - `getVideos` - `getVideoInfo` - `hls` @@ -51,10 +48,3 @@ Social stuff. Some could be fun to implement but I'm still not sure: - `jukeboxControl` - `getChatMessages` - `addChatMessage` - -Handling users is annoying but may be useful: -- `getUsers` -- `createUser` -- `updateUser` -- `deleteUser` -- `changePassword` diff --git a/pyproject.toml b/pyproject.toml index fa7093a..935501a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,50 @@ [build-system] -requires = ["setuptools>=42"] -build-backend = "setuptools.build_meta" \ No newline at end of file +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" + +[tool.poetry] +name = "beetstreamnext" +version = "1.9.0" +description = "A modern, feature-rich OpenSubsonic API server for your Beets.io music library." +authors = ["Florent Le Moël <25004801+FlorentLM@users.noreply.github.com>"] +license = "MIT" +readme = "README.md" +repository = "https://github.com/FlorentLM/BeetstreamNext" +packages = [{include = "beetsplug"}] + +[tool.poetry.dependencies] +python = ">3.9.1,<4.0" +beets = ">=2.0.0" +flask-cors = ">=6.0.1" +cryptography = ">=45.0.6" +pillow = ">=11.3.0" +requests = ">=2.32.4" +ffmpeg-python = {version = ">=0.2.0", optional = true} + +[tool.poetry.extras] +transcode = ["ffmpeg-python"] + +[tool.poetry.group.dev.dependencies] +pytest = ">=7.4.0" +ruff = ">=0.1.0" +poethepoet = ">=0.24.0" +pre-commit = ">=3.5.0" + +[tool.ruff] +# same line length as beets for consistency +line-length = 79 +select = ["E", "F", "I", "W", "C90", "N"] +ignore = ["E501"] # handled by formatter + +[tool.ruff.format] +quote-style = "single" + +[tool.poe.tasks] +# dev tasks +lint = "ruff check ." +format = "ruff format ." +check = [ + {cmd = "ruff format . --check"}, + {cmd = "ruff check ."}, +] +test = "pytest" \ No newline at end of file diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 9c558e3..0000000 --- a/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -. diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 01d5b2d..0000000 --- a/setup.cfg +++ /dev/null @@ -1,32 +0,0 @@ -[metadata] -name = Beetstream -version = 1.4.0 -author = Binary Brain -author_email = me@sachabron.ch -description = Beets.io plugin that expose SubSonic API endpoints, allowing you to stream your music everywhere. -long_description = file: README.md -long_description_content_type = text/markdown -url = https://github.com/BinaryBrain/Beetstream -project_urls = - Bug Tracker = https://github.com/BinaryBrain/Beetstream/issues -classifiers = - Environment :: No Input/Output (Daemon) - Intended Audience :: End Users/Desktop - Programming Language :: Python :: 3 - Programming Language :: Python :: 3.8 - Programming Language :: Python :: 3.9 - Operating System :: OS Independent - Topic :: Multimedia :: Sound/Audio :: Players - License :: OSI Approved :: MIT License - Framework :: Flask - -[options] -packages = find: -python_requires = >=3.8 -install_requires = - flask >= 1.1.2 - flask_cors >= 3.0.10 - Pillow >= 8.4.0 - ffmpeg-python >= 0.2.0 - -[options.packages.find]