Skip to content

Commit

Permalink
feat: Allow to enable/disable Cover Art in Web App & load from filesy…
Browse files Browse the repository at this point in the history
…stem (#2352)

* feat: Introduce Web App Settings

* feat: Allow to enable/disable Cover Art in Web App

* fix: handle Falsy mimetype and data even when APIC tag has been found my mutagen

* feat: Try to load cover from filesystem when not found in audio file

* fix: flake8 linting error

* docs: Add documentation for Cover Art

* feat: Allow show_covers setting to be managed in Web App

* fix: again flake8 linting errors
  • Loading branch information
pabera authored Apr 21, 2024
1 parent 6003682 commit aee32ff
Show file tree
Hide file tree
Showing 16 changed files with 283 additions and 19 deletions.
2 changes: 2 additions & 0 deletions documentation/builders/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@

## Web Application

* Application
* [Cover Art](./webapp/cover-art.md)
* Music
* [Playlists, Livestreams and Podcasts](./webapp/playlists-livestreams-podcasts.md)

Expand Down
37 changes: 37 additions & 0 deletions documentation/builders/webapp/cover-art.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Cover Art

## Enable/Disable Cover Art

The Web App automatically searches for cover art for albums and songs. If it finds cover art, it displays it; if not, it shows a placeholder image. However, you may prefer to disable cover art (e.g. in situations where device performance is low; screen space is limited; etc). There are two ways to do this:

1. **Web App Settings**: Go to the "Settings" tab. Under the "General" section, find and toggle the "Show Cover Art" option.
1. **Configuration File**: Open the `jukebox.yaml` file. Navigate to `webapp` -> `show_covers`. Set this value to `true` to enable or `false` to disable cover art display. If this option does not exist, it assumes `true` as a default.

## Providing Additional Cover Art

Cover art can be provided in two ways: 1) embedded within the audio file itself, or 2) as a separate image file in the same directory as the audio file. The software searches for cover art in the order listed.

To add cover art using the file system, place a file named `cover.jpg` in the same folder as your audio file or album. Accepted image file types are `jpg` and `png`.

### Example

Suppose none of your files currently include embedded cover art, the example below demonstrates how to enable cover art for an entire folder, applying the same cover art to all files within that folder.

> [!IMPORTANT]
> You cannot assign different cover arts to different tracks within the same folder.
#### Example Folder Structure

```text
└── audiofolders
├── Simone Sommerland
│ ├── 01 Aramsamsam.mp3
│ ├── 02 Das Rote Pferd.mp3
│ ├── 03 Hoch am Himmel.mp3
│ └── cover.jpg <- Cover Art file as JPG
└── Bibi und Tina
├── 01 Bibi und Tina Song.mp3
├── 02 Alles geht.mp3
├── 03 Solange dein Herz spricht.mp3
└── cover.png <- Cover Art file as PNG
```
4 changes: 4 additions & 0 deletions resources/default-settings/jukebox.default.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -153,3 +153,7 @@ sync_rfidcards:
config_file: ../../shared/settings/sync_rfidcards.yaml
webapp:
coverart_cache_path: ../../src/webapp/build/cover-cache
# Load cover arts in Webapp. Change to false in case you have performance issue
# when handling a lot of music
# Defaults to true
show_covers: true
19 changes: 19 additions & 0 deletions src/jukebox/components/misc.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@
import jukebox.plugs as plugin
import jukebox.utils
from jukebox.daemon import get_jukebox_daemon
import jukebox.cfghandler

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


