From 9b77cce9b8780af434b5b24c36e68d62c8bd7ead Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Sat, 15 Mar 2025 17:23:10 +0100 Subject: [PATCH] Fix: Always prefer tmpfs (if possible) for cache data --- music_assistant/constants.py | 4 +- music_assistant/controllers/streams.py | 53 +++++++++++++++----------- music_assistant/helpers/audio.py | 7 +--- music_assistant/helpers/util.py | 38 +++++++++--------- 4 files changed, 54 insertions(+), 48 deletions(-) diff --git a/music_assistant/constants.py b/music_assistant/constants.py index 0546904e..ac26065f 100644 --- a/music_assistant/constants.py +++ b/music_assistant/constants.py @@ -86,14 +86,12 @@ CONF_VOLUME_CONTROL: Final[str] = "volume_control" CONF_MUTE_CONTROL: Final[str] = "mute_control" CONF_OUTPUT_CODEC: Final[str] = "output_codec" CONF_ALLOW_AUDIO_CACHE: Final[str] = "allow_audio_cache" -CONF_AUDIO_CACHE_MAX_SIZE: Final[str] = "audio_cache_max_size" # config default values DEFAULT_HOST: Final[str] = "0.0.0.0" DEFAULT_PORT: Final[int] = 8095 -DEFAULT_ALLOW_AUDIO_CACHE: Final[str] = "auto" -DEFAULT_AUDIO_CACHE_MAX_SIZE: Final[int] = 5 # 5gb + # common db tables DB_TABLE_PLAYLOG: Final[str] = "playlog" diff --git a/music_assistant/controllers/streams.py b/music_assistant/controllers/streams.py index 5ab02993..c2d43b2d 100644 --- a/music_assistant/controllers/streams.py +++ b/music_assistant/controllers/streams.py @@ -10,9 +10,10 @@ from __future__ import annotations 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 @@ -31,7 +32,6 @@ from music_assistant_models.player_queue import PlayLogEntry 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, @@ -46,8 +46,6 @@ from music_assistant.constants import ( 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, @@ -67,8 +65,10 @@ from music_assistant.helpers.ffmpeg import LOGGER as FFMPEG_LOGGER 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, ) @@ -85,6 +85,8 @@ if TYPE_CHECKING: 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.""" @@ -115,10 +117,14 @@ class StreamsController(CoreController): ) 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: @@ -127,7 +133,7 @@ class StreamsController(CoreController): @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( @@ -217,7 +223,7 @@ class StreamsController(CoreController): 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"), @@ -236,16 +242,6 @@ class StreamsController(CoreController): 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: @@ -255,6 +251,19 @@ class StreamsController(CoreController): 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) @@ -1094,9 +1103,7 @@ class StreamsController(CoreController): 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 ) diff --git a/music_assistant/helpers/audio.py b/music_assistant/helpers/audio.py index 3be9304d..3208e30f 100644 --- a/music_assistant/helpers/audio.py +++ b/music_assistant/helpers/audio.py @@ -41,7 +41,6 @@ from music_assistant.constants import ( CONF_VOLUME_NORMALIZATION_RADIO, CONF_VOLUME_NORMALIZATION_TARGET, CONF_VOLUME_NORMALIZATION_TRACKS, - DEFAULT_ALLOW_AUDIO_CACHE, MASS_LOGGER_NAME, VERBOSE_LOG_LEVEL, ) @@ -70,8 +69,6 @@ LOGGER = logging.getLogger(f"{MASS_LOGGER_NAME}.audio") 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).""" @@ -600,11 +597,11 @@ async def _is_cache_allowed(mass: MusicAssistant, streamdetails: StreamDetails) 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 diff --git a/music_assistant/helpers/util.py b/music_assistant/helpers/util.py index 25606e40..a05796e5 100644 --- a/music_assistant/helpers/util.py +++ b/music_assistant/helpers/util.py @@ -22,7 +22,6 @@ from types import TracebackType 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 @@ -477,28 +476,33 @@ async def load_provider_module(domain: str, requirements: list[str]) -> Provider 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: -- 2.34.1