/
/
/
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.errors import SetupFailedError, UnsupportedSystemError
12
13from music_assistant.constants import VERBOSE_LOG_LEVEL
14from music_assistant.helpers.json import SerializableType
15from music_assistant.helpers.mount import error_summary, unmount
16from music_assistant.helpers.process import check_output
17from music_assistant.helpers.security import is_safe_path
18from music_assistant.helpers.util import get_ip_from_host
19from music_assistant.providers.filesystem_local import (
20 LocalFileSystemProvider,
21 isdir,
22 ismount,
23 makedirs,
24)
25from music_assistant.providers.filesystem_local.constants import (
26 CONF_CONTENT_TYPE,
27 CONF_ENTRY_CONTENT_TYPE,
28 CONF_ENTRY_IGNORE_ALBUM_PLAYLISTS,
29 CONF_ENTRY_LIBRARY_SYNC_AUDIOBOOKS,
30 CONF_ENTRY_LIBRARY_SYNC_PLAYLISTS,
31 CONF_ENTRY_LIBRARY_SYNC_PODCASTS,
32 CONF_ENTRY_LIBRARY_SYNC_TRACKS,
33 CONF_ENTRY_MISSING_ALBUM_ARTIST,
34 CONF_ENTRY_PROPAGATE_GENRES,
35 content_type_config_entry,
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 ConfigEntry, 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(
91 self.get_setup_value(CONF_CONTENT_TYPE, CONF_ENTRY_CONTENT_TYPE.default_value)
92 )
93 return (
94 content_type_config_entry(content_type),
95 CONF_ENTRY_MISSING_ALBUM_ARTIST,
96 CONF_ENTRY_IGNORE_ALBUM_PLAYLISTS,
97 CONF_ENTRY_LIBRARY_SYNC_TRACKS,
98 CONF_ENTRY_LIBRARY_SYNC_PLAYLISTS,
99 CONF_ENTRY_LIBRARY_SYNC_PODCASTS,
100 CONF_ENTRY_LIBRARY_SYNC_AUDIOBOOKS,
101 CONF_ENTRY_PROPAGATE_GENRES,
102 )
103
104 @property
105 def instance_name_postfix(self) -> str | None:
106 """Return a (default) instance name postfix for this provider instance."""
107 export_path = str(self.get_setup_value(CONF_EXPORT_PATH))
108 subfolder = str(self.get_setup_value(CONF_SUBFOLDER) or "")
109 if subfolder:
110 return subfolder
111 if export_path:
112 return PurePosixPath(export_path).name
113 return None
114
115 async def handle_async_init(self) -> None:
116 """Handle async initialization of the provider."""
117 # validate the connection details before attempting to mount
118 server = str(self.get_setup_value(CONF_HOST))
119 if not await get_ip_from_host(server):
120 msg = f"Unable to resolve {server}, make sure the address is resolvable."
121 raise SetupFailedError(
122 msg,
123 translation_key="host_unresolvable",
124 translation_args=[server],
125 )
126 export_path = str(self.get_setup_value(CONF_EXPORT_PATH))
127 if not export_path or not export_path.startswith("/") or not is_safe_path(export_path):
128 msg = "Invalid export path: must be an absolute path starting with /"
129 raise SetupFailedError(msg)
130 # the mount point may already exist; checking first is not reliable because
131 # reading the path fails while the server is unreachable
132 await makedirs(self.mount_path, exist_ok=True)
133 try:
134 # unmount first to cleanup any unexpected state
135 await unmount(self.mount_path, self.logger)
136 await self.mount()
137 except OSError as err:
138 msg = f"NFS mount failed: {err}"
139 raise SetupFailedError(msg) from err
140 # the subfolder lives on the share, so it can only be checked once mounted. Raise
141 # rather than leave it to check_write_access, which logs and lets the sync report an
142 # empty library instead.
143 if self._subfolder and not await isdir(self.base_path):
144 # a failed handle_async_init never gets unload(), so drop the mount here; failing
145 # to must not mask the missing subfolder
146 with suppress(SetupFailedError, OSError):
147 await unmount(self.mount_path, self.logger)
148 msg = f"Subfolder {self._subfolder} does not exist in the NFS export"
149 raise SetupFailedError(
150 msg,
151 translation_key="subfolder_not_found",
152 translation_owner=self.translation_owner,
153 translation_args=[self._subfolder],
154 )
155 await self.check_write_access()
156
157 async def unload(self, is_removed: bool = False) -> None:
158 """
159 Handle unload/close of the provider.
160
161 Called when provider is deregistered (e.g. MA exiting or config reloading).
162 """
163 await super().unload(is_removed)
164 await unmount(self.mount_path, self.logger)
165
166 async def get_diagnostics(self) -> dict[str, SerializableType]:
167 """Return diagnostics info for this provider to include in diagnostics reports."""
168 return {
169 **await super().get_diagnostics(),
170 "mounted": await ismount(self.mount_path),
171 }
172
173 async def mount(self) -> None:
174 """Mount the NFS export to a temporary folder."""
175 server = str(self.get_setup_value(CONF_HOST))
176 export_path = str(self.get_setup_value(CONF_EXPORT_PATH))
177
178 if platform.system() not in ("Linux", "Darwin"):
179 msg = f"NFS provider is not supported on {platform.system()}"
180 raise UnsupportedSystemError(msg)
181
182 mount_options = self._get_mount_options()
183 mount_cmd = [
184 "mount",
185 "-t",
186 "nfs",
187 "-o",
188 ",".join(mount_options),
189 f"{server}:{export_path}",
190 self.mount_path,
191 ]
192
193 self.logger.debug("Mounting %s:%s to %s", server, export_path, self.mount_path)
194 self.logger.log(VERBOSE_LOG_LEVEL, "Using mount command: %s", " ".join(mount_cmd))
195 returncode: int
196 output: bytes
197 returncode, output = await check_output(*mount_cmd)
198 if returncode != 0:
199 error = output.decode().strip()
200 msg = f"NFS mount failed with error: {error}"
201 raise SetupFailedError(
202 msg,
203 translation_key="mount_failed",
204 translation_args=[error_summary(error)],
205 )
206
207 def _get_mount_options(self) -> list[str]:
208 """Get platform-specific NFS mount options."""
209 if platform.system() == "Darwin":
210 options = ["resvport", "noatime", "soft", "timeo=30", "retrans=5"]
211 else:
212 options = ["noatime", "nolock", "tcp", "soft", "timeo=30", "retrans=5"]
213
214 nfs_version = str(self.get_setup_value(CONF_NFS_VERSION) or "")
215 if nfs_version:
216 options.append(f"vers={nfs_version}")
217
218 return options
219