Skip to content

Commit

Permalink
Merge branch 'future3/develop' into future3/multi-player
Browse files Browse the repository at this point in the history
  • Loading branch information
Groovylein committed May 2, 2024
2 parents 83692f1 + aee32ff commit 42c15f4
Show file tree
Hide file tree
Showing 23 changed files with 378 additions and 29 deletions.
6 changes: 3 additions & 3 deletions .github/workflows/codeql-analysis_v3.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ jobs:
uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v4
uses: actions/setup-python@v5
with:
python-version: '3.x'
- name: Install dependencies
Expand All @@ -51,9 +51,9 @@ jobs:
python -m pip install --upgrade pip
pip install -r requirements.txt
# Set the `CODEQL-PYTHON` environment variable to the Python executable
# Set the `CODEQL_EXTRACTOR_PYTHON_ANALYSIS_VERSION` environment variable to the Python executable
# that includes the dependencies
echo "CODEQL_PYTHON=$(which python)" >> $GITHUB_ENV
echo "CODEQL_EXTRACTOR_PYTHON_ANALYSIS_VERSION=$(which python)" >> $GITHUB_ENV
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/pythonpackage_future3.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,9 @@ jobs:
python-version: ['3.9', '3.10', '3.11', '3.12']

steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
Expand Down
2 changes: 1 addition & 1 deletion docker/Dockerfile.jukebox
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ RUN pip install --no-cache-dir -r ${INSTALLATION_PATH}/requirements.txt
ENV ZMQ_PREFIX /opt/libzmq
ENV ZMQ_DRAFT_API 1
COPY --from=libzmq ${ZMQ_PREFIX} ${ZMQ_PREFIX}
RUN pip install -v pyzmq --no-binary pyzmq
RUN pip install -v "pyzmq<26" --no-binary pyzmq

