/
/
/
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, passing the resolved track
243 # for richer metadata (MusicBrainz IDs) than the playback report alone
244 self.mass.create_task(self._request_from_lidarr(report, track))
245
246 async def _resolve_track(self, uri: str) -> Track | None:
247 """Resolve a track URI to a full Track object with provider mappings.
248
249 :param uri: Music Assistant track URI.
250 """
251 try:
252 item = await self.mass.music.get_item_by_uri(uri)
253 if item.media_type == MediaType.TRACK:
254 return item # type: ignore[return-value]
255 except Exception:
256 self.logger.debug("Failed to resolve URI: %s", uri)
257 return None
258
259 def _is_monitored_streaming_provider(self, domain: str) -> bool:
260 """Check if a provider domain is one we should monitor.
261
262 :param domain: Provider domain string.
263 """
264 if domain in self._local_domains:
265 return False
266 if not self._streaming_domains:
267 # empty set means monitor all non-local providers
268 return True
269 return domain in self._streaming_domains
270
271 @staticmethod
272 def _get_artist_mbid(track: Track, report: MediaItemPlaybackProgressReport) -> str | None:
273 """Extract the best available artist MusicBrainz ID.
274
275 Prefers the resolved track's artist metadata over the playback report,
276 as the report often has None for MBIDs.
277
278 :param track: Resolved Track object.
279 :param report: Playback progress report.
280 """
281 # try the resolved track's artists first
282 for artist in track.artists:
283 if artist.mbid:
284 return artist.mbid
285
286 # fall back to the playback report
287 if report.artist_mbids:
288 return report.artist_mbids[0]
289
290 return None
291
292 @staticmethod
293 def _get_album_mbid(track: Track, report: MediaItemPlaybackProgressReport) -> str | None:
294 """Extract the best available album MusicBrainz ID.
295
296 :param track: Resolved Track object.
297 :param report: Playback progress report.
298 """
299 if track.album and track.album.mbid:
300 return track.album.mbid
301 return report.album_mbid
302
303 def _build_dedup_key(self, report: MediaItemPlaybackProgressReport) -> str:
304 """Build a deduplication key for an album request.
305
306 Uses MusicBrainz album ID when available, falls back to artist+album string.
307
308 :param report: Playback progress report.
309 """
310 if report.album_mbid:
311 return f"mbid:{report.album_mbid}"
312 return f"name:{report.artist}:{report.album}".lower()
313
314 async def _request_from_lidarr(
315 self,
316 report: MediaItemPlaybackProgressReport,
317 track: Track,
318 ) -> None:
319 """Send a request to Lidarr for the album from the playback report.
320
321 Uses the resolved Track object for richer metadata (MusicBrainz IDs)
322 that the playback report often lacks.
323
324 :param report: Playback progress report containing track/album/artist info.
325 :param track: Resolved Track object with full artist/album metadata.
326 """
327 artist_name = report.artist or "Unknown Artist"
328 album_title = report.album or "Unknown Album"
329
330 # extract MusicBrainz IDs from the resolved track, falling back to the report
331 artist_mbid = self._get_artist_mbid(track, report)
332 album_mbid = self._get_album_mbid(track, report)
333
334 self.logger.info(
335 "Requesting album '%s' by '%s' from Lidarr (artist_mbid=%s, album_mbid=%s)",
336 album_title,
337 artist_name,
338 artist_mbid,
339 album_mbid,
340 )
341
342 try:
343 result: LidarrRequestResult = await self._client.request_album(
344 artist_name=artist_name,
345 album_title=album_title,
346 artist_mbid=artist_mbid,
347 album_mbid=album_mbid,
348 )
349 except Exception:
350 self.logger.exception(
351 "Unexpected error requesting '%s' by '%s' from Lidarr",
352 album_title,
353 artist_name,
354 )
355 return
356
357 # log the result at appropriate level
358 if result.success:
359 self.logger.info(
360 "Lidarr request result [%s]: %s",
361 result.action,
362 result.message,
363 )
364 else:
365 self.logger.warning(
366 "Lidarr request failed [%s]: %s",
367 result.action,
368 result.message,
369 )
370