diff --git a/.gitignore b/.gitignore index 9d38e1b8c..c715a314b 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ /shared/logs/*.log* /shared/*.* /shared/* +/src/webapp/public/cover-cache/*.* # Application /src/cli_client/pbc diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 1679bbeb5..03191dbd5 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -41,6 +41,7 @@ services: tty: true volumes: - ../src/jukebox:/root/RPi-Jukebox-RFID/src/jukebox + - ../src/webapp/public/cover-cache:/root/RPi-Jukebox-RFID/src/webapp/build/cover-cache - ../shared:/root/RPi-Jukebox-RFID/shared - ./config/docker.pulse.mpd.conf:/root/.config/mpd/mpd.conf command: python run_jukebox.py diff --git a/requirements.txt b/requirements.txt index bd2ea6651..ea9546315 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,6 +14,7 @@ pyalsaaudio pulsectl python-mpd2 ruamel.yaml +python-slugify # For playlistgenerator requests # For the publisher event reactor loop: diff --git a/resources/default-settings/jukebox.default.yaml b/resources/default-settings/jukebox.default.yaml index 36661c992..5c37f178a 100644 --- a/resources/default-settings/jukebox.default.yaml +++ b/resources/default-settings/jukebox.default.yaml @@ -20,7 +20,6 @@ modules: gpio: gpio.gpioz.plugin sync_rfidcards: synchronisation.rfidcards others: - - music_cover_art - misc pulse: # Reset system volume to this level after start. (Comment out disables and volume is not changed) @@ -146,3 +145,5 @@ speaking_text: sync_rfidcards: enable: false config_file: ../../shared/settings/sync_rfidcards.yaml +webapp: + coverart_cache_path: ../../src/webapp/build/cover-cache diff --git a/src/jukebox/components/music_cover_art/__init__.py b/src/jukebox/components/music_cover_art/__init__.py deleted file mode 100755 index c07f9441e..000000000 --- a/src/jukebox/components/music_cover_art/__init__.py +++ /dev/null @@ -1,30 +0,0 @@ -""" -Read all cover art from music save it to a cache for the UI to load - -.. note:: Not implemented. This is a feature planned for a future release. -""" -import logging -import jukebox.cfghandler -import jukebox.plugs as plugin - -logger = logging.getLogger('jb.music_cover_art') -cfg = jukebox.cfghandler.get_handler('jukebox') - - -class MusicCoverArt: - def __init__(self): - pass - - @plugin.tag - def get_by_filename_as_base64(self, audio_src: str): - """ - Not implemented. This is a feature planned for a future release. - """ - cover_base64_string = '' - return cover_base64_string - - -@plugin.initialize -def initialize(): - music_cover_art = MusicCoverArt() - plugin.register(music_cover_art, name='ctrl') diff --git a/src/jukebox/components/playermpd/__init__.py b/src/jukebox/components/playermpd/__init__.py index 3975fdb67..e854ee5ee 100644 --- a/src/jukebox/components/playermpd/__init__.py +++ b/src/jukebox/components/playermpd/__init__.py @@ -86,6 +86,7 @@ import logging import time import functools +from slugify import slugify import components.player import jukebox.cfghandler import jukebox.utils as utils @@ -97,6 +98,7 @@ from jukebox.NvManager import nv_manager from .playcontentcallback import PlayContentCallbacks, PlayCardState +from .coverart_cache_manager import CoverartCacheManager logger = logging.getLogger('jb.PlayerMPD') cfg = jukebox.cfghandler.get_handler('jukebox') @@ -154,6 +156,10 @@ def __init__(self): self.decode_2nd_swipe_option() self.mpd_client = mpd.MPDClient() + + coverart_cache_path = cfg.getn('webapp', 'coverart_cache_path') + self.coverart_cache_manager = CoverartCacheManager(os.path.expanduser(coverart_cache_path)) + # The timeout refer to the low-level socket time-out # If these are too short and the response is not fast enough (due to the PI being busy), # the current MPC command times out. Leave these at blocking calls, since we do not react on a timed out socket @@ -464,6 +470,49 @@ def play_card(self, folder: str, recursive: bool = False): self.play_folder(folder, recursive) + @plugs.tag + def get_single_coverart(self, song_url): + """ + Saves the album art image to a cache and returns the filename. + """ + base_filename = slugify(song_url) + + try: + metadata_list = self.mpd_client.listallinfo(song_url) + metadata = {} + if metadata_list: + metadata = metadata_list[0] + + if 'albumartist' in metadata and 'album' in metadata: + base_filename = slugify(f"{metadata['albumartist']}-{metadata['album']}") + + cache_filename = self.coverart_cache_manager.find_file_by_hash(base_filename) + + if cache_filename: + return cache_filename + + # Cache file does not exist + # Fetch cover art binary + album_art_data = self.mpd_client.readpicture(song_url) + + # Save to cache + cache_filename = self.coverart_cache_manager.save_to_cache(base_filename, album_art_data) + + return cache_filename + + except mpd.base.CommandError as e: + logger.error(f"{e.__class__.__qualname__}: {e} at uri {song_url}") + except Exception as e: + logger.error(f"{e.__class__.__qualname__}: {e} at uri {song_url}") + + return "" + + @plugs.tag + def get_album_coverart(self, albumartist: str, album: str): + song_list = self.list_songs_by_artist_and_album(albumartist, album) + + return self.get_single_coverart(song_list[0]['file']) + @plugs.tag def get_folder_content(self, folder: str): """ @@ -562,16 +611,16 @@ def list_all_dirs(self): @plugs.tag def list_albums(self): with self.mpd_lock: - albums = self.mpd_retry_with_mutex(self.mpd_client.list, 'album', 'group', 'albumartist') + album_list = self.mpd_retry_with_mutex(self.mpd_client.list, 'album', 'group', 'albumartist') - return albums + return album_list @plugs.tag - def list_song_by_artist_and_album(self, albumartist, album): + def list_songs_by_artist_and_album(self, albumartist, album): with self.mpd_lock: - albums = self.mpd_retry_with_mutex(self.mpd_client.find, 'albumartist', albumartist, 'album', album) + song_list = self.mpd_retry_with_mutex(self.mpd_client.find, 'albumartist', albumartist, 'album', album) - return albums + return song_list @plugs.tag def get_song_by_url(self, song_url): diff --git a/src/jukebox/components/playermpd/coverart_cache_manager.py b/src/jukebox/components/playermpd/coverart_cache_manager.py new file mode 100644 index 000000000..7883372ba --- /dev/null +++ b/src/jukebox/components/playermpd/coverart_cache_manager.py @@ -0,0 +1,22 @@ +import os + + +class CoverartCacheManager: + def __init__(self, cache_folder_path): + self.cache_folder_path = cache_folder_path + + def find_file_by_hash(self, hash_value): + for filename in os.listdir(self.cache_folder_path): + if filename.startswith(hash_value): + return filename + return None + + def save_to_cache(self, base_filename, album_art_data): + mime_type = album_art_data['type'] + file_extension = 'jpg' if mime_type == 'image/jpeg' else mime_type.split('/')[-1] + cache_filename = f"{base_filename}.{file_extension}" + + with open(os.path.join(self.cache_folder_path, cache_filename), 'wb') as file: + file.write(album_art_data['binary']) + + return cache_filename diff --git a/src/webapp/public/cover-cache/.gitkeep b/src/webapp/public/cover-cache/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/src/webapp/src/commands/index.js b/src/webapp/src/commands/index.js index bd8fd782e..2352e46b7 100644 --- a/src/webapp/src/commands/index.js +++ b/src/webapp/src/commands/index.js @@ -1,8 +1,13 @@ const commands = { - musicCoverByFilenameAsBase64: { - _package: 'music_cover_art', + getSingleCoverArt: { + _package: 'player', + plugin: 'ctrl', + method: 'get_single_coverart', + }, + getAlbumCoverArt: { + _package: 'player', plugin: 'ctrl', - method: 'get_by_filename_as_base64', + method: 'get_album_coverart', }, directoryTreeOfAudiofolder: { _package: 'player', @@ -17,7 +22,7 @@ const commands = { songList: { _package: 'player', plugin: 'ctrl', - method: 'list_song_by_artist_and_album', + method: 'list_songs_by_artist_and_album', }, getSongByUrl: { _package: 'player', diff --git a/src/webapp/src/components/Library/lists/albums/album-list/album-list-item.js b/src/webapp/src/components/Library/lists/albums/album-list/album-list-item.js index 8a6c54abf..75882dd0d 100644 --- a/src/webapp/src/components/Library/lists/albums/album-list/album-list-item.js +++ b/src/webapp/src/components/Library/lists/albums/album-list/album-list-item.js @@ -1,4 +1,4 @@ -import React, { forwardRef } from 'react'; +import React, { forwardRef, useEffect, useState } from 'react'; import { Link, useLocation, @@ -15,9 +15,28 @@ import { import noCover from '../../../../../assets/noCover.jpg'; +import request from '../../../../../utils/request'; + const AlbumListItem = ({ albumartist, album, isButton = true }) => { const { t } = useTranslation(); const { search: urlSearch } = useLocation(); + const [coverImage, setCoverImage] = useState(noCover); + + useEffect(() => { + const getCoverArt = async () => { + const { result } = await request('getAlbumCoverArt', { + albumartist: albumartist, + album: album + }); + if (result) { + setCoverImage(`/cover-cache/${result}`); + }; + } + + if (albumartist && album) { + getCoverArt(); + } + }, [albumartist, album]); const AlbumLink = forwardRef((props, ref) => { const { data } = props; @@ -41,7 +60,7 @@ const AlbumListItem = ({ albumartist, album, isButton = true }) => { > - + { {coverImage && {t('player.cover.title')}} {!coverImage && diff --git a/src/webapp/src/components/Player/index.js b/src/webapp/src/components/Player/index.js index 5efa5e10a..2d56adceb 100644 --- a/src/webapp/src/components/Player/index.js +++ b/src/webapp/src/components/Player/index.js @@ -9,34 +9,31 @@ import SeekBar from './seekbar'; import Volume from './volume'; import PlayerContext from '../../context/player/context'; -import PubSubContext from '../../context/pubsub/context'; import request from '../../utils/request'; -import { pluginIsLoaded } from '../../utils/utils'; const Player = () => { const { state: { playerstatus } } = useContext(PlayerContext); - const { state: { 'core.plugins.loaded': plugins } } = useContext(PubSubContext); const { file } = playerstatus || {}; const [coverImage, setCoverImage] = useState(undefined); const [backgroundImage, setBackgroundImage] = useState('none'); useEffect(() => { - const getMusicCover = async () => { - const { result } = await request('musicCoverByFilenameAsBase64', { audio_src: file }); + const getCoverArt = async () => { + const { result } = await request('getSingleCoverArt', { song_url: file }); if (result) { - setCoverImage(result); + setCoverImage(`/cover-cache/${result}`); setBackgroundImage([ 'linear-gradient(to bottom, rgba(18, 18, 18, 0.7), rgba(18, 18, 18, 1))', - `url(data:image/jpeg;base64,${result})` + `url(/cover-cache/${result})` ].join(',')); }; } - if (pluginIsLoaded(plugins, 'music_cover_art') && file) { - getMusicCover(); + if (file) { + getCoverArt(); } - }, [file, plugins]); + }, [file]); return (