Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Multi Player Support #2164

Draft
wants to merge 127 commits into
base: future3/develop
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
127 commits
Select commit Hold shift + click to select a range
c3549eb
Asking user for spotify
Groovylein Nov 30, 2021
5aaa2d1
activate spotify installation
Groovylein Nov 30, 2021
059566d
Merge branch 'future3/develop' into future3/spotify
Groovylein Dec 15, 2021
7992662
Enable spotify installation
Groovylein Dec 16, 2021
6b89ddf
Introduce default spotify folder
Groovylein Dec 16, 2021
1890016
Added information for installation
Groovylein Dec 20, 2021
32d53af
Setup librespot-java in Docker for development
pabera Dec 20, 2021
00d1ea5
Added docker for spotify development
Groovylein Dec 21, 2021
a19382b
Improve Docker file
pabera Dec 21, 2021
98cb97d
Ignore spotify config.toml file to avoid accidental credential commit
pabera Dec 21, 2021
d1536de
Create default config.toml file
pabera Dec 21, 2021
efb3590
Ignore spotify config.toml file to avoid accidental credential commit
Groovylein Dec 22, 2021
01ff28d
Delete docker.spotify.config.toml
Groovylein Dec 24, 2021
83001f7
Merge branch 'MiczFlor:future3/spotify' into future3/spotify
Groovylein Dec 24, 2021
e365417
Merge pull request #1680 from pabera/future3/spotify
Groovylein Dec 26, 2021
19ebc60
First implementation of spotify functions: play, stop, pause, prev, n…
Groovylein Dec 26, 2021
271c634
Merge branch 'future3/spotify' into future3/spotify
Groovylein Dec 26, 2021
92135e9
Merge pull request #1 from MiczFlor/future3/spotify
Groovylein Dec 26, 2021
6646f9a
Removed SpotLock
Groovylein Dec 27, 2021
6e0a3f8
Cleanup and device_info implementation
Groovylein Dec 27, 2021
5c1b84d
Playlist and volume implemented
Groovylein Dec 28, 2021
c74ead5
Added HTTP Error handling for spotify requests
Groovylein Dec 29, 2021
cbd59de
Catch other exception for spotify api
Groovylein Dec 29, 2021
81143cb
Added headers for requests calls
Groovylein Dec 29, 2021
93fc3a9
Refactored http error handling
Groovylein Jan 11, 2022
98da122
Delete json_example.json
Groovylein Jan 11, 2022
1c505b5
Spotify websocket initialization
Groovylein Jan 12, 2022
f3eeb6e
Merge branch 'future3/develop' into future3/spotify
Groovylein Jan 14, 2022
1b4b540
Bugfixing: import, paths, asyncio
Groovylein Jan 14, 2022
998ed06
Bugfixing: URL paths
Groovylein Jan 14, 2022
ade2023
Bugfixing: wait until connection to spotify api possible
Groovylein Jan 14, 2022
2b4e666
Bugfixing: Handle device_info right
Groovylein Jan 14, 2022
29cc50a
Docker adjustments
Groovylein Jan 14, 2022
d4ddadc
Adjustment in Websocket loop
Groovylein Jan 14, 2022
e1c25a7
Use JRE instead of JDE
pabera Jan 5, 2022
2d8ca15
Outsource http and ws client into own class (ws non functional!)
pabera Jan 15, 2022
a955afb
Use websocket-client and make http-client useful
pabera Jan 16, 2022
5aef09f
Reformating code
Groovylein Jan 17, 2022
6b3b6fc
Enhance spotify websocket to publish player status
Groovylein Jan 17, 2022
9928012
Fixing bugs in Contributing.md
pabera Jan 17, 2022
cbe4749
Abstract implementation of Player Factory
pabera Jan 18, 2022
e12a53b
Update factory naming
pabera Jan 19, 2022
5bf3b40
Fix builder init
pabera Jan 19, 2022
d0f64f4
Introduce PlayerStatus class and WS client for Spotify
pabera Jan 20, 2022
f7d5dff
Show Title, Album, CoverArt in webapp, prepare controls
pabera Jan 20, 2022
a75b0d2
Introduce Player Counter on React side
pabera Jan 21, 2022
df89357
Initial proposal for new player API with multi-backend handling
Jan 22, 2022
6e64466
Flake8 fixes
Groovylein Jan 28, 2022
5847dad
Flake8 fixes #2
Groovylein Jan 28, 2022
244a0cf
Reworked multi-backend proposal + AsyncIO MPD stub
Feb 2, 2022
04fc9fc
Merge pull request #1751 from ChisSoc: AsyncIO MPD player backend
Feb 6, 2022
2bf8fbc
Implement folder and album playback
Feb 8, 2022
de9cbb7
Clean up
Feb 8, 2022
90b54c4
Merge pull request #1759 from ChisSoc/future3/newplayer
Groovylein Feb 9, 2022
665a53f
Disable autoplay
Groovylein Feb 9, 2022
8db761f
First attempt Spotify Backend
Groovylein Feb 11, 2022
55d721a
Flake 8 corrections
Groovylein Feb 11, 2022
5412b59
Read mpd host config from jukebox.yaml
pabera Feb 13, 2022
ece4c2b
[Webapp] Interim MPD player adjustment to new player backend
pabera Feb 15, 2022
d47087e
Fix album decoding from URI
Feb 19, 2022
fbe43a0
Merge pull request #1767 from ChisSoc/future3/newplayer
Feb 19, 2022
c094839
Disable autoplay
Groovylein Feb 9, 2022
ecf11dc
First attempt Spotify Backend
Groovylein Feb 11, 2022
839f1ff
Flake 8 corrections
Groovylein Feb 11, 2022
c94d5db
Merge branch 'MiczFlor-future3/spotify' into future3/spotify
Groovylein Feb 24, 2022
5e3bc6d
Simplified internal Spotify URI
Groovylein Feb 24, 2022
a118f70
bugfixing connection loss of Spotify websocket
Groovylein Feb 24, 2022
d0832c9
Merge pull request #1761 from Groovylein/future3/spotify
Groovylein Feb 24, 2022
4d3c46c
Merge branch 'future3/develop' into future3/spotify
Groovylein Apr 30, 2022
731c9a9
Bugfixing: player.ctrl.list_backends show only keys
Groovylein May 1, 2022
23a0f82
Bugfixing: spotify player
Groovylein May 23, 2022
bd8af97
prepare Docker dor spotipy
Groovylein Jan 27, 2023
aa8443d
first attempt
Groovylein Jan 27, 2023
9d059b4
First successful attempt with cached data
Groovylein Feb 1, 2023
6ec73e2
Cleanup spotify player
Groovylein Apr 5, 2023
c7a3eab
Removed spotify code from playern
Groovylein Aug 4, 2023
20d7284
fist player content core
Groovylein Nov 6, 2023
6b54f47
populate content manager
Groovylein Nov 11, 2023
ef92b71
get_location for content, mpd fixes
Groovylein Nov 13, 2023
1547a42
Merge branch 'future3/develop' into future3/multi-player
Groovylein Nov 13, 2023
c8bc8c0
cleanup old player
Groovylein Nov 13, 2023
d0e851f
Update docker env
Groovylein Nov 18, 2023
fd04413
update from upstream
Groovylein Nov 18, 2023
aaa935a
update from upstream
Groovylein Nov 18, 2023
b910210
further update from upstream
Groovylein Nov 18, 2023
86e734e
correct flake8
Groovylein Nov 18, 2023
36bfd79
Fix docker; delete copy-paste-error
Groovylein Nov 18, 2023
b8fd417
Syntax error
Groovylein Nov 18, 2023
9006d6f
Added inheritance class for players
Groovylein Nov 18, 2023
5784f8d
Added missing functions for webapp
Groovylein Nov 18, 2023
3b4f448
first attempt for get_folder_content
Groovylein Dec 18, 2023
efbe969
Merge branch 'future3/develop' into future3/multi-player
Groovylein Dec 18, 2023
e85b294
Bring back spotify backend
Groovylein Dec 18, 2023
7a0d170
remove unnecessary files
Groovylein Dec 18, 2023
257d177
fix auth error during pip install in docker
Groovylein Dec 18, 2023
90cc33b
Docker bugfixing
Groovylein Dec 18, 2023
d43c6fe
implement Spotipy authentication via bottle
Groovylein Dec 21, 2023
e3ea953
Docker container for spotifyd with pulseaudio; Still problems
Groovylein Dec 28, 2023
e0faa44
Installation of spotifyd
Groovylein Jan 3, 2024
3d7195d
correct path of spotifyd config
Groovylein Jan 3, 2024
0deda1e
corrected relative path in jukebox yaml
Groovylein Jan 3, 2024
9a506f0
prepare for client_id, client_secret
Groovylein Jan 3, 2024
af3b281
extract player config into new file
Groovylein Jan 5, 2024
3e0c94a
make use of the new player.yaml
Groovylein Jan 5, 2024
8d65be3
create first basic documentation how to obtain client_id/client_secret
Groovylein Jan 5, 2024
bb24d8a
enhance installation method with client_id and client_secret
Groovylein Jan 5, 2024
f34815d
Adjust CI to handle spotify installation
Groovylein Jan 5, 2024
d24e16a
bugfixing spotifyd daemon
Groovylein Jan 6, 2024
190aaf7
Review changes
Groovylein Jan 8, 2024
2eccf39
Review changes #2
Groovylein Jan 8, 2024
633449e
Merge branch 'future3/develop' into future3/multi-player
Groovylein Jan 8, 2024
3510085
flake 8 fixes
Groovylein Jan 8, 2024
3aedd9c
reverted all spotify code; focus on multi-player
Groovylein Jan 10, 2024
8cb99d1
added missing functions to ABC
Groovylein Jan 13, 2024
afcafd1
cleanup old player calls for better development
Groovylein Jan 14, 2024
8be8ade
introduce necessary methods from abstract class
Groovylein Jan 14, 2024
e5ae214
enable read from new yaml file player.yaml
Groovylein Jan 22, 2024
3edfa67
display folder content in webapp
Groovylein Jan 31, 2024
c8f809b
enable playing from folder view
Groovylein Jan 31, 2024
fcf2ced
Make library available on webapp
Groovylein Feb 27, 2024
d38dc0c
Publish playerstatus, so that buttons work on the player
Groovylein Mar 11, 2024
d880d46
Merge branch 'future3/develop' into future3/multi-player
Groovylein Apr 9, 2024
ba72a18
Revert some changes
Groovylein Apr 16, 2024
8169592
transfer coverart_cache_manager
Groovylein Apr 23, 2024
b4ff177
<UNDER CONSTRUCTION> Status publishing via multitimer
Groovylein Apr 23, 2024
83692f1
<UNDER CONSTRUCTION> Correct status call in webapp
Groovylein May 2, 2024
42c15f4
Merge branch 'future3/develop' into future3/multi-player
Groovylein May 2, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 18 additions & 5 deletions src/jukebox/components/player/backends/mpd/interfacing_mpd.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import logging
import os.path
import re
from pathlib import Path
from typing import Optional

import jukebox.plugs as plugin
Expand All @@ -13,6 +14,7 @@

from mpd.asyncio import MPDClient
from components.player.backends import BackendPlayer
from components.player.core.coverart_cache_manager import CoverartCacheManager
from jukebox import publishing

logger = logging.getLogger('jb.mpd')
Expand All @@ -36,6 +38,7 @@ def __init__(self, event_loop):
self.loop = event_loop
self.host = cfg.setndefault('playermpd', 'host', value='localhost')
self.port = cfg.setndefault('playermpd', 'port', value='6600')
self.coverart_cache_manager = CoverartCacheManager()
self._flavors = {'folder': self.get_files,
'file': self.get_track,
'album': self.get_album_from_uri,
Expand All @@ -45,7 +48,7 @@ def __init__(self, event_loop):
# TODO: If connect fails on first try this is non recoverable
self.connect()
# Start the status listener in an endless loop in the event loop
asyncio.run_coroutine_threadsafe(self._status_listener(), self.loop)
# asyncio.run_coroutine_threadsafe(self._status_listener(), self.loop)

# ------------------------------------------------------------------------------------------------------
# Bring calls to client functions from the synchronous part into the async domain
Expand Down Expand Up @@ -92,21 +95,28 @@ async def _status_listener(self):
# logger.debug("MPD: Idle change in", subsystem)
s = await self.client.status()
# logger.debug(f"MPD: New Status: {s.result()}")
print(f"MPD: New Status: {type(s)} // {s}")
#print(f"MPD: New Status: {type(s)} // {s}")
# Now, do something with it ...
publishing.get_publisher().send('playerstatus', s)


async def _status(self):
return await self.client.status()

@plugin.tag
def status(self):
"""Refresh the current MPD status (by a manual, sync trigger)"""
# Example
# Status: {'volume': '40', 'repeat': '0', 'random': '0', 'single': '0', 'consume': '0', 'partition': 'default',
# 'playlist': '94', 'playlistlength': '22', 'mixrampdb': '0.000000', 'state': 'play', 'song': '0',
# 'songid': '71', 'time': '1:126', 'elapsed': '1.108', 'bitrate': '96', 'duration': '125.988',
# 'audio': '44100:24:2', 'nextsong': '1', 'nextsongid': '72'}
f = asyncio.run_coroutine_threadsafe(self._status(), self.loop).result()
print(f"Status: {f}")
# print(f"Status: {f}")
# Put it into unified structure and notify global player control
# ToDo: propagate to core player
publishing.get_publisher().send('playerstatus', f)
# publishing.get_publisher().send('playerstatus', f)
return f

# -----------------------------------------------------
# Stuff that controls current playback (i.e. moves around in the current playlist, termed "the queue")
Expand Down Expand Up @@ -295,7 +305,10 @@ def get_album_from_uri(self, uri: str):
return self.get_album_tracks(album_artist=p.group(4), album=p.group(3))

def get_single_coverart(self, song_url):
pass
mp3_file_path = Path(get_music_library_path(), song_url).expanduser()
cache_filename = self.coverart_cache_manager.get_cache_filename(mp3_file_path)

return cache_filename

def get_album_coverart(self):
pass
Expand Down
24 changes: 24 additions & 0 deletions src/jukebox/components/player/core/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
from typing import Dict, Callable, Optional, Any

import jukebox.plugs as plugin
from components.player.core.player_status import PlayerStatus
from jukebox import multitimer

logger = logging.getLogger('jb.player')

Expand All @@ -29,12 +31,23 @@ class PlayerCtrl:
def __init__(self):
self._backends: Dict[str, Any] = {}
self._active = None
self.player_status = None
self.status_poll_interval = 0.25
self.status_thread = multitimer.GenericEndlessTimerClass('player.timer_status',
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like this! The Timer Classes are pretty well done and can be used for such use cases nicely!

self.status_poll_interval, self._status_poll)
self.status_thread.start()

def _status_poll(self):
ret_status = self._active.status()
if ret_status.get('state') == 'play':
self.player_status.update(playing=True, elapsed=ret_status.get('elapsed', '0.0'), duration=ret_status.get('duration', '0.0') )

def register(self, name: str, backend):
self._backends[name] = backend
# For now simply default to first registered backend
if self._active is None:
self._active = self._backends.values().__iter__().__next__()
self.player_status.update(player=name)

@plugin.tag
def get_active(self):
Expand Down Expand Up @@ -64,6 +77,7 @@ def play_uri(self, uri, check_second_swipe=False, **kwargs):
if inst is None:
raise KeyError(f"URI player type unknown: '{player_type}'. Available backends are: {self._backends.keys()}.")
self._active = self._backends.get(player_type)
self.player_status.update(player=player_type)
self._active.play_uri(uri, **kwargs)

def _is_second_swipe(self):
Expand Down Expand Up @@ -106,22 +120,30 @@ def prev(self):
@plugin.tag
def play(self):
self._active.play()
self.player_status.update(playing=True)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks rather annoying to add self.player_status.update(playing=True/False) everywhere. You could use a decorator approach, maybe even in the BackendPlayer class (to be verified).

We already use decorators with @plugin.tag.


@plugin.tag
def play_single(self, uri):
self.play_uri(uri)
self.player_status.update(playing=True)

@plugin.tag
def play_album(self, albumartist, album):
self._active.play_album(albumartist, album)
self.player_status.update(playing=True)

@plugin.tag
def play_folder(self, folder, recursive):
self._active.play_folder(folder, recursive)
self.player_status.update(playing=True)

@plugin.tag
def toggle(self):
self._active.toggle()
if self.player_status.get_value('playing') is False:
self.player_status.update(playing=True)
else:
self.player_status.update(playing=False)

@plugin.tag
def shuffle(self, option='toggle'):
Expand All @@ -133,12 +155,14 @@ def shuffle(self, option='toggle'):
@plugin.tag
def pause(self):
self._active.pause()
self.player_status.update(playing=False)

@plugin.tag
def stop(self):
# Save current state for resume functionality
self._save_state()
self._active.stop()
self.player_status.update(playing=False)

@plugin.tag
def get_queue(self):
Expand Down
90 changes: 90 additions & 0 deletions src/jukebox/components/player/core/coverart_cache_manager.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
from mutagen.mp3 import MP3
from mutagen.id3 import ID3, APIC
from pathlib import Path
import hashlib
import logging
from queue import Queue
from threading import Thread
import jukebox.cfghandler

COVER_PREFIX = 'cover'
NO_COVER_ART_EXTENSION = 'no-art'
NO_CACHE = ''
CACHE_PENDING = 'CACHE_PENDING'

logger = logging.getLogger('jb.CoverartCacheManager')
cfg = jukebox.cfghandler.get_handler('jukebox')


class CoverartCacheManager:
def __init__(self):
coverart_cache_path = cfg.setndefault('webapp', 'coverart_cache_path', value='../../src/webapp/build/cover-cache')
self.cache_folder_path = Path(coverart_cache_path).expanduser()
self.write_queue = Queue()
self.worker_thread = Thread(target=self.process_write_requests)
self.worker_thread.daemon = True # Ensure the thread closes with the program
self.worker_thread.start()

def generate_cache_key(self, base_filename: str) -> str:
return f"{COVER_PREFIX}-{hashlib.sha256(base_filename.encode()).hexdigest()}"

def get_cache_filename(self, mp3_file_path: str) -> str:
base_filename = Path(mp3_file_path).stem
cache_key = self.generate_cache_key(base_filename)

for path in self.cache_folder_path.iterdir():
if path.stem == cache_key:
if path.suffix == f".{NO_COVER_ART_EXTENSION}":
return NO_CACHE
return path.name

self.save_to_cache(mp3_file_path)
return CACHE_PENDING

def save_to_cache(self, mp3_file_path: str):
self.write_queue.put(mp3_file_path)

def _save_to_cache(self, mp3_file_path: str):
base_filename = Path(mp3_file_path).stem
cache_key = self.generate_cache_key(base_filename)
file_extension, data = self._extract_album_art(mp3_file_path)

cache_filename = f"{cache_key}.{file_extension}"
full_path = self.cache_folder_path / cache_filename # Works due to Pathlib

with full_path.open('wb') as file:
file.write(data)
logger.debug(f"Created file: {cache_filename}")

return cache_filename

def _extract_album_art(self, mp3_file_path: str) -> tuple:
try:
audio_file = MP3(mp3_file_path, ID3=ID3)
except Exception as e:
logger.error(f"Error reading MP3 file {mp3_file_path}: {e}")
return (NO_COVER_ART_EXTENSION, b'')

for tag in audio_file.tags.values():
if isinstance(tag, APIC):
mime_type = tag.mime
file_extension = 'jpg' if mime_type == 'image/jpeg' else mime_type.split('/')[-1]
return (file_extension, tag.data)

return (NO_COVER_ART_EXTENSION, b'')

def process_write_requests(self):
while True:
mp3_file_path = self.write_queue.get()
try:
self._save_to_cache(mp3_file_path)
except Exception as e:
logger.error(f"Error processing write request: {e}")
self.write_queue.task_done()

def flush_cache(self):
for path in self.cache_folder_path.iterdir():
if path.is_file():
path.unlink()
logger.debug(f"Deleted cached file: {path.name}")
logger.info("Cache flushed successfully.")
5 changes: 4 additions & 1 deletion src/jukebox/components/player/core/player_status.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import logging

import jukebox.plugs as plugin
from jukebox import publishing
from jukebox import publishing, multitimer

logger = logging.getLogger('jb.player')

Expand Down Expand Up @@ -33,6 +33,9 @@ def update(self, **kwargs):

self.publish()

def get_value(self, key):
return self.STATUS.get(key)

def publish(self):
logger.debug(f'Published: {self._player_status}')
return publishing.get_publisher().send(
Expand Down
2 changes: 1 addition & 1 deletion src/jukebox/components/player/plugin/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ def initialize():
player_arbiter = PlayerCtrl()

player_status = PlayerStatus()
player_status.publish()
player_arbiter.player_status = player_status

# ToDo: remove player_content
# player_content = PlayerData()
Expand Down