Fix: Always prefer tmpfs (if possible) for cache data
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Sat, 15 Mar 2025 16:23:10 +0000 (17:23 +0100)
committerMarcel van der Veldt <m.vanderveldt@outlook.com>
Sat, 15 Mar 2025 16:23:10 +0000 (17:23 +0100)
music_assistant/constants.py
music_assistant/controllers/streams.py
music_assistant/helpers/audio.py
music_assistant/helpers/util.py

index 0546904e41463505e26ae88fb881659ae763e293..ac26065f021b764d23b41d7f989ef413ab265625 100644 (file)
@@ -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"
index 5ab0299322077fe3dc7e115faec14340ac39ee68..c2d43b2debbc9d26ff7086d77accf37b7ea6e0cf 100644 (file)
@@ -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
         )
index 3be9304d72e0fab2440f86001d7ac9798f6b29a3..3208e30f7a7827a4904f8d6ec209d6a7e9ee7232 100644 (file)
@@ -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
index 25606e40f0efaaec9983f0824df8d876acf9fd5b..a05796e561f49cb5d4b46e1c5b3456995cb19e12 100644 (file)
@@ -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: