/
/
/
1"""Lidarr Integration plugin provider for Music Assistant."""
2
3from __future__ import annotations
4
5import asyncio
6from collections.abc import Callable
7from typing import TYPE_CHECKING
8
9from music_assistant_models.enums import EventType, MediaType
10from music_assistant_models.errors import SetupFailedError
11
12from music_assistant.models.plugin import PluginProvider
13from music_assistant.providers.lidarr_integration.constants import (
14 CONF_ENABLED,
15 CONF_LIDARR_API_KEY,
16 CONF_LIDARR_URL,
17 CONF_LOCAL_PROVIDER_DOMAINS,
18 CONF_METADATA_PROFILE_ID,
19 CONF_QUALITY_PROFILE_ID,
20 CONF_ROOT_FOLDER_PATH,
21 CONF_STREAMING_PROVIDERS,
22 DEFAULT_LOCAL_DOMAINS,
23)
24from music_assistant.providers.lidarr_integration.lidarr_client import (
25 LidarrClient,
26 LidarrRequestResult,
27)
28
29if TYPE_CHECKING:
30 from music_assistant_models.event import MassEvent
31 from music_assistant_models.media_items import Track
32 from music_assistant_models.playback_progress_report import MediaItemPlaybackProgressReport
33
34
35class LidarrIntegrationProvider(PluginProvider):
36 """Plugin provider that monitors playback and requests albums from Lidarr."""
37
38 _client: LidarrClient
39 _on_unload: list[Callable[[], None]]
40 _requested_albums: set[str]
41 _processing_lock: asyncio.Lock
42 _local_domains: set[str]
43 _streaming_domains: set[str]
44 _auto_request_enabled: bool
45
46 async def handle_async_init(self) -> None:
47 """Handle async initialization of the provider."""
48 self._on_unload = []
49 self._requested_albums = set()
50 self._processing_lock = asyncio.Lock()
51
52 self._auto_request_enabled = bool(self.config.get_value(CONF_ENABLED, True))
53
54 lidarr_url = str(self.config.get_value(CONF_LIDARR_URL, "http://localhost:8686"))
55 api_key = str(self.config.get_value(CONF_LIDARR_API_KEY, ""))
56 quality_profile_id = int(self.config.get_value(CONF_QUALITY_PROFILE_ID, 1))
57 metadata_profile_id = int(self.config.get_value(CONF_METADATA_PROFILE_ID, 1))
58 root_folder_path = str(self.config.get_value(CONF_ROOT_FOLDER_PATH, "/music"))
59
60 if not api_key:
61 msg = "Lidarr API key is required"
62 raise SetupFailedError(msg)
63
64 # parse domain lists
65 streaming_raw = str(self.config.get_value(CONF_STREAMING_PROVIDERS, ""))
66 if streaming_raw.strip():
67 self._streaming_domains = {d.strip() for d in streaming_raw.split(",") if d.strip()}
68 else:
69 self._streaming_domains = set() # empty means monitor all streaming providers
70
71 local_raw = str(
72 self.config.get_value(CONF_LOCAL_PROVIDER_DOMAINS, "filesystem_local,filesystem_smb")
73 )
74 if local_raw.strip():
75 self._local_domains = {d.strip() for d in local_raw.split(",") if d.strip()}
76 else:
77 self._local_domains = DEFAULT_LOCAL_DOMAINS
78
79 self._client = LidarrClient(
80 base_url=lidarr_url,
81 api_key=api_key,
82 quality_profile_id=quality_profile_id,
83 metadata_profile_id=metadata_profile_id,
84 root_folder_path=root_folder_path,
85 logger=self.logger,
86 )
87
88 # verify connection
89 if not await self._client.test_connection():
90 msg = f"Cannot connect to Lidarr at {lidarr_url}"
91 raise SetupFailedError(msg)
92
93 self.logger.info("Connected to Lidarr at %s", lidarr_url)
94
95 async def loaded_in_mass(self) -> None:
96 """Register event subscriptions after provider load."""
97 await super().loaded_in_mass()
98
99 self._on_unload.append(
100 self.mass.subscribe(
101 self._on_media_item_played,
102 EventType.MEDIA_ITEM_PLAYED,
103 )
104 )
105 self.logger.info(
106 "Lidarr integration active â monitoring streaming playback for auto-requests"
107 )
108
109 async def unload(self, is_removed: bool = False) -> None:
110 """Handle unload/close of the provider."""
111 for unload_cb in self._on_unload:
112 unload_cb()
113 self._on_unload.clear()
114 await self._client.close()
115
116 async def _on_media_item_played(self, event: MassEvent) -> None:
117 """Handle MEDIA_ITEM_PLAYED events.
118
119 Fires every ~30 seconds during playback and when a track finishes.
120 We only act on the first detection of a new track (is_playing=True)
121 to avoid duplicate requests.
122 """
123 if not self._auto_request_enabled:
124 return
125
126 report: MediaItemPlaybackProgressReport = event.data
127
128 self.logger.info(
129 "Received playback event: '%s' by %s (type=%s, playing=%s, album=%s, uri=%s)",
130 report.name,
131 report.artist,
132 report.media_type,
133 report.is_playing,
134 report.album,
135 report.uri,
136 )
137
138 # only process tracks (not podcasts, audiobooks, etc.)
139 if report.media_type != MediaType.TRACK:
140 self.logger.info("Skipping: media_type is %s, not track", report.media_type)
141 return
142
143 # only act on initial detection (is_playing and not yet processed)
144 if not report.is_playing:
145 self.logger.info("Skipping: not currently playing")
146 return
147
148 # skip if no album info (singles without album context)
149 if not report.album:
150 self.logger.info(
151 "Skipping '%s' by %s â no album information available",
152 report.name,
153 report.artist,
154 )
155 return
156
157 # create a dedup key from the album info
158 dedup_key = self._build_dedup_key(report)
159 if dedup_key in self._requested_albums:
160 self.logger.info("Skipping '%s' â already requested (key: %s)", report.album, dedup_key)
161 return
162
163 # check provider mappings to determine if local copy exists
164 await self._process_track(report, dedup_key)
165
166 async def _process_track(
167 self,
168 report: MediaItemPlaybackProgressReport,
169 dedup_key: str,
170 ) -> None:
171 """Resolve the track and decide whether to send a Lidarr request.
172
173 :param report: Playback progress report.
174 :param dedup_key: Deduplication key for this album.
175 """
176 async with self._processing_lock:
177 # double-check after acquiring lock
178 if dedup_key in self._requested_albums:
179 return
180
181 try:
182 track = await self._resolve_track(report.uri)
183 except Exception:
184 self.logger.info(
185 "Could not resolve track URI '%s', skipping Lidarr check",
186 report.uri,
187 )
188 return
189
190 if track is None:
191 self.logger.info("Track resolved to None for URI '%s'", report.uri)
192 return
193
194 # log all provider mappings for visibility
195 mappings_str = ", ".join(
196 f"{pm.provider_domain}({pm.provider_instance}, avail={pm.available})"
197 for pm in track.provider_mappings
198 )
199 self.logger.info(
200 "Track '%s' provider mappings: [%s]",
201 report.name,
202 mappings_str,
203 )
204
205 # check if the track has a local provider mapping
206 has_local = any(
207 pm.provider_domain in self._local_domains and pm.available
208 for pm in track.provider_mappings
209 )
210
211 if has_local:
212 self.logger.info(
213 "Track '%s' by %s has local copy, skipping Lidarr request",
214 report.name,
215 report.artist,
216 )
217 # still mark as processed so we don't re-check every 30s
218 self._requested_albums.add(dedup_key)
219 return
220
221 # check if track is from a streaming provider we should monitor
222 has_streaming = any(
223 self._is_monitored_streaming_provider(pm.provider_domain)
224 for pm in track.provider_mappings
225 if pm.available
226 )
227
228 if not has_streaming:
229 self.logger.info(
230 "Track '%s' by %s not from monitored streaming providers "
231 "(local_domains=%s, streaming_domains=%s), skipping",
232 report.name,
233 report.artist,
234 self._local_domains,
235 self._streaming_domains,
236 )
237 return
238
239 # mark as requested before the async call to prevent duplicates
240 self._requested_albums.add(dedup_key)
241
242 # fire and forget the Lidarr request
243 self.mass.create_task(self._request_from_lidarr(report))
244
245 async def _resolve_track(self, uri: str) -> Track | None:
246 """Resolve a track URI to a full Track object with provider mappings.
247
248 :param uri: Music Assistant track URI.
249 """
250 try:
251 item = await self.mass.music.get_item_by_uri(uri)
252 if item.media_type == MediaType.TRACK:
253 return item # type: ignore[return-value]
254 except Exception:
255 self.logger.debug("Failed to resolve URI: %s", uri)
256 return None
257
258 def _is_monitored_streaming_provider(self, domain: str) -> bool:
259 """Check if a provider domain is one we should monitor.
260
261 :param domain: Provider domain string.
262 """
263 if domain in self._local_domains:
264 return False
265 if not self._streaming_domains:
266 # empty set means monitor all non-local providers
267 return True
268 return domain in self._streaming_domains
269
270 def _build_dedup_key(self, report: MediaItemPlaybackProgressReport) -> str:
271 """Build a deduplication key for an album request.
272
273 Uses MusicBrainz album ID when available, falls back to artist+album string.
274
275 :param report: Playback progress report.
276 """
277 if report.album_mbid:
278 return f"mbid:{report.album_mbid}"
279 return f"name:{report.artist}:{report.album}".lower()
280
281 async def _request_from_lidarr(self, report: MediaItemPlaybackProgressReport) -> None:
282 """Send a request to Lidarr for the album from the playback report.
283
284 :param report: Playback progress report containing track/album/artist info.
285 """
286 artist_name = report.artist or "Unknown Artist"
287 album_title = report.album or "Unknown Album"
288
289 # prefer the first artist MusicBrainz ID
290 artist_mbid = report.artist_mbids[0] if report.artist_mbids else None
291 album_mbid = report.album_mbid
292
293 self.logger.info(
294 "Requesting album '%s' by '%s' from Lidarr (artist_mbid=%s, album_mbid=%s)",
295 album_title,
296 artist_name,
297 artist_mbid,
298 album_mbid,
299 )
300
301 try:
302 result: LidarrRequestResult = await self._client.request_album(
303 artist_name=artist_name,
304 album_title=album_title,
305 artist_mbid=artist_mbid,
306 album_mbid=album_mbid,
307 )
308 except Exception:
309 self.logger.exception(
310 "Unexpected error requesting '%s' by '%s' from Lidarr",
311 album_title,
312 artist_name,
313 )
314 return
315
316 # log the result at appropriate level
317 if result.success:
318 self.logger.info(
319 "Lidarr request result [%s]: %s",
320 result.action,
321 result.message,
322 )
323 else:
324 self.logger.warning(
325 "Lidarr request failed [%s]: %s",
326 result.action,
327 result.message,
328 )
329