documentation: str | None = None
# multi_instance: whether multiple instances of the same provider are allowed/possible
multi_instance: bool = False
- # builtin: whether this provider is a system/builtin and can not disabled/removed
+ # builtin: whether this provider is a system/builtin provider, loaded by default
builtin: bool = False
- # hidden: hide entry in the UI
- hidden: bool = False
- # load_by_default: load this provider by default (may be used together with `builtin`)
- load_by_default: bool = False
+ # allow_disable: whether this provider can be disabled (used with builtin)
+ allow_disable: bool = True
# depends_on: depends on another provider to function
depends_on: str | None = None
# icon: name of the material design icon (https://pictogrammers.com/library/mdi)
msg = f"Provider {instance_id} does not exist"
raise KeyError(msg)
prov_manifest = self.mass.get_provider_manifest(existing["domain"])
- if prov_manifest.load_by_default and instance_id == prov_manifest.domain:
- # Guard for a provider that is loaded by default
- LOGGER.warning(
- "Provider %s can not be removed, disabling instead...",
- prov_manifest.name,
- )
- existing["enabled"] = False
- await self._update_provider_config(instance_id, existing)
- return
if prov_manifest.builtin:
msg = f"Builtin provider {prov_manifest.name} can not be removed."
raise RuntimeError(msg)
else:
# disable provider
prov_manifest = self.mass.get_provider_manifest(config.domain)
- if prov_manifest.builtin:
- msg = "Builtin provider can not be disabled."
+ if not prov_manifest.allow_disable:
+ msg = "Provider can not be disabled."
raise RuntimeError(msg)
# also unload any other providers dependent of this provider
for dep_prov in self.mass.providers:
DEFAULT_LANGUAGE = "en_US"
REFRESH_INTERVAL = 60 * 60 * 24 * 90
MAX_ONLINE_CALLS_PER_DAY = 30
+CONF_ENABLE_ONLINE_METADATA = "enable_online_metadata"
class MetaDataController(CoreController):
"""Several helpers to search and store metadata for mediaitems."""
domain: str = "metadata"
+ config: CoreConfig
def __init__(self, *args, **kwargs) -> None:
"""Initialize class."""
"in your preferred language is not available.",
options=tuple(ConfigValueOption(value, key) for key, value in LOCALES.items()),
),
+ ConfigEntry(
+ key=CONF_ENABLE_ONLINE_METADATA,
+ type=ConfigEntryType.BOOLEAN,
+ label="Enable metadata retrieval from online metadata providers",
+ required=False,
+ default_value=True,
+ description="Enable online metadata lookups.\n\n"
+ "This will allow Music Assistant to fetch additional metadata from (enabled) "
+ "metadata providers, such as The Audio DB and Fanart.tv.\n\n"
+ "Note that these online sources are only queried when no information is already "
+ "available from local files or the music providers.\n\n"
+ "The retrieval of additional rich metadata is a process that is executed slowly "
+ "in the background to not overload these free services with requests. "
+ "You can speedup the process by storing the images and other metadata locally.",
+ ),
)
async def setup(self, config: CoreConfig) -> None:
"""Async initialize of module."""
+ self.config = config
if not self.logger.isEnabledFor(VERBOSE_LOG_LEVEL):
# silence PIL logger
logging.getLogger("PIL").setLevel(logging.WARNING)
# this shouldn't happen but just in case.
raise RuntimeError("Metadata can only be updated for library items")
if item.media_type == MediaType.ARTIST:
- await self._update_artist_metadata(item)
+ await self._update_artist_metadata(item, force_refresh=force_refresh)
if item.media_type == MediaType.ALBUM:
- await self._update_album_metadata(item)
+ await self._update_album_metadata(item, force_refresh=force_refresh)
if item.media_type == MediaType.TRACK:
- await self._update_track_metadata(item)
+ await self._update_track_metadata(item, force_refresh=force_refresh)
if item.media_type == MediaType.PLAYLIST:
- await self._update_playlist_metadata(item)
+ await self._update_playlist_metadata(item, force_refresh=force_refresh)
if item.media_type == MediaType.RADIO:
- await self._update_radio_metadata(item)
+ await self._update_radio_metadata(item, force_refresh=force_refresh)
@api_command("metadata/scan")
async def metadata_scanner(self) -> None:
return
self._scanner_running = True
try:
- timestamp = int(time() - 60 * 60 * 24 * 7)
+ timestamp = int(time() - 60 * 60 * 24 * 30)
query = (
f"WHERE json_extract({DB_TABLE_ARTISTS}.metadata,'$.last_refresh') ISNULL "
f"OR json_extract({DB_TABLE_ARTISTS}.metadata,'$.last_refresh') < {timestamp}"
)
for artist in await self.mass.music.artists.library_items(
- limit=250, order_by="random", extra_query=query
+ limit=50, order_by="random", extra_query=query
):
await self._update_artist_metadata(artist)
# we really need to throttle this
- await asyncio.sleep(10)
+ await asyncio.sleep(30)
query = (
f"WHERE json_extract({DB_TABLE_ALBUMS}.metadata,'$.last_refresh') ISNULL "
f"OR json_extract({DB_TABLE_ALBUMS}.metadata,'$.last_refresh') < {timestamp}"
)
for album in await self.mass.music.albums.library_items(
- limit=250, order_by="random", extra_query=query
+ limit=50, order_by="random", extra_query=query
):
await self._update_album_metadata(album)
# we really need to throttle this
- await asyncio.sleep(10)
+ await asyncio.sleep(30)
query = (
f"WHERE json_extract({DB_TABLE_PLAYLISTS}.metadata,'$.last_refresh') ISNULL "
f"OR json_extract({DB_TABLE_PLAYLISTS}.metadata,'$.last_refresh') < {timestamp}"
)
for playlist in await self.mass.music.playlists.library_items(
- limit=250, order_by="random", extra_query=query
+ limit=50, order_by="random", extra_query=query
):
await self._update_playlist_metadata(playlist)
# we really need to throttle this
- await asyncio.sleep(10)
+ await asyncio.sleep(30)
query = (
f"WHERE json_extract({DB_TABLE_TRACKS}.metadata,'$.last_refresh') ISNULL "
# to not overload the (free) metadata providers with api calls
# TODO: Utilize a global (cloud) cache for metadata lookups to save on API calls
- if force_refresh or (
- self._online_slots_available
- and ((time() - (artist.metadata.last_refresh or 0)) > REFRESH_INTERVAL)
+ if self.config.get_value(CONF_ENABLE_ONLINE_METADATA) and (
+ force_refresh
+ or (
+ self._online_slots_available
+ and ((time() - (artist.metadata.last_refresh or 0)) > REFRESH_INTERVAL)
+ )
):
self._online_slots_available -= 1
# set timestamp, used to determine when this function was last called
# NOTE: we only allow this every REFRESH_INTERVAL and a max amount of calls per day
# to not overload the (free) metadata providers with api calls
# TODO: Utilize a global (cloud) cache for metadata lookups to save on API calls
- if force_refresh or (
- self._online_slots_available
- and ((time() - (album.metadata.last_refresh or 0)) > REFRESH_INTERVAL)
- and (album.mbid or album.artists)
+ if self.config.get_value(CONF_ENABLE_ONLINE_METADATA) and (
+ force_refresh
+ or (
+ self._online_slots_available
+ and ((time() - (album.metadata.last_refresh or 0)) > REFRESH_INTERVAL)
+ and (album.mbid or album.artists)
+ )
):
self._online_slots_available -= 1
# set timestamp, used to determine when this function was last called
# NOTE: we only allow this every REFRESH_INTERVAL and a max amount of calls per day
# to not overload the (free) metadata providers with api calls
# TODO: Utilize a global (cloud) cache for metadata lookups to save on API calls
- if force_refresh or (
- self._online_slots_available
- and ((time() - (track.metadata.last_refresh or 0)) > REFRESH_INTERVAL)
- and (track.mbid or track.artists or track.album)
+ if self.config.get_value(CONF_ENABLE_ONLINE_METADATA) and (
+ force_refresh
+ or (
+ self._online_slots_available
+ and ((time() - (track.metadata.last_refresh or 0)) > REFRESH_INTERVAL)
+ and (track.mbid or track.artists or track.album)
+ )
):
self._online_slots_available -= 1
# set timestamp, used to determine when this function was last called
return
# all other versions: reset the database
- # we only migrate from prtev version to current we do not try to handle
+ # we only migrate from prev version to current, we do not try to handle
# more complex migrations
self.logger.warning(
"Database schema too old - Resetting library/database - "
return None
-def create_safe_string(input_str: str) -> str:
+def create_safe_string(input_str: str, lowercase: bool = True, replace_space: bool = False) -> str:
"""Return clean lowered string for compare actions."""
- input_str = input_str.lower().strip()
+ input_str = input_str.lower().strip() if lowercase else input_str.strip()
unaccented_string = unidecode.unidecode(input_str)
- return re.sub(r"[^a-zA-Z0-9]", "", unaccented_string)
+ regex = r"[^a-zA-Z0-9]" if replace_space else r"[^a-zA-Z0-9 ]"
+ return re.sub(regex, "", unaccented_string)
def loose_compare_strings(base: str, alt: str) -> bool:
"domain": "builtin",
"name": "Music Assistant",
"description": "Built-in/generic provider that handles generic urls and playlists.",
- "codeowners": [
- "@music-assistant"
- ],
+ "codeowners": ["@music-assistant"],
"requirements": [],
"documentation": "https://music-assistant.io/music-providers/builtin/",
"multi_instance": false,
- "builtin": true,
- "hidden": false
+ "builtin": true
}
import aiohttp.client_exceptions
-from music_assistant.common.models.enums import ExternalID, ProviderFeature
+from music_assistant.common.models.config_entries import ConfigEntry
+from music_assistant.common.models.enums import ConfigEntryType, ExternalID, ProviderFeature
from music_assistant.common.models.media_items import ImageType, MediaItemImage, MediaItemMetadata
from music_assistant.server.controllers.cache import use_cache
from music_assistant.server.helpers.app_vars import app_var # pylint: disable=no-name-in-module
from music_assistant.server.models.metadata_provider import MetadataProvider
if TYPE_CHECKING:
- from music_assistant.common.models.config_entries import (
- ConfigEntry,
- ConfigValueType,
- ProviderConfig,
- )
+ from music_assistant.common.models.config_entries import ConfigValueType, ProviderConfig
from music_assistant.common.models.media_items import Album, Artist
from music_assistant.common.models.provider import ProviderManifest
from music_assistant.server import MusicAssistant
ProviderFeature.ALBUM_METADATA,
)
-# TODO: add support for personal api keys ?
-
+CONF_ENABLE_ARTIST_IMAGES = "enable_artist_images"
+CONF_ENABLE_ALBUM_IMAGES = "enable_album_images"
+CONF_CLIENT_KEY = "client_key"
IMG_MAPPING = {
"artistthumb": ImageType.THUMB,
values: the (intermediate) raw values for config entries sent with the action.
"""
# ruff: noqa: ARG001
- return () # we do not have any config entries (yet)
+ return (
+ ConfigEntry(
+ key=CONF_ENABLE_ARTIST_IMAGES,
+ type=ConfigEntryType.BOOLEAN,
+ label="Enable retrieval of artist images.",
+ default_value=True,
+ ),
+ ConfigEntry(
+ key=CONF_ENABLE_ALBUM_IMAGES,
+ type=ConfigEntryType.BOOLEAN,
+ label="Enable retrieval of album image(s).",
+ default_value=True,
+ ),
+ ConfigEntry(
+ key=CONF_CLIENT_KEY,
+ type=ConfigEntryType.SECURE_STRING,
+ label="VIP Member Personal API Key (optional)",
+ description="Support this metadata provider by becoming a VIP Member, "
+ "resulting in higher rate limits and faster response times among other benefits. "
+ "See https://wiki.fanart.tv/General/personal%20api/ for more information.",
+ required=False,
+ ),
+ )
class FanartTvMetadataProvider(MetadataProvider):
async def handle_async_init(self) -> None:
"""Handle async initialization of the provider."""
self.cache = self.mass.cache
- self.throttler = Throttler(rate_limit=1, period=30)
+ if self.config.get_value(CONF_CLIENT_KEY):
+ # loosen the throttler when a personal client key is used
+ self.throttler = Throttler(rate_limit=1, period=1)
+ else:
+ self.throttler = Throttler(rate_limit=1, period=30)
@property
def supported_features(self) -> tuple[ProviderFeature, ...]:
"""Retrieve metadata for artist on fanart.tv."""
if not artist.mbid:
return None
+ if not self.config.get_value(CONF_ENABLE_ARTIST_IMAGES):
+ return None
self.logger.debug("Fetching metadata for Artist %s on Fanart.tv", artist.name)
if data := await self._get_data(f"music/{artist.mbid}"):
metadata = MediaItemMetadata()
"""Retrieve metadata for album on fanart.tv."""
if (mbid := album.get_external_id(ExternalID.MB_RELEASEGROUP)) is None:
return None
+ if not self.config.get_value(CONF_ENABLE_ALBUM_IMAGES):
+ return None
self.logger.debug("Fetching metadata for Album %s on Fanart.tv", album.name)
if data := await self._get_data(f"music/albums/{mbid}"):
if data and data.get("albums"):
async def _get_data(self, endpoint, **kwargs) -> dict | None:
"""Get data from api."""
url = f"http://webservice.fanart.tv/v3/{endpoint}"
- kwargs["api_key"] = app_var(4)
+ headers = {
+ "api-key": app_var(4),
+ }
+ if client_key := self.config.get_value(CONF_CLIENT_KEY):
+ headers["client_key"] = client_key
async with (
self.throttler,
- self.mass.http_session.get(url, params=kwargs, ssl=False) as response,
+ self.mass.http_session.get(url, params=kwargs, headers=headers, ssl=False) as response,
):
try:
result = await response.json()
{
"type": "metadata",
"domain": "fanarttv",
- "name": "fanart.tv Metadata provider",
+ "name": "fanart.tv",
"description": "fanart.tv is a community database of artwork for movies, tv series and music.",
- "codeowners": [
- "@music-assistant"
- ],
+ "codeowners": ["@music-assistant"],
"requirements": [],
"documentation": "",
"multi_instance": false,
DB_TABLE_TRACK_ARTISTS,
VARIOUS_ARTISTS_NAME,
)
-from music_assistant.server.helpers.compare import compare_strings
+from music_assistant.server.helpers.compare import compare_strings, create_safe_string
from music_assistant.server.helpers.playlists import parse_m3u, parse_pls
from music_assistant.server.helpers.tags import parse_tags, split_items
from music_assistant.server.models.music_provider import MusicProvider
break
else:
# check if we have an artist folder for this artist at root level
+ safe_artist_name = create_safe_string(name, lowercase=False, replace_space=False)
if await self.exists(name):
artist_path = name
- elif await self.exists(name.title()):
- artist_path = name.title()
+ elif await self.exists(safe_artist_name):
+ artist_path = safe_artist_name
if artist_path: # noqa: SIM108
# prefer the path as id
"requirements": ["python-fullykiosk==0.0.14"],
"documentation": "https://music-assistant.io/player-support/fully-kiosk/",
"multi_instance": true,
- "builtin": false,
- "load_by_default": false
+ "builtin": false
}
"documentation": "",
"multi_instance": false,
"builtin": false,
- "load_by_default": false,
"icon": "md:webhook",
"requirements": ["hass-client==1.2.0"]
}
"domain": "hass_players",
"name": "Home Assistant MediaPlayers",
"description": "Use (supported) Home Assistant media players as players in Music Assistant.",
- "codeowners": [
- "@music-assistant"
- ],
+ "codeowners": ["@music-assistant"],
"documentation": "https://music-assistant.io/player-support/ha/",
"multi_instance": false,
"builtin": false,
- "load_by_default": false,
"icon": "md:webhook",
"depends_on": "hass",
"requirements": []
{
"type": "metadata",
"domain": "musicbrainz",
- "name": "MusicBrainz Metadata provider",
- "description": "MusicBrainz is an open music encyclopedia that collects music metadata and makes it available to the public.",
- "codeowners": [
- "@music-assistant"
- ],
+ "name": "MusicBrainz",
+ "description": "MusicBrainz is an open music encyclopedia that collects music metadata and makes it available to the public. Music Assistant uses MusicBrainz primary to identify (unique) media items and therefore the provider can not be disabled. However note that lookups will only be performed if this info is absent locally.",
+ "codeowners": ["@music-assistant"],
"requirements": [],
"documentation": "",
"multi_instance": false,
"builtin": true,
+ "allow_disable": false,
"icon": "mdi-folder-information"
}
"domain": "slimproto",
"name": "Slimproto (Squeezebox players)",
"description": "Support for slimproto based players (e.g. squeezebox, squeezelite).",
- "codeowners": [
- "@music-assistant"
- ],
- "requirements": [
- "aioslimproto==3.0.1"
- ],
+ "codeowners": ["@music-assistant"],
+ "requirements": ["aioslimproto==3.0.1"],
"documentation": "https://music-assistant.io/player-support/slimproto/",
"multi_instance": false,
- "builtin": false,
- "load_by_default": false
+ "builtin": false
}
"domain": "snapcast",
"name": "Snapcast",
"description": "Support for snapcast server and clients.",
- "codeowners": [
- "@SantigoSotoC"
- ],
- "requirements": [
- "snapcast==2.3.6",
- "bidict==0.23.1"
- ],
+ "codeowners": ["@SantigoSotoC"],
+ "requirements": ["snapcast==2.3.6", "bidict==0.23.1"],
"documentation": "https://music-assistant.io/player-support/snapcast/",
"multi_instance": false,
- "builtin": false,
- "load_by_default": false
+ "builtin": false
}
ConfigEntry(
key=CONF_CLIENT_ID,
type=ConfigEntryType.SECURE_STRING,
- label="Client ID",
+ label="Client ID (optional)",
description="By default, a generic client ID is used which is heavy rate limited. "
"It is advised that you create your own Spotify Developer account and use "
"that client ID here to speedup performance.",
"domain": "test",
"name": "Test / demo provider",
"description": "Test/Demo provider that creates a collection of fake media items.",
- "codeowners": [
- "@music-assistant"
- ],
+ "codeowners": ["@music-assistant"],
"requirements": [],
"documentation": "",
"multi_instance": false,
- "builtin": false,
- "hidden": false
+ "builtin": false
}
import aiohttp.client_exceptions
-from music_assistant.common.models.enums import ExternalID, ProviderFeature
+from music_assistant.common.models.config_entries import ConfigEntry
+from music_assistant.common.models.enums import ConfigEntryType, ExternalID, ProviderFeature
from music_assistant.common.models.media_items import (
Album,
AlbumType,
from music_assistant.server.models.metadata_provider import MetadataProvider
if TYPE_CHECKING:
- from music_assistant.common.models.config_entries import (
- ConfigEntry,
- ConfigValueType,
- ProviderConfig,
- )
+ from music_assistant.common.models.config_entries import ConfigValueType, ProviderConfig
from music_assistant.common.models.provider import ProviderManifest
from music_assistant.server import MusicAssistant
from music_assistant.server.models import ProviderInstanceType
"EP": AlbumType.EP,
}
+CONF_ENABLE_IMAGES = "enable_images"
+CONF_ENABLE_ARTIST_METADATA = "enable_artist_metadata"
+CONF_ENABLE_ALBUM_METADATA = "enable_album_metadata"
+CONF_ENABLE_TRACK_METADATA = "enable_track_metadata"
+
async def setup(
mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
values: the (intermediate) raw values for config entries sent with the action.
"""
# ruff: noqa: ARG001
- return () # we do not have any config entries (yet)
+ return (
+ ConfigEntry(
+ key=CONF_ENABLE_ARTIST_METADATA,
+ type=ConfigEntryType.BOOLEAN,
+ label="Enable retrieval of artist metadata.",
+ default_value=True,
+ ),
+ ConfigEntry(
+ key=CONF_ENABLE_ALBUM_METADATA,
+ type=ConfigEntryType.BOOLEAN,
+ label="Enable retrieval of album metadata.",
+ default_value=True,
+ ),
+ ConfigEntry(
+ key=CONF_ENABLE_TRACK_METADATA,
+ type=ConfigEntryType.BOOLEAN,
+ label="Enable retrieval of track metadata.",
+ default_value=False,
+ ),
+ ConfigEntry(
+ key=CONF_ENABLE_IMAGES,
+ type=ConfigEntryType.BOOLEAN,
+ label="Enable retrieval of artist/album/track images",
+ default_value=True,
+ ),
+ )
class AudioDbMetadataProvider(MetadataProvider):
async def handle_async_init(self) -> None:
"""Handle async initialization of the provider."""
self.cache = self.mass.cache
- self.throttler = Throttler(rate_limit=1, period=30)
+ self.throttler = Throttler(rate_limit=1, period=1)
@property
def supported_features(self) -> tuple[ProviderFeature, ...]:
async def get_artist_metadata(self, artist: Artist) -> MediaItemMetadata | None:
"""Retrieve metadata for artist on theaudiodb."""
+ if not self.config.get_value(CONF_ENABLE_ARTIST_METADATA):
+ return None
if not artist.mbid:
# for 100% accuracy we require the musicbrainz id for all lookups
return None
async def get_album_metadata(self, album: Album) -> MediaItemMetadata | None:
"""Retrieve metadata for album on theaudiodb."""
+ if not self.config.get_value(CONF_ENABLE_ALBUM_METADATA):
+ return None
if (mbid := album.get_external_id(ExternalID.MB_RELEASEGROUP)) is None:
return None
result = await self._get_data("album-mb.php", i=mbid)
async def get_track_metadata(self, track: Track) -> MediaItemMetadata | None:
"""Retrieve metadata for track on theaudiodb."""
+ if not self.config.get_value(CONF_ENABLE_TRACK_METADATA):
+ return None
if track.mbid:
result = await self._get_data("track-mb.php", i=track.mbid)
if result and result.get("track"):
else:
metadata.description = artist_obj.get("strBiographyEN")
# images
+ if not self.config.get_value(CONF_ENABLE_IMAGES):
+ return metadata
metadata.images = UniqueList()
for key, img_type in IMG_MAPPING.items():
for postfix in ("", "2", "3", "4", "5", "6", "7", "8", "9", "10"):
metadata.description = album_obj.get("strDescriptionEN")
metadata.review = album_obj.get("strReview")
# images
+ if not self.config.get_value(CONF_ENABLE_IMAGES):
+ return metadata
metadata.images = UniqueList()
for key, img_type in IMG_MAPPING.items():
for postfix in ("", "2", "3", "4", "5", "6", "7", "8", "9", "10"):
else:
metadata.description = track_obj.get("strDescriptionEN")
# images
+ if not self.config.get_value(CONF_ENABLE_IMAGES):
+ return metadata
metadata.images = UniqueList([])
for key, img_type in IMG_MAPPING.items():
for postfix in ("", "2", "3", "4", "5", "6", "7", "8", "9", "10"):
{
"type": "metadata",
"domain": "theaudiodb",
- "name": "TheAudioDB Metadata provider",
+ "name": "The Audio DB",
"description": "TheAudioDB is a community Database of audio artwork and metadata with a JSON API.",
- "codeowners": [
- "@music-assistant"
- ],
+ "codeowners": ["@music-assistant"],
"requirements": [],
"documentation": "",
"multi_instance": false,
--- /dev/null
+"""Tests for Filesystem provider."""
--- /dev/null
+"""Tests for utility/helper functions."""
+
+from music_assistant.server.providers.filesystem_local import helpers
+
+# ruff: noqa: S108
+
+
+def test_get_artist_dir() -> None:
+ """Test the extraction of an artist dir."""
+ album_path = "/tmp/Artist/Album"
+ artist_name = "Artist"
+ assert helpers.get_artist_dir(album_path, artist_name) == "/tmp/Artist"
+ album_path = "/tmp/artist/Album"
+ assert helpers.get_artist_dir(album_path, artist_name) == "/tmp/artist"
+ album_path = "/tmp/Album"
+ assert helpers.get_artist_dir(album_path, artist_name) is None
+ album_path = "/tmp/ARTIST!/Album"
+ assert helpers.get_artist_dir(album_path, artist_name) == "/tmp/ARTIST!"
+ album_path = "/tmp/Artist/Album"
+ artist_name = "Artist!"
+ assert helpers.get_artist_dir(album_path, artist_name) == "/tmp/Artist"
+ album_path = "/tmp/REM/Album"
+ artist_name = "R.E.M."
+ assert helpers.get_artist_dir(album_path, artist_name) == "/tmp/REM"
+ album_path = "/tmp/ACDC/Album"
+ artist_name = "AC/DC"
+ assert helpers.get_artist_dir(album_path, artist_name) == "/tmp/ACDC"
+ album_path = "/tmp/Celine Dion/Album"
+ artist_name = "Céline Dion"
+ assert helpers.get_artist_dir(album_path, artist_name) == "/tmp/Celine Dion"
+ album_path = "/tmp/Antonin Dvorak/Album"
+ artist_name = "Antonín Dvořák"
+ assert helpers.get_artist_dir(album_path, artist_name) == "/tmp/Antonin Dvorak"