raphson-music-headless 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2 @@
1
+ config.json
2
+ __pycache__/
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## 1.0.0 2024-11-23
4
+
5
+ Initial release
@@ -0,0 +1,77 @@
1
+ Metadata-Version: 2.3
2
+ Name: raphson_music_headless
3
+ Version: 1.0.0
4
+ Summary: Headless music player server for the Raphson music player
5
+ Project-URL: Homepage, https://codeberg.org/raphson/music-headless
6
+ Project-URL: Issues, https://codeberg.org/raphson/music-headless/issues
7
+ Author-email: Robin Slot <robin@rslot.nl>
8
+ Classifier: Development Status :: 5 - Production/Stable
9
+ Classifier: Environment :: No Input/Output (Daemon)
10
+ Classifier: Framework :: AsyncIO
11
+ Classifier: Intended Audience :: System Administrators
12
+ Classifier: License :: OSI Approved :: GNU General Public License v3 (GPLv3)
13
+ Classifier: Natural Language :: English
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Topic :: Multimedia :: Sound/Audio :: Players
17
+ Classifier: Typing :: Typed
18
+ Requires-Python: >=3.12
19
+ Requires-Dist: aiohttp~=3.0
20
+ Requires-Dist: python-vlc~=3.0
21
+ Requires-Dist: raphson-music-client~=1.0
22
+ Description-Content-Type: text/markdown
23
+
24
+ # Headless playback server for Raphson Music
25
+
26
+ Client for the Raphson Music server that can be controlled remotely via a simple web interface or [Home Assistant](https://github.com/Sjorsa/ha-rmp/).
27
+
28
+ ## Installation
29
+
30
+ ### Using pipx (recommended)
31
+
32
+ ```
33
+ pipx install raphson-music-headless
34
+ ```
35
+
36
+ Run: `raphson-music-headless`
37
+
38
+ ### Using distribution packages
39
+ * Debian: `apt install python3-aiohttp python3-vlc`
40
+ * Fedora: `dnf install python3-aiohttp python3-vlc`
41
+
42
+ Then install the rest from PyPi: `pip install --user raphson-music-headless`
43
+
44
+ ### From Git
45
+ ```
46
+ git clone https://codeberg.org/raphson/music-headless
47
+ cd music-headless
48
+ python3 -m raphson_music_headless
49
+ ```
50
+
51
+ To install:
52
+ ```
53
+ pip install --user .
54
+ ```
55
+
56
+ ## Usage
57
+
58
+ 1. Create a `config.json` file with credentials (see `config.json.example`).
59
+ 2. Run `raphson-music-headless`
60
+
61
+ ## API
62
+
63
+ See [API.md](./docs/API.md)
64
+
65
+ ## Temporary files
66
+
67
+ The server writes music to temporary files so VLC can access them. On Linux, the `/tmp` directory is used for this purpose. It is strongly recommended to mount `tmpfs` on `/tmp` to avoid unnecessary writes to your disk, especially when using a Raspberry Pi with sd card.
68
+
69
+ Check if it is the case by running `mount | grep /tmp`. It should show something like: `tmpfs on /tmp type tmpfs ...`
70
+
71
+ ## Cache size
72
+
73
+ The `cache_size` setting determines the number of cached tracks for each playlist. These tracks are kept in memory, consuming roughly 3 - 6MB per track including cover image. Say `cache_size` is set to 4 and you use a maximum of 10 playlists, you will need around 200MiB of memory.
74
+
75
+ ## Bugs
76
+
77
+ When playing mono audio (like news), sound may only be played on the left channel. This appears to be an issue with PipeWire.
@@ -0,0 +1,54 @@
1
+ # Headless playback server for Raphson Music
2
+
3
+ Client for the Raphson Music server that can be controlled remotely via a simple web interface or [Home Assistant](https://github.com/Sjorsa/ha-rmp/).
4
+
5
+ ## Installation
6
+
7
+ ### Using pipx (recommended)
8
+
9
+ ```
10
+ pipx install raphson-music-headless
11
+ ```
12
+
13
+ Run: `raphson-music-headless`
14
+
15
+ ### Using distribution packages
16
+ * Debian: `apt install python3-aiohttp python3-vlc`
17
+ * Fedora: `dnf install python3-aiohttp python3-vlc`
18
+
19
+ Then install the rest from PyPi: `pip install --user raphson-music-headless`
20
+
21
+ ### From Git
22
+ ```
23
+ git clone https://codeberg.org/raphson/music-headless
24
+ cd music-headless
25
+ python3 -m raphson_music_headless
26
+ ```
27
+
28
+ To install:
29
+ ```
30
+ pip install --user .
31
+ ```
32
+
33
+ ## Usage
34
+
35
+ 1. Create a `config.json` file with credentials (see `config.json.example`).
36
+ 2. Run `raphson-music-headless`
37
+
38
+ ## API
39
+
40
+ See [API.md](./docs/API.md)
41
+
42
+ ## Temporary files
43
+
44
+ The server writes music to temporary files so VLC can access them. On Linux, the `/tmp` directory is used for this purpose. It is strongly recommended to mount `tmpfs` on `/tmp` to avoid unnecessary writes to your disk, especially when using a Raspberry Pi with sd card.
45
+
46
+ Check if it is the case by running `mount | grep /tmp`. It should show something like: `tmpfs on /tmp type tmpfs ...`
47
+
48
+ ## Cache size
49
+
50
+ The `cache_size` setting determines the number of cached tracks for each playlist. These tracks are kept in memory, consuming roughly 3 - 6MB per track including cover image. Say `cache_size` is set to 4 and you use a maximum of 10 playlists, you will need around 200MiB of memory.
51
+
52
+ ## Bugs
53
+
54
+ When playing mono audio (like news), sound may only be played on the left channel. This appears to be an issue with PipeWire.
@@ -0,0 +1,9 @@
1
+ {
2
+ "host": "127.0.0.1",
3
+ "port": 8181,
4
+ "server": "https://music.raphson.nl",
5
+ "token": "",
6
+ "default_playlists": [],
7
+ "cache_size": 4,
8
+ "news": true
9
+ }
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env bash
2
+ set -e
3
+ rm -rf dist
4
+ python3 -m build
5
+ python3 -m twine upload dist/*
@@ -0,0 +1,82 @@
1
+ # API
2
+
3
+ ## GET `/`
4
+
5
+ Basic web UI
6
+
7
+ ## GET `/state`
8
+
9
+ ```json
10
+ {
11
+ "playlists": {
12
+ "all": ["CB", "DK", "JK", "JM", "MA"], // List of all playlist names
13
+ "enabled": ["CB", "JK"] // List of enabled playlist names
14
+ },
15
+ "player": {
16
+ "has_media": true, // True when paused or playing, false when stopped.
17
+ "is_playing": true, // True when playing, false when paused or stopped
18
+ "position": 15, // Current playback position (-1 when stopped)
19
+ "duration": 207, // Total track duration as reported by VLC (-1 when stopped)
20
+ "volume": 100 // VLC volume (0-100, -1 when stopped, 0 at initial startup)
21
+ },
22
+ "currently_playing": { // May be null, if no media is present or if playing a virtual track like news
23
+ "path": "JK/25. Resist and Bite.mp3",
24
+ "duration": 207, // Duration as reported by the server. For seek bars, use the duration in the player section instead.
25
+ "title": "Resist And Bite", // May be null
26
+ "album": "War And Victory - Best Of...Sabaton", // May be null
27
+ "album_artist": "Sabaton", // May be null
28
+ "year": 2016, // May be null
29
+ "artists": [ // May be empty, but never null
30
+ "Sabaton"
31
+ ]
32
+ }
33
+ }
34
+ ```
35
+
36
+ ## GET `/image`
37
+
38
+ Album cover image for currently playing track. Responds with status code 400 if no track is playing.
39
+
40
+ ## GET `/list_tracks?playlist=<playlist>`
41
+
42
+ List of tracks in a playlist. Directly returns the response from the music player endpoint: `/tracks/filter?playlist=<playlist>`
43
+
44
+ ## POST `/stop`
45
+
46
+ Stop music, if currently playing. Nothing happens if no music is playing.
47
+
48
+ ## POST `/pause`
49
+
50
+ Pauses music. Nothing happens if music is already paused or no music is playing.
51
+
52
+ ## POST `/play`
53
+
54
+ If music is paused, playback is resumed. If no music was playing, a new track is loaded and started. If no playlists are enabled, nothing happens.
55
+
56
+ ## POST `/next`
57
+
58
+ A new track is loaded from the next playlist, and started. If no playlists are enabled, nothing happens.
59
+
60
+ ### POST `/play_news`
61
+
62
+ The latest available news is downloaded and played immediately, even if hourly news is disabled.
63
+
64
+ ### POST `/seek`
65
+
66
+ Seek to position in seconds, provided as an integer in the request body.
67
+
68
+ ### POST `/volume`
69
+
70
+ Set player volume. Post body should be set to an integer 0-100.
71
+
72
+ ## POST `/playlists`
73
+
74
+ Set enabled playlists. Post body should be a json array of playlist names.
75
+
76
+ ## POST `/enqueue`
77
+
78
+ Add track to queue. Post body should contain a track path.
79
+
80
+ ## POST `/play_track`
81
+
82
+ Load a specific track and play it immediately. Post body should contain a track path.
@@ -0,0 +1,42 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "raphson_music_headless"
7
+ version = "1.0.0"
8
+ authors = [
9
+ { name = "Robin Slot", email = "robin@rslot.nl" },
10
+ ]
11
+ description = "Headless music player server for the Raphson music player"
12
+ readme = "README.md"
13
+ requires-python = ">=3.12"
14
+ classifiers = [
15
+ "Development Status :: 5 - Production/Stable",
16
+ "Environment :: No Input/Output (Daemon)",
17
+ "Framework :: AsyncIO",
18
+ "Intended Audience :: System Administrators",
19
+ "License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
20
+ "Natural Language :: English",
21
+ "Operating System :: OS Independent",
22
+ "Programming Language :: Python :: 3",
23
+ "Topic :: Multimedia :: Sound/Audio :: Players",
24
+ "Typing :: Typed",
25
+ ]
26
+ dependencies = [
27
+ "aiohttp~=3.0",
28
+ "raphson_music_client~=1.0",
29
+ "python-vlc~=3.0"
30
+ ]
31
+
32
+ [project.scripts]
33
+ raphson-music-headless = "raphson_music_headless.__main__:main"
34
+
35
+ [project.urls]
36
+ Homepage = "https://codeberg.org/raphson/music-headless"
37
+ Issues = "https://codeberg.org/raphson/music-headless/issues"
38
+
39
+ [tool.basedpyright]
40
+ include = ["raphson_music_headless"]
41
+ reportUnusedCallResult = false
42
+ reportAny = false
@@ -0,0 +1,11 @@
1
+ import logging
2
+ from pathlib import Path
3
+
4
+ from .config import Config
5
+ from .server import App
6
+
7
+ if __name__ == '__main__':
8
+ logging.basicConfig(level=logging.INFO)
9
+ config = Config.load(Path('config.json'))
10
+ app = App(config)
11
+ app.start(config)
@@ -0,0 +1,23 @@
1
+
2
+ import json
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from typing import Self
6
+
7
+
8
+ @dataclass
9
+ class Config:
10
+ host: str
11
+ port: int
12
+ server: str
13
+ token: str
14
+ default_playlists: list[str]
15
+ cache_size: int
16
+ news: bool
17
+
18
+ @classmethod
19
+ def load(cls, path: Path) -> Self:
20
+ with open(path, 'r') as config_file:
21
+ config = json.load(config_file)
22
+
23
+ return cls(config['host'], config['port'], config['server'], config['token'], config['default_playlists'], config['cache_size'], config['news'])
@@ -0,0 +1,132 @@
1
+ import asyncio
2
+ import logging
3
+ import time
4
+ import traceback
5
+ from collections import deque
6
+ from datetime import datetime
7
+
8
+ from raphson_music_client import DownloadedTrack, Playlist, RaphsonMusicClient
9
+ from requests import RequestException
10
+
11
+ from .config import Config
12
+
13
+ _LOGGER = logging.getLogger(__name__)
14
+
15
+
16
+ class Downloader:
17
+ client: RaphsonMusicClient
18
+ cache: dict[str, deque[DownloadedTrack]] = {}
19
+ queue: list[DownloadedTrack] = []
20
+ all_playlists: dict[str, Playlist] = {}
21
+ previous_playlist: str | None = None
22
+ enabled_playlists: list[str]
23
+ cache_size: int
24
+ news: DownloadedTrack | None = None
25
+ last_news_update: int = 0
26
+
27
+ def __init__(self, client: RaphsonMusicClient, config: Config):
28
+ self.client = client
29
+ self.enabled_playlists = list(config.default_playlists)
30
+ self.cache_size = config.cache_size
31
+
32
+ async def setup(self):
33
+ asyncio.create_task(self._fill_cache_task())
34
+ asyncio.create_task(self._update_playlists_task())
35
+
36
+ async def _update_playlists_task(self):
37
+ async def update_playlists():
38
+ self.all_playlists = {playlist.name: playlist for playlist in await self.client.playlists()}
39
+
40
+ while True:
41
+ await asyncio.gather(update_playlists(), asyncio.sleep(300))
42
+
43
+ async def _fill_cache_task(self):
44
+ while True:
45
+ await asyncio.gather(self.fill_cache(), asyncio.sleep(1))
46
+
47
+ async def fill_cache(self):
48
+ """
49
+ Ensure cache contains enough downloaded tracks
50
+ """
51
+ # Only update news once every 10 minutes to prevent spam of failing requests
52
+ if time.time() - self.last_news_update > 10*60:
53
+ if not self.news:
54
+ # No news, update immediately
55
+ _LOGGER.info('downloading news')
56
+ self.last_news_update = int(time.time())
57
+ self.news = await self.client.get_news()
58
+ return
59
+
60
+ # Update when new news is ready
61
+ if datetime.now().minute == 10:
62
+ _LOGGER.info('Downloading news')
63
+ self.last_news_update = int(time.time())
64
+ self.news = await self.client.get_news()
65
+ return
66
+
67
+ if len(self.enabled_playlists) == 0:
68
+ return
69
+
70
+ for playlist_name in self.enabled_playlists:
71
+ if playlist_name in self.cache:
72
+ if len(self.cache[playlist_name]) >= self.cache_size:
73
+ continue
74
+ else:
75
+ self.cache[playlist_name] = deque()
76
+
77
+ try:
78
+ track = await self.client.choose_track(playlist_name)
79
+ _LOGGER.info('Downloading track: %s', track.path)
80
+ downloaded = await track.download()
81
+ self.cache[playlist_name].append(downloaded)
82
+ except RequestException:
83
+ _LOGGER.warning('Failed to download track for playlist %s', playlist_name)
84
+ traceback.print_exc()
85
+ time.sleep(1)
86
+
87
+ def select_playlist(self) -> str | None:
88
+ """
89
+ Choose a playlist to play a track from.
90
+ """
91
+ if len(self.enabled_playlists) == 0:
92
+ _LOGGER.warning('No playlists enabled!')
93
+ return None
94
+
95
+ if self.previous_playlist:
96
+ try:
97
+ cur_index = self.enabled_playlists.index(self.previous_playlist)
98
+ self.previous_playlist = self.enabled_playlists[(cur_index + 1) % len(self.enabled_playlists)]
99
+ except ValueError: # not in list
100
+ self.previous_playlist = self.enabled_playlists[0]
101
+ else:
102
+ self.previous_playlist = self.enabled_playlists[0]
103
+
104
+ return self.previous_playlist
105
+
106
+ async def enqueue(self, track_path: str, front: bool = False) -> None:
107
+ track = await self.client.get_track(track_path)
108
+ download = await track.download()
109
+ if front:
110
+ self.queue.insert(0, download)
111
+ else:
112
+ self.queue.append(download)
113
+
114
+ async def enqueue_news(self) -> None:
115
+ if self.news:
116
+ self.queue.append(self.news)
117
+
118
+ def get_track(self) -> DownloadedTrack | None:
119
+ """
120
+ Get the next track to play
121
+ """
122
+ if self.queue:
123
+ return self.queue.pop(0)
124
+
125
+ playlist = self.select_playlist()
126
+ if playlist is None:
127
+ return None
128
+
129
+ if playlist not in self.cache or len(self.cache[playlist]) == 0:
130
+ return None
131
+
132
+ return self.cache[playlist].popleft()
@@ -0,0 +1,177 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <title>Raphson Playback Server - Web UI</title>
6
+ <script>
7
+ document.addEventListener('DOMContentLoaded', () => {
8
+ document.getElementById('button-play').addEventListener('click', () => {
9
+ fetch('/play', {method: 'POST'})
10
+ });
11
+ document.getElementById('button-pause').addEventListener('click', () => {
12
+ fetch('/pause', {method: 'POST'})
13
+ });
14
+ document.getElementById('button-next').addEventListener('click', () => {
15
+ fetch('/next', {method: 'POST'})
16
+ });
17
+ document.getElementById('button-stop').addEventListener('click', () => {
18
+ fetch('/stop', {method: 'POST'})
19
+ });
20
+ document.getElementById('button-news').addEventListener('click', () => {
21
+ fetch('/play_news', {method: 'POST'})
22
+ });
23
+ document.getElementById('button-seek').addEventListener('click', () => {
24
+ fetch('/seek', {method: 'POST', body: parseInt(document.getElementById('position').textContent) + 30});
25
+ });
26
+
27
+ setInterval(async () => {
28
+ const response = await fetch('/state');
29
+ const state = await response.json();
30
+ const player = state.player;
31
+
32
+ document.getElementById('has-media').textContent = player.has_media;
33
+ document.getElementById('is-playing').textContent = player.is_playing;
34
+ document.getElementById('position').textContent = player.position;
35
+ document.getElementById('duration').textContent = player.duration;
36
+ document.getElementById('volume').textContent = player.volume;
37
+ document.getElementById('track-path').textContent = state.currently_playing ? state.currently_playing.path : '';
38
+ document.getElementById('track-title').textContent = state.currently_playing ? state.currently_playing.title : '';
39
+ document.getElementById('track-artists').textContent = state.currently_playing && state.currently_playing.artists ? state.currently_playing.artists.join(', ') : '';
40
+ document.getElementById('track-album').textContent = state.currently_playing ? state.currently_playing.album : '';
41
+ document.getElementById('track-album-artist').textContent = state.currently_playing ? state.currently_playing.album_artist : '';
42
+ document.getElementById('track-year').textContent = state.currently_playing ? state.currently_playing.year: '';
43
+
44
+ // add redundant parameter to make the browser perform the request again when the path is changed
45
+ if (state.currently_playing) {
46
+ document.getElementById('album-cover').src = `/image#cache_bust` + encodeURIComponent(state.currently_playing.path);
47
+ } else {
48
+ document.getElementById('album-cover').src = "";
49
+ }
50
+ }, 1000);
51
+
52
+ setInterval(async () => {
53
+ const response = await fetch('/lyrics');
54
+ const lyrics = await response.text();
55
+ document.getElementById('lyrics').textContent = lyrics;
56
+ }, 2000);
57
+
58
+ document.getElementById('get-playlists').addEventListener('click', async () => {
59
+ const response = await fetch('/state');
60
+ const state = await response.json();
61
+ const playlists = state.playlists;
62
+
63
+ for (playlist of playlists.all) {
64
+ const input = document.createElement('input');
65
+ input.type = 'checkbox';
66
+ input.name = playlist
67
+ input.id = 'checkbox-' + playlist.replaceAll(' ', '-');
68
+ input.checked = playlists.enabled.indexOf(playlist) !== -1;
69
+ input.classList.add('playlist-checkbox');
70
+
71
+ const label = document.createElement('label');
72
+ label.htmlFor = 'checkbox-' + playlist.replaceAll(' ', '-');;
73
+ label.textContent = playlist;
74
+
75
+ const br = document.createElement('br');
76
+
77
+ document.getElementById('playlist-checkboxes').append(input, label, br);
78
+ }
79
+
80
+ document.getElementById('save-playlists').removeAttribute('disabled');
81
+ document.getElementById('get-playlists').setAttribute('disabled', '');
82
+ });
83
+
84
+ document.getElementById('save-playlists').addEventListener('click', async () => {
85
+ const playlists = []
86
+ for (checkbox of document.getElementsByClassName('playlist-checkbox')) {
87
+ if (checkbox.checked) {
88
+ playlists.push(checkbox.name);
89
+ }
90
+ }
91
+ await fetch('/playlists', {method: 'POST', body: JSON.stringify(playlists)});
92
+ document.getElementById('save-playlists').setAttribute('disabled', '');
93
+ document.getElementById('get-playlists').removeAttribute('disabled');
94
+ document.getElementById('playlist-checkboxes').replaceChildren();
95
+ });
96
+ });
97
+ </script>
98
+ </head>
99
+
100
+ <body>
101
+ <h3>Controls</h3>
102
+ <button id="button-play">Play</button>
103
+ <button id="button-pause">Pause</button>
104
+ <button id="button-next">Next</button>
105
+ <button id="button-stop">Stop</button>
106
+ <button id="button-news">News</button>
107
+ <button id="button-seek">Seek +30s</button>
108
+ <h3>Info (updated every second)</h3>
109
+ <table>
110
+ <tr>
111
+ <th colspan="2">Player</th>
112
+ </tr>
113
+ <tr>
114
+ <td>Has media</td>
115
+ <td id="has-media"></td>
116
+ </tr>
117
+ <tr>
118
+ <td>Is playing</td>
119
+ <td id="is-playing"></td>
120
+ </tr>
121
+ <tr>
122
+ <td>Position</td>
123
+ <td id="position"></td>
124
+ </tr>
125
+ <tr>
126
+ <td>Duration</td>
127
+ <td id="duration"></td>
128
+ </tr>
129
+ <tr>
130
+ <td>Volume</td>
131
+ <td id="volume"></td>
132
+ </tr>
133
+ <tr>
134
+ <th colspan="2">Track</th>
135
+ </tr>
136
+ <tr>
137
+ <td>Path</td>
138
+ <td id="track-path"></td>
139
+ </tr>
140
+ <tr>
141
+ <td>Title</td>
142
+ <td id="track-title"></td>
143
+ </tr>
144
+ <tr>
145
+ <td>Artists</td>
146
+ <td id="track-artists"></td>
147
+ </tr>
148
+ <tr>
149
+ <td>Album</td>
150
+ <td id="track-album"></td>
151
+ </tr>
152
+ <tr>
153
+ <td>Album artist</td>
154
+ <td id="track-album-artist"></td>
155
+ </tr>
156
+ <tr>
157
+ <td>Year</td>
158
+ <td id="track-year"></td>
159
+ </tr>
160
+ </table>
161
+ <h3>Playlists</h3>
162
+ <button id="get-playlists">Get playlists</button>
163
+ <div>
164
+ <div id="playlist-checkboxes">
165
+
166
+ </div>
167
+ <button id="save-playlists" disabled>Save playlists</button>
168
+ </div>
169
+
170
+ <h3>Lyrics</h3>
171
+ <div id="lyrics" style="white-space: pre-line"></div>
172
+
173
+ <h3>Album cover</h3>
174
+ <img src="" width="1200" style="max-width: 100%;" id="album-cover">
175
+ </body>
176
+
177
+ </html>
@@ -0,0 +1,178 @@
1
+ import asyncio
2
+ import logging
3
+ import time
4
+ import traceback
5
+ from collections.abc import Coroutine
6
+ from datetime import datetime
7
+ from tempfile import NamedTemporaryFile
8
+ from typing import TYPE_CHECKING, cast
9
+
10
+ from raphson_music_client.track import DownloadedTrack
11
+ import vlc
12
+ from raphson_music_client import RaphsonMusicClient
13
+ from requests import RequestException
14
+
15
+ from .config import Config
16
+ from .downloader import Downloader
17
+
18
+ if TYPE_CHECKING:
19
+ from tempfile import \
20
+ _TemporaryFileWrapper # pyright: ignore[reportPrivateUsage]
21
+
22
+
23
+ _LOGGER = logging.getLogger(__name__)
24
+
25
+
26
+ class AudioPlayer():
27
+ temp_file: '_TemporaryFileWrapper[bytes] | None' = None
28
+ downloader: 'Downloader'
29
+ client: RaphsonMusicClient
30
+ currently_playing: DownloadedTrack | None = None
31
+ vlc_instance: vlc.Instance
32
+ vlc_player: vlc.MediaPlayer
33
+ vlc_events: vlc.EventManager
34
+ start_timestamp: int = 0
35
+ news: bool
36
+ last_news: int
37
+
38
+ def __init__(self,
39
+ client: RaphsonMusicClient,
40
+ downloader: 'Downloader',
41
+ config: Config):
42
+ self.client = client
43
+ self.downloader = downloader
44
+ self.news = config.news
45
+ self.last_news = int(time.time()) # do not queue news right after starting
46
+
47
+ self.vlc_instance = vlc.Instance('--file-caching=0')
48
+ self.vlc_player = self.vlc_instance.media_player_new()
49
+ self.vlc_events = self.vlc_player.event_manager()
50
+
51
+ async def setup(self):
52
+ asyncio.create_task(self._now_playing_submitter())
53
+ self.vlc_events.event_attach(vlc.EventType.MediaPlayerEndReached, self.on_media_end, asyncio.get_running_loop())
54
+
55
+ async def on_media_end_async(self) -> None:
56
+ tasks: list[Coroutine[None, None, None]] = []
57
+ # save current info before it is replaced by the next track
58
+ if self.currently_playing and self.currently_playing.track:
59
+ path = self.currently_playing.track.path
60
+ start_timestamp = self.start_timestamp
61
+ tasks.append(self.client.submit_played(path, start_timestamp))
62
+ tasks.append(self.next(retry=True))
63
+ await asyncio.gather(*tasks)
64
+
65
+ def on_media_end(self, event, loop: asyncio.AbstractEventLoop):
66
+ _LOGGER.info('Media ended, play next')
67
+ async def create_task():
68
+ await loop.create_task(self.on_media_end_async())
69
+ asyncio.run_coroutine_threadsafe(create_task(), loop)
70
+
71
+ def stop(self):
72
+ try:
73
+ self.vlc_player.stop()
74
+ self.vlc_player.set_media(None)
75
+ self.currently_playing = None
76
+ finally:
77
+ if self.temp_file:
78
+ self.temp_file.close()
79
+
80
+ def pause(self):
81
+ self.vlc_player.set_pause(True)
82
+ asyncio.create_task(self._submit_now_playing())
83
+
84
+ async def play(self):
85
+ if self.has_media():
86
+ self.vlc_player.play()
87
+ asyncio.create_task(self._submit_now_playing())
88
+ else:
89
+ await self.next()
90
+
91
+ async def next(self, retry: bool = False, force_news: bool = False) -> None:
92
+ if force_news:
93
+ await self.downloader.enqueue_news()
94
+ elif self.news:
95
+ minute = datetime.now().minute
96
+ # a few minutes past the hour and last news played more than 30 minutes ago?
97
+ if minute > 11 and minute < 20 and time.time() - self.last_news > 30*60:
98
+ # Attempt to download news. If it fails, next retry news won't be
99
+ # downloaded again because last_news is updated
100
+ self.last_news = int(time.time())
101
+ try:
102
+ await self.downloader.enqueue_news()
103
+ except:
104
+ traceback.print_exc()
105
+
106
+ download = self.downloader.get_track()
107
+
108
+ if not download:
109
+ _LOGGER.warning('No cached track available')
110
+ if retry:
111
+ _LOGGER.info('Retry enabled, going to try again')
112
+ time.sleep(5)
113
+ self.next(retry)
114
+ return
115
+
116
+ self.currently_playing = download
117
+ self.start_timestamp = int(time.time())
118
+ if download.track:
119
+ _LOGGER.info('Playing track:', download.track.path)
120
+ else:
121
+ _LOGGER.info('Playing virtual track')
122
+ temp_file = NamedTemporaryFile('wb', prefix='rmp-playback-server-')
123
+
124
+ try:
125
+ temp_file.truncate(0)
126
+ temp_file.write(download.audio)
127
+
128
+ media = self.vlc_instance.media_new(temp_file.name)
129
+ self.vlc_player.set_media(media)
130
+ self.vlc_player.play()
131
+ finally:
132
+ # Remove old temp file
133
+ if self.temp_file:
134
+ self.temp_file.close()
135
+ # Store current temp file so it can be removed later
136
+ self.temp_file = temp_file
137
+
138
+ asyncio.create_task(self._submit_now_playing())
139
+
140
+ def has_media(self) -> bool:
141
+ return self.vlc_player.get_media() is not None
142
+
143
+ def is_playing(self) -> bool:
144
+ return cast(int, self.vlc_player.is_playing()) == 1
145
+
146
+ def position(self) -> int:
147
+ return cast(int, self.vlc_player.get_time()) // 1000
148
+
149
+ def duration(self) -> int:
150
+ return cast(int, self.vlc_player.get_length()) // 1000
151
+
152
+ def seek(self, position: int):
153
+ _LOGGER.info('Seek to:', position)
154
+ self.vlc_player.set_time(position * 1000)
155
+
156
+ def volume(self) -> int:
157
+ return self.vlc_player.audio_get_volume()
158
+
159
+ def set_volume(self, volume: int) -> None:
160
+ self.vlc_player.audio_set_volume(volume)
161
+
162
+ async def _submit_now_playing(self):
163
+ if self.has_media() and self.currently_playing and self.currently_playing.track:
164
+ await self.client.submit_now_playing(self.currently_playing.track.path,
165
+ self.position(),
166
+ not self.is_playing())
167
+
168
+ async def _now_playing_submitter(self):
169
+ while True:
170
+ try:
171
+ await self._submit_now_playing()
172
+ except RequestException:
173
+ _LOGGER.warning('Failed to submit now playing info')
174
+
175
+ if self.is_playing():
176
+ await asyncio.sleep(10)
177
+ else:
178
+ await asyncio.sleep(60)
@@ -0,0 +1,180 @@
1
+ import asyncio
2
+ import logging
3
+ from pathlib import Path
4
+ from typing import Any, cast
5
+
6
+ from aiohttp import web
7
+ from aiohttp.web_app import Application
8
+ from raphson_music_client import RaphsonMusicClient
9
+ from raphson_music_client.playlist import Playlist
10
+
11
+ from .config import Config
12
+ from .downloader import Downloader
13
+ from .player import AudioPlayer
14
+
15
+ _LOGGER = logging.getLogger(__name__)
16
+
17
+
18
+ class App:
19
+ config: Config
20
+ client: RaphsonMusicClient
21
+ player: AudioPlayer
22
+ downloader: Downloader
23
+
24
+ def __init__(self, config: Config):
25
+ self.config = config
26
+ self.client = RaphsonMusicClient()
27
+ self.downloader = Downloader(self.client, config)
28
+ self.player = AudioPlayer(self.client, self.downloader, config)
29
+
30
+ async def _root(self, _request: web.Request) -> web.StreamResponse:
31
+ return web.FileResponse(Path(__file__).parent / "index.html")
32
+
33
+ async def _state(self, _request: web.Request) -> web.StreamResponse:
34
+ data: dict[str, Any] = {
35
+ "playlists": {
36
+ "all": list(self.downloader.all_playlists.keys()),
37
+ "enabled": self.downloader.enabled_playlists,
38
+ },
39
+ "player": {
40
+ "has_media": self.player.has_media(),
41
+ "is_playing": self.player.is_playing(),
42
+ "position": self.player.position(),
43
+ "duration": self.player.duration(),
44
+ "volume": self.player.volume(),
45
+ },
46
+ }
47
+ if self.player.currently_playing and self.player.currently_playing.track:
48
+ track = self.player.currently_playing.track
49
+ data["currently_playing"] = {
50
+ "path": track.path,
51
+ "duration": track.duration,
52
+ "title": track.title,
53
+ "album": track.album,
54
+ "album_artist": track.album_artist,
55
+ "year": track.year,
56
+ "artists": track.artists,
57
+ }
58
+ else:
59
+ data["currently_playing"] = None
60
+
61
+ return web.json_response(data)
62
+
63
+ async def _image(self, _request: web.Request) -> web.StreamResponse:
64
+ if self.player.currently_playing:
65
+ return web.Response(
66
+ body=self.player.currently_playing.image,
67
+ status=200,
68
+ content_type="image/webp",
69
+ )
70
+
71
+ return web.Response(status=404)
72
+
73
+ async def _lyrics(self, _request: web.Request) -> web.StreamResponse:
74
+ if self.player.currently_playing:
75
+ lyrics = self.player.currently_playing.lyrics_text
76
+ if lyrics:
77
+ return web.Response(
78
+ body=self.player.currently_playing.lyrics_text,
79
+ status=200,
80
+ content_type="text/plain",
81
+ )
82
+
83
+ return web.Response(status=204)
84
+
85
+ async def _list_tracks(self, request: web.Request) -> web.StreamResponse:
86
+ playlist = request.query["playlist"]
87
+ return web.Response(
88
+ body=await self.client.list_tracks_response(playlist),
89
+ status=200,
90
+ content_type="application/json",
91
+ )
92
+
93
+ async def _stop(self, _request: web.Request) -> web.StreamResponse:
94
+ self.player.stop()
95
+ return web.Response(status=204)
96
+
97
+ async def _pause(self, _request: web.Request) -> web.StreamResponse:
98
+ self.player.pause()
99
+ return web.Response(status=204)
100
+
101
+ async def _play(self, _request: web.Request) -> web.StreamResponse:
102
+ await self.player.play()
103
+ return web.Response(status=204)
104
+
105
+ async def _next(self, _request: web.Request) -> web.StreamResponse:
106
+ await self.player.next()
107
+ return web.Response(status=204)
108
+
109
+ async def _play_news(self, _request: web.Request) -> web.StreamResponse:
110
+ await self.player.next(force_news=True)
111
+ return web.Response(status=204)
112
+
113
+ async def _seek(self, request: web.Request) -> web.StreamResponse:
114
+ position = int(await request.text())
115
+ self.player.seek(position)
116
+ return web.Response(status=204)
117
+
118
+ async def _volume(self, request: web.Request) -> web.StreamResponse:
119
+ volume = int(await request.text())
120
+ self.player.set_volume(volume)
121
+ return web.Response(status=204)
122
+
123
+ async def _playlists(self, request: web.Request) -> web.StreamResponse:
124
+ playlists = cast(list[str], await request.json())
125
+ assert isinstance(playlists, list)
126
+ for playlist in playlists:
127
+ assert isinstance(playlist, str)
128
+ assert playlist in self.downloader.all_playlists
129
+ _LOGGER.info("Changed enabled playlists:", playlists)
130
+ self.downloader.enabled_playlists = playlists
131
+ return web.Response(status=204)
132
+
133
+ async def _enqueue(self, request: web.Request) -> web.StreamResponse:
134
+ await self.downloader.enqueue(await request.text())
135
+ return web.Response(status=204)
136
+
137
+ async def _play_track(self, request: web.Request) -> web.StreamResponse:
138
+ await self.downloader.enqueue(await request.text(), front=True)
139
+ await self.player.next()
140
+ return web.Response(status=204)
141
+
142
+ async def setup(self, _app: Application):
143
+ await self.client.setup(
144
+ base_url=self.config.server,
145
+ user_agent="Raphson-Music-Headless",
146
+ token=self.config.token,
147
+ )
148
+ await self.downloader.setup()
149
+ await self.player.setup()
150
+
151
+ async def cleanup(self, _app: Application) -> None:
152
+ _LOGGER.info("Stopping")
153
+ await self.client.close()
154
+ self.player.stop()
155
+
156
+ def start(self, config: Config):
157
+ routes = [
158
+ web.get("/", self._root),
159
+ web.get("/state", self._state),
160
+ web.get("/image", self._image),
161
+ web.get("/lyrics", self._lyrics),
162
+ web.get("/list_tracks", self._list_tracks),
163
+ web.post("/stop", self._stop),
164
+ web.post("/pause", self._pause),
165
+ web.post("/play", self._play),
166
+ web.post("/next", self._next),
167
+ web.post("/play_news", self._play_news),
168
+ web.post("/seek", self._seek),
169
+ web.post("/volume", self._volume),
170
+ web.post("/playlists", self._playlists),
171
+ web.post("/enqueue", self._enqueue),
172
+ web.post("/play_track", self._play_track),
173
+ ]
174
+
175
+ app = web.Application()
176
+ app.add_routes(routes)
177
+ app.on_startup.append(self.setup)
178
+ app.on_cleanup.append(self.cleanup)
179
+
180
+ web.run_app(app, host=config.host, port=config.port)