Skip to content

Commit

Permalink
Cover Art with Cache (MiczFlor#2177)
Browse files Browse the repository at this point in the history
* CoverArt with Cache in Docker, Cache Path on Pi missing

* Make cache path available in both Docker and Pi

* Fix flake8 errors
  • Loading branch information
pabera authored and AlvinSchiller committed Jan 9, 2024
1 parent 76ca98c commit e18ab88
Show file tree
Hide file tree
Showing 12 changed files with 119 additions and 53 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
/shared/logs/*.log*
/shared/*.*
/shared/*
/src/webapp/public/cover-cache/*.*

# Application
/src/cli_client/pbc
Expand Down
1 change: 1 addition & 0 deletions docker/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ pyalsaaudio
pulsectl
python-mpd2
ruamel.yaml
python-slugify
# For playlistgenerator
requests
# For the publisher event reactor loop:
Expand Down
3 changes: 2 additions & 1 deletion resources/default-settings/jukebox.default.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
30 changes: 0 additions & 30 deletions src/jukebox/components/music_cover_art/__init__.py

This file was deleted.

59 changes: 54 additions & 5 deletions src/jukebox/components/playermpd/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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')
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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):
"""
Expand Down Expand Up @@ -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):
Expand Down
22 changes: 22 additions & 0 deletions src/jukebox/components/playermpd/coverart_cache_manager.py
Original file line number Diff line number Diff line change
@@ -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
Empty file.
13 changes: 9 additions & 4 deletions src/webapp/src/commands/index.js
Original file line number Diff line number Diff line change
@@ -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',
Expand All @@ -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',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { forwardRef } from 'react';
import React, { forwardRef, useEffect, useState } from 'react';
import {
Link,
useLocation,
Expand All @@ -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;
Expand All @@ -41,7 +60,7 @@ const AlbumListItem = ({ albumartist, album, isButton = true }) => {
>
<ListItemButton>
<ListItemAvatar>
<Avatar variant="rounded" alt="Cover" src={noCover} />
<Avatar variant="rounded" alt="Cover" src={coverImage} />
</ListItemAvatar>
<ListItemText
primary={album || t('library.albums.unknown-album')}
Expand Down
2 changes: 1 addition & 1 deletion src/webapp/src/components/Player/cover.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ const Cover = ({ coverImage }) => {
{coverImage &&
<img
alt={t('player.cover.title')}
src={`data:image/jpeg;base64,${coverImage}`}
src={coverImage}
style={{ width: '100%', height: '100%' }}
/>}
{!coverImage &&
Expand Down
17 changes: 7 additions & 10 deletions src/webapp/src/components/Player/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Grid
Expand Down

0 comments on commit e18ab88

Please sign in to comment.