EXPOSE 5555 5556
WORKDIR ${INSTALLATION_PATH}/src/jukebox
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
```
2 changes: 1 addition & 1 deletion documentation/developers/docker.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ They can be run individually or in combination. To do that, we use
load-module module-native-protocol-tcp auth-ip-acl=127.0.0.1
```
1. Edit `$INSTALL_DIR/etc/pulse//etc/pulse/daemon.conf`, find the
1. Edit `$INSTALL_DIR/etc/pulse/daemon.conf`, find the
following line and change it to:
``` bash
Expand Down
16 changes: 15 additions & 1 deletion installation/routines/setup_jukebox_core.sh
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ _jukebox_core_build_and_install_pyzmq() {
fi

ZMQ_PREFIX="${JUKEBOX_ZMQ_PREFIX}" ZMQ_DRAFT_API=1 \
pip install -v pyzmq --no-binary pyzmq
pip install -v 'pyzmq<26' --no-binary pyzmq
else
print_lc " Skipping. pyzmq already installed"
fi
Expand Down Expand Up @@ -121,6 +121,20 @@ _jukebox_core_check() {
local pip_modules=$(get_args_from_file "${INSTALLATION_PATH}/requirements.txt")
verify_pip_modules pyzmq $pip_modules

log " Verify ZMQ version '${JUKEBOX_ZMQ_VERSION}'"
local zmq_version=$(python -c 'import zmq; print(f"{zmq.zmq_version()}")')
if [[ "${zmq_version}" != "${JUKEBOX_ZMQ_VERSION}" ]]; then
exit_on_error "ERROR: ZMQ version '${zmq_version}' differs from expected '${JUKEBOX_ZMQ_VERSION}'!"
fi
log " CHECK"

log " Verify ZMQ has 'DRAFT-API' activated"
local zmq_hasDraftApi=$(python -c 'import zmq; print(f"{zmq.DRAFT_API}")')
if [[ "${zmq_hasDraftApi}" != "True" ]]; then
exit_on_error "ERROR: ZMQ has 'DRAFT-API' '${zmq_hasDraftApi}' differs from expected 'True'!"
fi
log " CHECK"

verify_files_chmod_chown 644 "${CURRENT_USER}" "${CURRENT_USER_GROUP}" "${JUKEBOX_PULSE_CONFIG}"

verify_files_chmod_chown 644 "${CURRENT_USER}" "${CURRENT_USER_GROUP}" "${SETTINGS_PATH}/jukebox.yaml"
Expand Down
4 changes: 4 additions & 0 deletions resources/default-settings/jukebox.default.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -136,3 +136,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)
62 changes: 60 additions & 2 deletions src/jukebox/components/playermpd/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,31 @@ def __init__(self):
self.second_swipe_action = None
self.decode_2nd_swipe_option()

self.end_of_playlist_next_action = utils.get_config_action(cfg,
'playermpd',
'end_of_playlist_next_action',
'none',
{'rewind': self.rewind,
'stop': self.stop,
'none': lambda: None},
logger)
self.stopped_prev_action = utils.get_config_action(cfg,
'playermpd',
'stopped_prev_action',
'prev',
{'rewind': self.rewind,
'prev': self._prev_in_stopped_state,
'none': lambda: None},
logger)
self.stopped_next_action = utils.get_config_action(cfg,
'playermpd',
'stopped_next_action',
'next',
{'rewind': self.rewind,
'next': self._next_in_stopped_state,
'none': lambda: None},
logger)

self.mpd_client = mpd.MPDClient()
self.coverart_cache_manager = CoverartCacheManager()

Expand Down Expand Up @@ -327,15 +352,48 @@ def pause(self, state: int = 1):
@plugs.tag
def prev(self):
logger.debug("Prev")
if self.mpd_status['state'] == 'stop':
logger.debug('Player is stopped, calling stopped_prev_action')
return self.stopped_prev_action()
try:
with self.mpd_lock:
self.mpd_client.previous()
except mpd.base.CommandError:
# This shouldn't happen in reality, but we still catch
# this error to avoid crashing the player thread:
logger.warning('Failed to go to previous song, ignoring')

def _prev_in_stopped_state(self):
with self.mpd_lock:
self.mpd_client.previous()
self.mpd_client.play(max(0, int(self.mpd_status['pos']) - 1))

@plugs.tag
def next(self):
"""Play next track in current playlist"""
logger.debug("Next")
if self.mpd_status['state'] == 'stop':
logger.debug('Player is stopped, calling stopped_next_action')
return self.stopped_next_action()
playlist_len = int(self.mpd_status.get('playlistlength', -1))
current_pos = int(self.mpd_status.get('pos', 0))
if current_pos == playlist_len - 1:
logger.debug(f'next() called during last song ({current_pos}) of '
f'playlist (len={playlist_len}), running end_of_playlist_next_action.')
return self.end_of_playlist_next_action()
try:
with self.mpd_lock:
self.mpd_client.next()
except mpd.base.CommandError:
# This shouldn't happen in reality, but we still catch
# this error to avoid crashing the player thread:
logger.warning('Failed to go to next song, ignoring')

def _next_in_stopped_state(self):
pos = int(self.mpd_status['pos']) + 1
if pos > int(self.mpd_status['playlistlength']) - 1:
return self.end_of_playlist_next_action()
with self.mpd_lock:
self.mpd_client.next()
self.mpd_client.play(pos)

@plugs.tag
def seek(self, new_time):
Expand Down
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
13 changes: 13 additions & 0 deletions src/jukebox/jukebox/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,19 @@ def rpc_call_to_str(cfg_rpc_call: Dict, with_args=True) -> str:
return readable


def get_config_action(cfg, section, option, default, valid_actions_dict, logger):
"""
Looks up the given {section}.{option} config option and returns
the associated entry from valid_actions_dict, if valid. Falls back to the given
default otherwise.
"""
action = cfg.setndefault(section, option, value='').lower()
if action not in valid_actions_dict:
logger.error(f"Config {section}.{option} must be one of {valid_actions_dict.keys()}. Using default '{default}'")
action = default
return valid_actions_dict[action]


def indent(doc, spaces=4):
lines = doc.split('\n')
for i in range(0, len(lines)):
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 @@ -121,6 +121,7 @@ const commands = {
_package: 'volume',
plugin: 'ctrl',
method: 'set_volume',
argKeys: ['volume'],
},
getVolume: {
_package: 'volume',
Expand Down Expand Up @@ -251,6 +252,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
Loading

0 comments on commit 42c15f4

Please sign in to comment.