@plugin.register
Expand Down Expand Up @@ -105,3 +107,20 @@ def empty_rpc_call(msg: str = ''):
"""
if msg:
logger.warning(msg)


@plugin.register
def get_app_settings():
"""Return settings for web app stored in jukebox.yaml"""
show_covers = cfg.setndefault('webapp', 'show_covers', value=True)

return {
'show_covers': show_covers
}


@plugin.register
def set_app_settings(settings={}):
"""Set configuration settings for the web app."""
for key, value in settings.items():
cfg.setn('webapp', key, value=value)
23 changes: 20 additions & 3 deletions src/jukebox/components/playermpd/coverart_cache_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,10 @@ def save_to_cache(self, mp3_file_path: str):
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)
if file_extension == NO_COVER_ART_EXTENSION: # Check if cover has been added as separate file in folder
file_extension, data = self._get_from_filesystem(mp3_file_path)

cache_filename = f"{cache_key}.{file_extension}"
full_path = self.cache_folder_path / cache_filename # Works due to Pathlib
Expand All @@ -67,9 +70,23 @@ def _extract_album_art(self, mp3_file_path: str) -> tuple:

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)
if tag.mime and tag.data:
file_extension = 'jpg' if tag.mime == 'image/jpeg' else tag.mime.split('/')[-1]
return (file_extension, tag.data)

return (NO_COVER_ART_EXTENSION, b'')

def _get_from_filesystem(self, mp3_file_path: str) -> tuple:
path = Path(mp3_file_path)
directory = path.parent
cover_files = list(directory.glob('Cover.*')) + list(directory.glob('cover.*'))

for file in cover_files:
if file.suffix.lower() in ['.jpg', '.jpeg', '.png']:
with file.open('rb') as img_file:
data = img_file.read()
file_extension = file.suffix[1:] # Get extension without dot
return (file_extension, data)

return (NO_COVER_ART_EXTENSION, b'')

Expand Down
6 changes: 6 additions & 0 deletions src/webapp/public/locales/de/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,12 @@
"why": "Warum?",
"control-label": "Auto Hotspot"
},
"general": {
"title": "Allgmeine Einstellungen",
"show_covers": {
"title": "Cover anzeigen"
}
},
"timers": {
"option-label-timeslot": "{{value}} min",
"option-label-off": "Aus",
Expand Down
6 changes: 6 additions & 0 deletions src/webapp/public/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,12 @@
"why": "Why?",
"control-label": "Auto Hotspot"
},
"general": {
"title": "General Settings",
"show_covers": {
"title": "Show Cover Art"
}
},
"timers": {
"option-label-timeslot": "{{value}} min",
"option-label-off": "Off",
Expand Down
21 changes: 12 additions & 9 deletions src/webapp/src/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React, { Suspense } from 'react';

import Grid from '@mui/material/Grid';

import AppSettingsProvider from './context/appsettings';
import PubSubProvider from './context/pubsub';
import PlayerProvider from './context/player';
import Router from './router';
Expand All @@ -10,15 +11,17 @@ function App() {
return (
<PubSubProvider>
<PlayerProvider>
<Grid
alignItems="center"
container
direction="row"
id="routes"
justifyContent="center"
>
<Router />
</Grid>
<AppSettingsProvider>
<Grid
alignItems="center"
container
direction="row"
id="routes"
justifyContent="center"
>
<Router />
</Grid>
</AppSettingsProvider>
</PlayerProvider>
</PubSubProvider>
);
Expand Down
13 changes: 13 additions & 0 deletions src/webapp/src/commands/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ const commands = {
_package: 'volume',
plugin: 'ctrl',
method: 'set_volume',
argKeys: ['volume'],
},
getVolume: {
_package: 'volume',
Expand Down Expand Up @@ -250,6 +251,18 @@ const commands = {
argKeys: ['option'],
},

// Misc
getAppSettings: {
_package: 'misc',
plugin: 'get_app_settings'
},

setAppSettings: {
_package: 'misc',
plugin: 'set_app_settings',
argKeys: ['settings'],
},

// Synchronisation
'sync_rfidcards_all': {
_package: 'sync_rfidcards',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { forwardRef, useEffect, useState } from 'react';
import React, { forwardRef, useContext, useEffect, useState } from 'react';
import {
Link,
useLocation,
Expand All @@ -15,13 +15,22 @@ import {

import noCover from '../../../../../assets/noCover.jpg';

import AppSettingsContext from '../../../../../context/appsettings/context';
import request from '../../../../../utils/request';

const AlbumListItem = ({ albumartist, album, isButton = true }) => {
const { t } = useTranslation();
const { search: urlSearch } = useLocation();
const [coverImage, setCoverImage] = useState(noCover);

const {
settings,
} = useContext(AppSettingsContext);

const {
show_covers,
} = settings;

useEffect(() => {
const getCoverArt = async () => {
const { result } = await request('getAlbumCoverArt', {
Expand All @@ -35,7 +44,7 @@ const AlbumListItem = ({ albumartist, album, isButton = true }) => {
};
}

if (albumartist && album) {
if (albumartist && album && show_covers) {
getCoverArt();
}
}, [albumartist, album]);
Expand All @@ -61,9 +70,11 @@ const AlbumListItem = ({ albumartist, album, isButton = true }) => {
key={album}
>
<ListItemButton>
<ListItemAvatar>
<Avatar variant="rounded" alt="Cover" src={coverImage} />
</ListItemAvatar>
{show_covers &&
<ListItemAvatar>
<Avatar variant="rounded" alt="Cover" src={coverImage} />
</ListItemAvatar>
}
<ListItemText
primary={album || t('library.albums.unknown-album')}
secondary={albumartist || null}
Expand Down
9 changes: 8 additions & 1 deletion src/webapp/src/components/Player/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import Display from './display';
import SeekBar from './seekbar';
import Volume from './volume';

import AppSettingsContext from '../../context/appsettings/context';
import PlayerContext from '../../context/player/context';
import request from '../../utils/request';

Expand All @@ -18,6 +19,12 @@ const Player = () => {
const [coverImage, setCoverImage] = useState(undefined);
const [backgroundImage, setBackgroundImage] = useState('none');

const {
settings,
} = useContext(AppSettingsContext);

const { show_covers } = settings;

useEffect(() => {
const getCoverArt = async () => {
const { result } = await request('getSingleCoverArt', { song_url: file });
Expand All @@ -30,7 +37,7 @@ const Player = () => {
};
}

if (file) {
if (file && show_covers) {
getCoverArt();
}
}, [file]);
Expand Down
39 changes: 39 additions & 0 deletions src/webapp/src/components/Settings/general/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import React from 'react';
import { useTranslation } from 'react-i18next';

import { useTheme } from '@mui/material/styles';

import {
Card,
CardContent,
CardHeader,
Divider,
Grid,
} from '@mui/material';
import ShowCovers from './show-covers';

const SettingsGeneral = () => {
const { t } = useTranslation();
const theme = useTheme();
const spacer = { marginBottom: theme.spacing(2) }

return (
<Card>
<CardHeader
title={t('settings.general.title')}
/>
<Divider />
<CardContent>
<Grid
container
direction="column"
sx={{ '& > .MuiGrid-root:not(:last-child)': spacer }}
>
<ShowCovers />
</Grid>
</CardContent>
</Card>
);
};

export default SettingsGeneral;
56 changes: 56 additions & 0 deletions src/webapp/src/components/Settings/general/show-covers.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import React, { useContext } from 'react';
import { useTranslation } from 'react-i18next';

import {
Box,
Grid,
Switch,
Typography,
} from '@mui/material';

import AppSettingsContext from '../../../context/appsettings/context';
import request from '../../../utils/request';

const ShowCovers = () => {
const { t } = useTranslation();

const {
settings,
setSettings,
} = useContext(AppSettingsContext);

const {
show_covers,
} = settings;

const updateShowCoversSetting = async (show_covers) => {
await request('setAppSettings', { settings: { show_covers }});
}

const handleSwitch = (event) => {
setSettings({ show_covers: event.target.checked});
updateShowCoversSetting(event.target.checked);
}

return (
<Grid container direction="column" justifyContent="center">
<Grid container direction="row" justifyContent="space-between" alignItems="center">
<Typography>
{t(`settings.general.show_covers.title`)}
</Typography>
<Box sx={{
display: 'flex',
alignItems: 'center',
marginLeft: '0',
}}>
<Switch
checked={show_covers}
onChange={handleSwitch}
/>
</Box>
</Grid>
</Grid>
);
};

export default ShowCovers;
Loading

0 comments on commit aee32ff

Please sign in to comment.