import asyncio
import os
+import shutil
import urllib.parse
from collections.abc import AsyncGenerator
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, Final
from aiofiles.os import wrap
from aiohttp import web
from music_assistant.constants import (
ANNOUNCE_ALERT_FILE,
CONF_ALLOW_AUDIO_CACHE,
- CONF_AUDIO_CACHE_MAX_SIZE,
CONF_BIND_IP,
CONF_BIND_PORT,
CONF_CROSSFADE,
CONF_VOLUME_NORMALIZATION_FIXED_GAIN_TRACKS,
CONF_VOLUME_NORMALIZATION_RADIO,
CONF_VOLUME_NORMALIZATION_TRACKS,
- DEFAULT_ALLOW_AUDIO_CACHE,
- DEFAULT_AUDIO_CACHE_MAX_SIZE,
DEFAULT_PCM_FORMAT,
DEFAULT_STREAM_HEADERS,
ICY_HEADERS,
from music_assistant.helpers.ffmpeg import check_ffmpeg_version, get_ffmpeg_stream
from music_assistant.helpers.util import (
clean_old_files,
+ get_free_space,
get_ip,
get_ips,
+ has_enough_space,
select_free_port,
try_parse_bool,
)
isfile = wrap(os.path.isfile)
+AUDIO_CACHE_MAX_SIZE: Final[int] = 2 # 2gb
+
def parse_pcm_info(content_type: str) -> tuple[int, int, int]:
"""Parse PCM info from a codec/content_type string."""
)
self.manifest.icon = "cast-audio"
self.announcements: dict[str, str] = {}
- # create cache dir if needed
- self._audio_cache_dir = audio_cache_dir = os.path.join(self.mass.cache_path, ".audio")
- if not os.path.isdir(audio_cache_dir):
- os.makedirs(audio_cache_dir)
+ # TEMP: remove old cache dir
+ # remove after 2.5.0b15 or b16
+ prev_cache_dir = os.path.join(self.mass.cache_path, ".audio")
+ if os.path.isdir(prev_cache_dir):
+ shutil.rmtree(prev_cache_dir)
+ # prefer /tmp/.audio as audio cache dir
+ self._audio_cache_dir = os.path.join("/tmp/.audio") # noqa: S108
+ self.allow_cache_default = "auto"
@property
def base_url(self) -> str:
@property
def audio_cache_dir(self) -> str:
- """Return the directory where audio cache files are stored."""
+ """Return the directory where (temporary) audio cache files are stored."""
return self._audio_cache_dir
async def get_config_entries(
ConfigEntry(
key=CONF_ALLOW_AUDIO_CACHE,
type=ConfigEntryType.STRING,
- default_value=DEFAULT_ALLOW_AUDIO_CACHE,
+ default_value=self.allow_cache_default,
options=[
ConfigValueOption("Always", "always"),
ConfigValueOption("Disabled", "disabled"),
category="advanced",
required=True,
),
- ConfigEntry(
- key=CONF_AUDIO_CACHE_MAX_SIZE,
- type=ConfigEntryType.INTEGER,
- default_value=DEFAULT_AUDIO_CACHE_MAX_SIZE,
- label="Maximum size of audio cache",
- description="The maximum amount of diskspace (in GB) "
- "the audio cache may consume (if enabled).",
- range=(1, 50),
- category="advanced",
- ),
)
async def setup(self, config: CoreConfig) -> None:
FFMPEG_LOGGER.setLevel(self.logger.level)
# perform check for ffmpeg version
await check_ffmpeg_version()
+ # note that on HAOS we run /tmp in tmpfs so we need to check if we're running
+ # on a system that has enough space to store the audio cache in the tmpfs
+ # if not, we choose another location
+ if await get_free_space("/tmp") < AUDIO_CACHE_MAX_SIZE * 1.5: # noqa: S108
+ self._audio_cache_dir = os.path.join(os.path.expanduser("~"), ".audio")
+ if not await asyncio.to_thread(os.path.isdir, self._audio_cache_dir):
+ await asyncio.to_thread(os.makedirs, self._audio_cache_dir)
+ self.allow_cache_default = (
+ "auto"
+ if await has_enough_space(self._audio_cache_dir, AUDIO_CACHE_MAX_SIZE * 1.5)
+ else "disabled"
+ )
+ # schedule cleanup of old audio cache files
await self._clean_audio_cache()
# start the webserver
self.publish_port = config.get_value(CONF_BIND_PORT)
async def _clean_audio_cache(self) -> None:
"""Clean up audio cache periodically."""
- max_cache_size = await self.mass.config.get_core_config_value(
- self.domain, CONF_AUDIO_CACHE_MAX_SIZE
- )
+ max_cache_size = AUDIO_CACHE_MAX_SIZE
cache_enabled = await self.mass.config.get_core_config_value(
self.domain, CONF_ALLOW_AUDIO_CACHE
)
CONF_VOLUME_NORMALIZATION_RADIO,
CONF_VOLUME_NORMALIZATION_TARGET,
CONF_VOLUME_NORMALIZATION_TRACKS,
- DEFAULT_ALLOW_AUDIO_CACHE,
MASS_LOGGER_NAME,
VERBOSE_LOG_LEVEL,
)
HTTP_HEADERS = {"User-Agent": "Lavf/60.16.100.MusicAssistant"}
HTTP_HEADERS_ICY = {**HTTP_HEADERS, "Icy-MetaData": "1"}
-REQUIRED_FREE_CACHE_SPACE = 5 # 5 GB
-
async def remove_file(file_path: str) -> None:
"""Remove file path (if it exists)."""
if streamdetails.stream_type in (StreamType.ICY, StreamType.LOCAL_FILE, StreamType.UNKNOWN):
return False
allow_cache = mass.config.get_raw_core_config_value(
- "streams", CONF_ALLOW_AUDIO_CACHE, DEFAULT_ALLOW_AUDIO_CACHE
+ "streams", CONF_ALLOW_AUDIO_CACHE, mass.streams.allow_cache_default
)
if allow_cache == "disabled":
return False
- if not await has_enough_space(mass.streams.audio_cache_dir, REQUIRED_FREE_CACHE_SPACE):
+ if not await has_enough_space(mass.streams.audio_cache_dir, 0.5):
return False
if allow_cache == "always":
return True
from typing import TYPE_CHECKING, Any, ParamSpec, Self, TypeVar
from urllib.parse import urlparse
-import aiofiles
import cchardet as chardet
import ifaddr
from zeroconf import IPVersion
async def has_tmpfs_mount() -> bool:
"""Check if we have a tmpfs mount."""
- try:
- async with aiofiles.open("/proc/mounts") as file:
- async for line in file:
- if "tmpfs /tmp tmpfs rw" in line:
- return True
- except (FileNotFoundError, OSError, PermissionError):
- pass
- return False
+ def _has_tmpfs_mount() -> bool:
+ """Check if we have a tmpfs mount."""
+ try:
+ with open("/proc/mounts") as file:
+ for line in file:
+ if "tmpfs /tmp tmpfs rw" in line:
+ return True
+ except (FileNotFoundError, OSError, PermissionError):
+ pass
+ return False
-async def get_tmp_free_space() -> float:
- """Return free space on tmp in GB's."""
- return await get_free_space("/tmp") # noqa: S108
+ return await asyncio.to_thread(_has_tmpfs_mount)
async def get_free_space(folder: str) -> float:
"""Return free space on given folderpath in GB."""
- try:
- if res := await asyncio.to_thread(shutil.disk_usage, folder):
- return res.free / float(1 << 30)
- except (FileNotFoundError, OSError, PermissionError):
- return 0.0
+
+ def _get_free_space(folder: str) -> float:
+ """Return free space on given folderpath in GB."""
+ try:
+ if res := shutil.disk_usage(folder):
+ return res.free / float(1 << 30)
+ except (FileNotFoundError, OSError, PermissionError):
+ return 0.0
+
+ return await asyncio.to_thread(_get_free_space, folder)
async def has_enough_space(folder: str, size: int) -> bool: