/
/
/
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 # the connection details come from the setup flow, so they live in setup_data;
55 # get_setup_value falls back to the config entries for instances set up before
56 # the flow existed
57 lidarr_url = str(self.get_setup_value(CONF_LIDARR_URL, "http://localhost:8686"))
58 api_key = str(self.get_setup_value(CONF_LIDARR_API_KEY, ""))
59 quality_profile_id = int(self.get_setup_value(CONF_QUALITY_PROFILE_ID, 1))
60 metadata_profile_id = int(self.get_setup_value(CONF_METADATA_PROFILE_ID, 1))
61 root_folder_path = str(self.get_setup_value(CONF_ROOT_FOLDER_PATH, "/music"))
62
63 if not api_key:
64 msg = "Lidarr API key is required"
65 raise SetupFailedError(msg)
66
67 # parse domain lists
68 streaming_raw = str(self.config.get_value(CONF_STREAMING_PROVIDERS, ""))
69 if streaming_raw.strip():
70 self._streaming_domains = {d.strip() for d in streaming_raw.split(",") if d.strip()}
71 else:
72 self._streaming_domains = set() # empty means monitor all streaming providers
73
74 local_raw = str(
75 self.config.get_value(CONF_LOCAL_PROVIDER_DOMAINS, "filesystem_local,filesystem_smb")
76 )
77 if local_raw.strip():
78 self._local_domains = {d.strip() for d in local_raw.split(",") if d.strip()}
79 else:
80 self._local_domains = DEFAULT_LOCAL_DOMAINS
81
82 self._client = LidarrClient(
83 base_url=lidarr_url,
84 api_key=api_key,
85 quality_profile_id=quality_profile_id,
86 metadata_profile_id=metadata_profile_id,
87 root_folder_path=root_folder_path,
88 logger=self.logger,
89 )
90
91 # verify connection
92 if not await self._client.test_connection():
93 msg = f"Cannot connect to Lidarr at {lidarr_url}"
94 raise SetupFailedError(msg)
95
96 self.logger.info("Connected to Lidarr at %s", lidarr_url)
97
98 async def loaded_in_mass(self) -> None:
99 """Register event subscriptions after provider load."""
100 await super().loaded_in_mass()
101
102 self._on_unload.append(
103 self.mass.subscribe(
104 self._on_media_item_played,
105 EventType.MEDIA_ITEM_PLAYED,
106 )
107 )
108 self.logger.info(
109 "Lidarr integration active â monitoring streaming playback for auto-requests"
110 )
111
112 async def unload(self, is_removed: bool = False) -> None:
113 """Handle unload/close of the provider."""
114 for unload_cb in self._on_unload:
115 unload_cb()
116 self._on_unload.clear()
117 await self._client.close()
118
119 async def _on_media_item_played(self, event: MassEvent) -> None:
120 """Handle MEDIA_ITEM_PLAYED events.
121
122 Fires every ~30 seconds during playback and when a track finishes.
123 We only act on the first detection of a new track (is_playing=True)
124 to avoid duplicate requests.
125 """
126 if not self._auto_request_enabled:
127 return
128
129 report: MediaItemPlaybackProgressReport = event.data
130
131 self.logger.info(
132 "Received playback event: '%s' by %s (type=%s, playing=%s, album=%s, uri=%s)",
133 report.name,
134 report.artist,
135 report.media_type,
136 report.is_playing,
137 report.album,
138 report.uri,
139 )
140
141 # only process tracks (not podcasts, audiobooks, etc.)
142 if report.media_type != MediaType.TRACK:
143 self.logger.info("Skipping: media_type is %s, not track", report.media_type)
144 return
145
146 # only act on initial detection (is_playing and not yet processed)
147 if not report.is_playing:
148 self.logger.info("Skipping: not currently playing")
149 return
150
151 # skip if no album info (singles without album context)
152 if not report.album:
153 self.logger.info(
154 "Skipping '%s' by %s â no album information available",
155 report.name,
156 report.artist,
157 )
158 return
159
160 # create a dedup key from the album info
161 dedup_key = self._build_dedup_key(report)
162 if dedup_key in self._requested_albums:
163 self.logger.info("Skipping '%s' â already requested (key: %s)", report.album, dedup_key)
164 return
165
166 # check provider mappings to determine if local copy exists
167 await self._process_track(report, dedup_key)
168
169 async def _process_track(
170 self,
171 report: MediaItemPlaybackProgressReport,
172 dedup_key: str,
173 ) -> None:
174 """Resolve the track and decide whether to send a Lidarr request.
175
176 :param report: Playback progress report.
177 :param dedup_key: Deduplication key for this album.
178 """
179 async with self._processing_lock:
180 # double-check after acquiring lock
181 if dedup_key in self._requested_albums:
182 return
183
184 try:
185 track = await self._resolve_track(report.uri)
186 except Exception:
187 self.logger.info(
188 "Could not resolve track URI '%s', skipping Lidarr check",
189 report.uri,
190 )
191 return
192
193 if track is None:
194 self.logger.info("Track resolved to None for URI '%s'", report.uri)
195 return
196
197 # log all provider mappings for visibility
198 mappings_str = ", ".join(
199 f"{pm.provider_domain}({pm.provider_instance}, avail={pm.available})"
200 for pm in track.provider_mappings
201 )
202 self.logger.info(
203 "Track '%s' provider mappings: [%s]",
204 report.name,
205 mappings_str,
206 )
207
208 # check if the track has a local provider mapping
209 has_local = any(
210 pm.provider_domain in self._local_domains and pm.available
211 for pm in track.provider_mappings
212 )
213
214 if has_local:
215 self.logger.info(
216 "Track '%s' by %s has local copy, skipping Lidarr request",
217 report.name,
218 report.artist,
219 )
220 # still mark as processed so we don't re-check every 30s
221 self._requested_albums.add(dedup_key)
222 return
223
224 # check if track is from a streaming provider we should monitor
225 has_streaming = any(
226 self._is_monitored_streaming_provider(pm.provider_domain)
227 for pm in track.provider_mappings
228 if pm.available
229 )
230
231 if not has_streaming:
232 self.logger.info(
233 "Track '%s' by %s not from monitored streaming providers "
234 "(local_domains=%s, streaming_domains=%s), skipping",
235 report.name,
236 report.artist,
237 self._local_domains,
238 self._streaming_domains,
239 )
240 return
241
242 # mark as requested before the async call to prevent duplicates
243 self._requested_albums.add(dedup_key)
244
245 # fire and forget the Lidarr request, passing the resolved track
246 # for richer metadata (MusicBrainz IDs) than the playback report alone
247 self.mass.create_task(self._request_from_lidarr(report, track))
248
249 async def _resolve_track(self, uri: str) -> Track | None:
250 """Resolve a track URI to a full Track object with provider mappings.
251
252 :param uri: Music Assistant track URI.
253 """
254 try:
255 item = await self.mass.music.get_item_by_uri(uri)
256 if item.media_type == MediaType.TRACK:
257 return item # type: ignore[return-value]
258 except Exception:
259 self.logger.debug("Failed to resolve URI: %s", uri)
260 return None
261
262 def _is_monitored_streaming_provider(self, domain: str) -> bool:
263 """Check if a provider domain is one we should monitor.
264
265 :param domain: Provider domain string.
266 """
267 if domain in self._local_domains:
268 return False
269 if not self._streaming_domains:
270 # empty set means monitor all non-local providers
271 return True
272 return domain in self._streaming_domains
273
274 @staticmethod
275 def _get_artist_mbid(track: Track, report: MediaItemPlaybackProgressReport) -> str | None:
276 """Extract the best available artist MusicBrainz ID.
277
278 Prefers the resolved track's artist metadata over the playback report,
279 as the report often has None for MBIDs.
280
281 :param track: Resolved Track object.
282 :param report: Playback progress report.
283 """
284 # try the resolved track's artists first
285 for artist in track.artists:
286 if artist.mbid:
287 return artist.mbid
288
289 # fall back to the playback report
290 if report.artist_mbids:
291 return report.artist_mbids[0]
292
293 return None
294
295 @staticmethod
296 def _get_album_mbid(track: Track, report: MediaItemPlaybackProgressReport) -> str | None:
297 """Extract the best available album MusicBrainz ID.
298
299 :param track: Resolved Track object.
300 :param report: Playback progress report.
301 """
302 if track.album and track.album.mbid:
303 return track.album.mbid
304 return report.album_mbid
305
306 def _build_dedup_key(self, report: MediaItemPlaybackProgressReport) -> str:
307 """Build a deduplication key for an album request.
308
309 Uses MusicBrainz album ID when available, falls back to artist+album string.
310
311 :param report: Playback progress report.
312 """
313 if report.album_mbid:
314 return f"mbid:{report.album_mbid}"
315 return f"name:{report.artist}:{report.album}".lower()
316
317 async def _request_from_lidarr(
318 self,
319 report: MediaItemPlaybackProgressReport,
320 track: Track,
321 ) -> None:
322 """Send a request to Lidarr for the album from the playback report.
323
324 Uses the resolved Track object for richer metadata (MusicBrainz IDs)
325 that the playback report often lacks.
326
327 :param report: Playback progress report containing track/album/artist info.
328 :param track: Resolved Track object with full artist/album metadata.
329 """
330 artist_name = report.artist or "Unknown Artist"
331 album_title = report.album or "Unknown Album"
332
333 # extract MusicBrainz IDs from the resolved track, falling back to the report
334 artist_mbid = self._get_artist_mbid(track, report)
335 album_mbid = self._get_album_mbid(track, report)
336
337 self.logger.info(
338 "Requesting album '%s' by '%s' from Lidarr (artist_mbid=%s, album_mbid=%s)",
339 album_title,
340 artist_name,
341 artist_mbid,
342 album_mbid,
343 )
344
345 try:
346 result: LidarrRequestResult = await self._client.request_album(
347 artist_name=artist_name,
348 album_title=album_title,
349 artist_mbid=artist_mbid,
350 album_mbid=album_mbid,
351 )
352 except Exception:
353 self.logger.exception(
354 "Unexpected error requesting '%s' by '%s' from Lidarr",
355 album_title,
356 artist_name,
357 )
358 return
359
360 # log the result at appropriate level
361 if result.success:
362 self.logger.info(
363 "Lidarr request result [%s]: %s",
364 result.action,
365 result.message,
366 )
367 else:
368 self.logger.warning(
369 "Lidarr request failed [%s]: %s",
370 result.action,
371 result.message,
372 )
373