/
/
/
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 # only process tracks (not podcasts, audiobooks, etc.)
129 if report.media_type != MediaType.TRACK:
130 return
131
132 # only act on initial detection (is_playing and not yet processed)
133 if not report.is_playing:
134 return
135
136 # skip if no album info (singles without album context)
137 if not report.album:
138 self.logger.debug(
139 "Skipping '%s' by %s â no album information available",
140 report.name,
141 report.artist,
142 )
143 return
144
145 # create a dedup key from the album info
146 dedup_key = self._build_dedup_key(report)
147 if dedup_key in self._requested_albums:
148 return
149
150 # check provider mappings to determine if local copy exists
151 await self._process_track(report, dedup_key)
152
153 async def _process_track(
154 self,
155 report: MediaItemPlaybackProgressReport,
156 dedup_key: str,
157 ) -> None:
158 """Resolve the track and decide whether to send a Lidarr request.
159
160 :param report: Playback progress report.
161 :param dedup_key: Deduplication key for this album.
162 """
163 async with self._processing_lock:
164 # double-check after acquiring lock
165 if dedup_key in self._requested_albums:
166 return
167
168 try:
169 track = await self._resolve_track(report.uri)
170 except Exception:
171 self.logger.debug(
172 "Could not resolve track URI '%s', skipping Lidarr check",
173 report.uri,
174 )
175 return
176
177 if track is None:
178 return
179
180 # check if the track has a local provider mapping
181 has_local = any(
182 pm.provider_domain in self._local_domains and pm.available
183 for pm in track.provider_mappings
184 )
185
186 if has_local:
187 self.logger.debug(
188 "Track '%s' by %s has local copy, skipping Lidarr request",
189 report.name,
190 report.artist,
191 )
192 # still mark as processed so we don't re-check every 30s
193 self._requested_albums.add(dedup_key)
194 return
195
196 # check if track is from a streaming provider we should monitor
197 has_streaming = any(
198 self._is_monitored_streaming_provider(pm.provider_domain)
199 for pm in track.provider_mappings
200 if pm.available
201 )
202
203 if not has_streaming:
204 self.logger.debug(
205 "Track '%s' by %s not from monitored streaming providers, skipping",
206 report.name,
207 report.artist,
208 )
209 return
210
211 # mark as requested before the async call to prevent duplicates
212 self._requested_albums.add(dedup_key)
213
214 # fire and forget the Lidarr request
215 self.mass.create_task(self._request_from_lidarr(report))
216
217 async def _resolve_track(self, uri: str) -> Track | None:
218 """Resolve a track URI to a full Track object with provider mappings.
219
220 :param uri: Music Assistant track URI.
221 """
222 try:
223 item = await self.mass.music.get_item_by_uri(uri)
224 if item.media_type == MediaType.TRACK:
225 return item # type: ignore[return-value]
226 except Exception:
227 self.logger.debug("Failed to resolve URI: %s", uri)
228 return None
229
230 def _is_monitored_streaming_provider(self, domain: str) -> bool:
231 """Check if a provider domain is one we should monitor.
232
233 :param domain: Provider domain string.
234 """
235 if domain in self._local_domains:
236 return False
237 if not self._streaming_domains:
238 # empty set means monitor all non-local providers
239 return True
240 return domain in self._streaming_domains
241
242 def _build_dedup_key(self, report: MediaItemPlaybackProgressReport) -> str:
243 """Build a deduplication key for an album request.
244
245 Uses MusicBrainz album ID when available, falls back to artist+album string.
246
247 :param report: Playback progress report.
248 """
249 if report.album_mbid:
250 return f"mbid:{report.album_mbid}"
251 return f"name:{report.artist}:{report.album}".lower()
252
253 async def _request_from_lidarr(self, report: MediaItemPlaybackProgressReport) -> None:
254 """Send a request to Lidarr for the album from the playback report.
255
256 :param report: Playback progress report containing track/album/artist info.
257 """
258 artist_name = report.artist or "Unknown Artist"
259 album_title = report.album or "Unknown Album"
260
261 # prefer the first artist MusicBrainz ID
262 artist_mbid = report.artist_mbids[0] if report.artist_mbids else None
263 album_mbid = report.album_mbid
264
265 self.logger.info(
266 "Requesting album '%s' by '%s' from Lidarr (artist_mbid=%s, album_mbid=%s)",
267 album_title,
268 artist_name,
269 artist_mbid,
270 album_mbid,
271 )
272
273 try:
274 result: LidarrRequestResult = await self._client.request_album(
275 artist_name=artist_name,
276 album_title=album_title,
277 artist_mbid=artist_mbid,
278 album_mbid=album_mbid,
279 )
280 except Exception:
281 self.logger.exception(
282 "Unexpected error requesting '%s' by '%s' from Lidarr",
283 album_title,
284 artist_name,
285 )
286 return
287
288 # log the result at appropriate level
289 if result.success:
290 self.logger.info(
291 "Lidarr request result [%s]: %s",
292 result.action,
293 result.message,
294 )
295 else:
296 self.logger.warning(
297 "Lidarr request failed [%s]: %s",
298 result.action,
299 result.message,
300 )
301