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.
- raphson_music_headless-1.0.0/.gitignore +2 -0
- raphson_music_headless-1.0.0/CHANGES.md +5 -0
- raphson_music_headless-1.0.0/PKG-INFO +77 -0
- raphson_music_headless-1.0.0/README.md +54 -0
- raphson_music_headless-1.0.0/config.example.json +9 -0
- raphson_music_headless-1.0.0/deploy-pypi.sh +5 -0
- raphson_music_headless-1.0.0/docs/API.md +82 -0
- raphson_music_headless-1.0.0/pyproject.toml +42 -0
- raphson_music_headless-1.0.0/raphson_music_headless/__init__.py +0 -0
- raphson_music_headless-1.0.0/raphson_music_headless/__main__.py +11 -0
- raphson_music_headless-1.0.0/raphson_music_headless/config.py +23 -0
- raphson_music_headless-1.0.0/raphson_music_headless/downloader.py +132 -0
- raphson_music_headless-1.0.0/raphson_music_headless/index.html +177 -0
- raphson_music_headless-1.0.0/raphson_music_headless/player.py +178 -0
- raphson_music_headless-1.0.0/raphson_music_headless/server.py +180 -0
|
@@ -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,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
|
|
File without changes
|
|
@@ -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)
|