/
/
/
1"""NFS Filesystem Provider implementation."""
2
3from __future__ import annotations
4
5import os
6import platform
7from contextlib import suppress
8from pathlib import PurePosixPath
9from typing import TYPE_CHECKING
10
11from music_assistant_models.config_entries import ConfigEntry
12from music_assistant_models.enums import ConfigEntryType
13from music_assistant_models.errors import SetupFailedError, UnsupportedSystemError
14
15from music_assistant.constants import VERBOSE_LOG_LEVEL
16from music_assistant.helpers.json import SerializableType
17from music_assistant.helpers.mount import error_summary, unmount
18from music_assistant.helpers.process import check_output
19from music_assistant.helpers.security import is_safe_path
20from music_assistant.helpers.util import get_ip_from_host
21from music_assistant.providers.filesystem_local import (
22 LocalFileSystemProvider,
23 isdir,
24 ismount,
25 makedirs,
26)
27from music_assistant.providers.filesystem_local.constants import (
28 CONF_CONTENT_TYPE,
29 CONF_ENTRY_IGNORE_ALBUM_PLAYLISTS,
30 CONF_ENTRY_LIBRARY_SYNC_AUDIOBOOKS,
31 CONF_ENTRY_LIBRARY_SYNC_PLAYLISTS,
32 CONF_ENTRY_LIBRARY_SYNC_PODCASTS,
33 CONF_ENTRY_LIBRARY_SYNC_TRACKS,
34 CONF_ENTRY_MISSING_ALBUM_ARTIST,
35 CONF_ENTRY_PROPAGATE_GENRES,
36)
37
38from .constants import CONF_EXPORT_PATH, CONF_HOST, CONF_NFS_VERSION, CONF_SUBFOLDER
39
40if TYPE_CHECKING:
41 from music_assistant_models.config_entries import ProviderConfig
42 from music_assistant_models.provider import ProviderManifest
43
44 from music_assistant.mass import MusicAssistant
45
46
47class NFSFileSystemProvider(LocalFileSystemProvider):
48 """
49 Implementation of an NFS File System Provider.
50
51 This is a wrapper around the local filesystem provider that mounts
52 an NFS export to a temporary location. Once mounted, all file operations
53 are handled by the base LocalFileSystemProvider.
54 """
55
56 def __init__(
57 self,
58 mass: MusicAssistant,
59 manifest: ProviderManifest,
60 config: ProviderConfig,
61 base_path: str | None = None,
62 ) -> None:
63 """
64 Initialize NFS FileSystem Provider.
65
66 :raises SetupFailedError: If the configured subfolder would escape the mountpoint.
67 """
68 super().__init__(mass, manifest, config, base_path)
69 # NFSv3's rpc.mountd only hands out a filehandle for a path that is itself exported,
70 # so the subfolder can only be a path inside the mount: mount_path owns the mount
71 # lifecycle, base_path is the scan/serve root the base class works from.
72 self.mount_path: str = self.base_path
73 self._subfolder: str = str(self.get_setup_value(CONF_SUBFOLDER) or "").strip().lstrip("/")
74 if not self._subfolder:
75 return
76 if not is_safe_path(self._subfolder, self.mount_path):
77 msg = f"Invalid subfolder {self._subfolder}: must be a relative path inside the export"
78 raise SetupFailedError(
79 msg,
80 translation_key="invalid_subfolder",
81 translation_owner=self.translation_owner,
82 translation_args=[self._subfolder],
83 )
84 self.base_path = os.path.normpath(os.path.join(self.mount_path, self._subfolder))
85
86 async def get_config_entries(self) -> tuple[ConfigEntry, ...]:
87 """Return Config entries to setup this provider."""
88 # connection details and content type are collected by the setup flow; surface the
89 # (immutable) content type read-only so the sync options' depends_on chains resolve
90 content_type = str(self.get_setup_value(CONF_CONTENT_TYPE, "music"))
91 return (
92 ConfigEntry(key=CONF_CONTENT_TYPE, type=ConfigEntryType.LABEL, value=content_type),
93 CONF_ENTRY_MISSING_ALBUM_ARTIST,
94 CONF_ENTRY_IGNORE_ALBUM_PLAYLISTS,
95 CONF_ENTRY_LIBRARY_SYNC_TRACKS,
96 CONF_ENTRY_LIBRARY_SYNC_PLAYLISTS,
97 CONF_ENTRY_LIBRARY_SYNC_PODCASTS,
98 CONF_ENTRY_LIBRARY_SYNC_AUDIOBOOKS,
99 CONF_ENTRY_PROPAGATE_GENRES,
100 )
101
102 @property
103 def instance_name_postfix(self) -> str | None:
104 """Return a (default) instance name postfix for this provider instance."""
105 export_path = str(self.get_setup_value(CONF_EXPORT_PATH))
106 subfolder = str(self.get_setup_value(CONF_SUBFOLDER) or "")
107 if subfolder:
108 return subfolder
109 if export_path:
110 return PurePosixPath(export_path).name
111 return None
112
113 async def handle_async_init(self) -> None:
114 """Handle async initialization of the provider."""
115 # validate the connection details before attempting to mount
116 server = str(self.get_setup_value(CONF_HOST))
117 if not await get_ip_from_host(server):
118 msg = f"Unable to resolve {server}, make sure the address is resolvable."
119 raise SetupFailedError(
120 msg,
121 translation_key="host_unresolvable",
122 translation_args=[server],
123 )
124 export_path = str(self.get_setup_value(CONF_EXPORT_PATH))
125 if not export_path or not export_path.startswith("/") or not is_safe_path(export_path):
126 msg = "Invalid export path: must be an absolute path starting with /"
127 raise SetupFailedError(msg)
128 # the mount point may already exist; checking first is not reliable because
129 # reading the path fails while the server is unreachable
130 await makedirs(self.mount_path, exist_ok=True)
131 try:
132 # unmount first to cleanup any unexpected state
133 await unmount(self.mount_path, self.logger)
134 await self.mount()
135 except OSError as err:
136 msg = f"NFS mount failed: {err}"
137 raise SetupFailedError(msg) from err
138 # the subfolder lives on the share, so it can only be checked once mounted. Raise
139 # rather than leave it to check_write_access, which logs and lets the sync report an
140 # empty library instead.
141 if self._subfolder and not await isdir(self.base_path):
142 # a failed handle_async_init never gets unload(), so drop the mount here; failing
143 # to must not mask the missing subfolder
144 with suppress(SetupFailedError, OSError):
145 await unmount(self.mount_path, self.logger)
146 msg = f"Subfolder {self._subfolder} does not exist in the NFS export"
147 raise SetupFailedError(
148 msg,
149 translation_key="subfolder_not_found",
150 translation_owner=self.translation_owner,
151 translation_args=[self._subfolder],
152 )
153 await self.check_write_access()
154
155 async def unload(self, is_removed: bool = False) -> None:
156 """
157 Handle unload/close of the provider.
158
159 Called when provider is deregistered (e.g. MA exiting or config reloading).
160 """
161 await unmount(self.mount_path, self.logger)
162
163 async def get_diagnostics(self) -> dict[str, SerializableType]:
164 """Return diagnostics info for this provider to include in diagnostics reports."""
165 return {
166 **await super().get_diagnostics(),
167 "mounted": await ismount(self.mount_path),
168 }
169
170 async def mount(self) -> None:
171 """Mount the NFS export to a temporary folder."""
172 server = str(self.get_setup_value(CONF_HOST))
173 export_path = str(self.get_setup_value(CONF_EXPORT_PATH))
174
175 if platform.system() not in ("Linux", "Darwin"):
176 msg = f"NFS provider is not supported on {platform.system()}"
177 raise UnsupportedSystemError(msg)
178
179 mount_options = self._get_mount_options()
180 mount_cmd = [
181 "mount",
182 "-t",
183 "nfs",
184 "-o",
185 ",".join(mount_options),
186 f"{server}:{export_path}",
187 self.mount_path,
188 ]
189
190 self.logger.debug("Mounting %s:%s to %s", server, export_path, self.mount_path)
191 self.logger.log(VERBOSE_LOG_LEVEL, "Using mount command: %s", " ".join(mount_cmd))
192 returncode: int
193 output: bytes
194 returncode, output = await check_output(*mount_cmd)
195 if returncode != 0:
196 error = output.decode().strip()
197 msg = f"NFS mount failed with error: {error}"
198 raise SetupFailedError(
199 msg,
200 translation_key="mount_failed",
201 translation_args=[error_summary(error)],
202 )
203
204 def _get_mount_options(self) -> list[str]:
205 """Get platform-specific NFS mount options."""
206 if platform.system() == "Darwin":
207 options = ["resvport", "noatime", "soft", "timeo=30", "retrans=5"]
208 else:
209 options = ["noatime", "nolock", "tcp", "soft", "timeo=30", "retrans=5"]
210
211 nfs_version = str(self.get_setup_value(CONF_NFS_VERSION) or "")
212 if nfs_version:
213 options.append(f"vers={nfs_version}")
214
215 return options
216