/
/
/
1"""Built-in/generic provider to handle media from files and (remote) urls."""
2
3from __future__ import annotations
4
5import asyncio
6import os
7import re
8from collections.abc import AsyncGenerator, Mapping
9from typing import TYPE_CHECKING, Final, cast
10from urllib.parse import urlparse
11
12import aiofiles
13from music_assistant_models.auth import Scope
14from music_assistant_models.background_task import TaskSchedule
15from music_assistant_models.enums import (
16 ContentType,
17 ExternalID,
18 ImageType,
19 MediaType,
20 ProviderFeature,
21 StreamType,
22)
23from music_assistant_models.errors import (
24 InvalidDataError,
25 MediaNotFoundError,
26 ProviderUnavailableError,
27)
28from music_assistant_models.media_items import (
29 Artist,
30 AudioFormat,
31 MediaItem,
32 MediaItemImage,
33 MediaItemMetadata,
34 MediaItemType,
35 Playlist,
36 ProviderMapping,
37 Radio,
38 SoundEffect,
39 Track,
40 UniqueList,
41 media_from_dict,
42)
43from music_assistant_models.streamdetails import StreamDetails
44
45from music_assistant.constants import (
46 GENRE_ICONS_DIR_NAME,
47 MASS_LOGO,
48 PLAYLIST_MEDIA_TYPES,
49 RESOURCES_DIR,
50 VARIOUS_ARTISTS_FANART,
51 PlaylistPlayableItem,
52)
53from music_assistant.controllers.cache import use_cache
54from music_assistant.controllers.tasks.context import (
55 get_current_task_id,
56 report_current_task_failure,
57 update_current_task_progress_from_index,
58 update_current_task_progress_text,
59)
60from music_assistant.helpers.compare import compare_strings
61from music_assistant.helpers.playlists import (
62 ImageInfo,
63 IsHLSPlaylist,
64 PlaylistItem,
65 ProviderMappingInfo,
66 construct_media_item_from_playlist_item,
67 fetch_playlist,
68 generate_m3u,
69 media_item_to_playlist_item,
70 parse_extinf_title,
71 parse_m3u,
72 parse_m3u_playlist_image,
73 parse_m3u_playlist_name,
74)
75from music_assistant.helpers.security import is_safe_path
76from music_assistant.helpers.tags import AudioTags, async_parse_tags
77from music_assistant.helpers.track_filter import filter_tracks, get_track_filter
78from music_assistant.helpers.uri import parse_uri
79from music_assistant.models.music_provider import MusicProvider
80
81from .constants import (
82 ALL_FAVORITE_TRACKS,
83 BUILTIN_PLAYLISTS,
84 BUILTIN_PLAYLISTS_ENTRIES,
85 COLLAGE_IMAGE_PLAYLISTS,
86 CONF_ENTRY_LIBRARY_SYNC_BACK_HIDDEN,
87 CONF_ENTRY_LIBRARY_SYNC_PLAYLISTS_HIDDEN,
88 CONF_ENTRY_LIBRARY_SYNC_RADIOS_HIDDEN,
89 CONF_ENTRY_LIBRARY_SYNC_TRACKS_HIDDEN,
90 CONF_KEY_PLAYLISTS,
91 CONF_KEY_RADIOS,
92 CONF_KEY_TRACKS,
93 DEFAULT_FANART,
94 DEFAULT_THUMB,
95 DYNAMIC_BUILTIN_PLAYLISTS,
96 INFINITE_MIX,
97 INFINITE_MIX_FAVORITES,
98 RANDOM_ALBUM,
99 RANDOM_ARTIST,
100 RANDOM_TRACKS,
101 RECENTLY_ADDED_TRACKS,
102 RECENTLY_PLAYED,
103 StoredItem,
104)
105
106if TYPE_CHECKING:
107 from music_assistant_models.config_entries import ConfigEntry, ProviderConfig
108 from music_assistant_models.provider import ProviderManifest
109
110 from music_assistant.mass import MusicAssistant
111 from music_assistant.models import ProviderInstanceType
112
113CACHE_CATEGORY_MEDIA_INFO: Final[int] = 1
114CACHE_CATEGORY_PLAYLISTS: Final[int] = 2
115
116SUPPORTED_FEATURES = {
117 ProviderFeature.BROWSE,
118 ProviderFeature.LIBRARY_TRACKS,
119 ProviderFeature.LIBRARY_RADIOS,
120 ProviderFeature.LIBRARY_PLAYLISTS,
121 ProviderFeature.LIBRARY_TRACKS_EDIT,
122 ProviderFeature.LIBRARY_RADIOS_EDIT,
123 ProviderFeature.LIBRARY_PLAYLISTS_EDIT,
124 ProviderFeature.PLAYLIST_CREATE,
125 ProviderFeature.PLAYLIST_CREATE_AUDIOBOOKS,
126 ProviderFeature.PLAYLIST_CREATE_PODCAST_EPISODES,
127 ProviderFeature.PLAYLIST_CREATE_RADIOS,
128 ProviderFeature.PLAYLIST_CREATE_MIXED,
129 ProviderFeature.PLAYLIST_TRACKS_EDIT,
130}
131
132
133async def setup(
134 mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
135) -> ProviderInstanceType:
136 """Initialize provider(instance) with given configuration."""
137 return BuiltinProvider(mass, manifest, config, SUPPORTED_FEATURES)
138
139
140class BuiltinProvider(MusicProvider):
141 """Built-in/generic provider to handle (manually added) media from files and (remote) urls."""
142
143 _playlists_dir: str
144 _playlist_lock: asyncio.Lock
145 _playlist_locks: dict[str, asyncio.Lock]
146
147 async def get_config_entries(self) -> tuple[ConfigEntry, ...]:
148 """Return Config entries to setup this provider."""
149 return (
150 *BUILTIN_PLAYLISTS_ENTRIES,
151 # hide some of the default (dynamic) entries for library management
152 CONF_ENTRY_LIBRARY_SYNC_TRACKS_HIDDEN,
153 CONF_ENTRY_LIBRARY_SYNC_PLAYLISTS_HIDDEN,
154 CONF_ENTRY_LIBRARY_SYNC_RADIOS_HIDDEN,
155 CONF_ENTRY_LIBRARY_SYNC_BACK_HIDDEN,
156 )
157
158 async def loaded_in_mass(self) -> None:
159 """Call after the provider has been loaded."""
160 self._playlist_lock = asyncio.Lock()
161 self._playlist_locks = {}
162 self._playlists_dir = os.path.join(self.mass.storage_path, "playlists")
163 if not await asyncio.to_thread(os.path.exists, self._playlists_dir):
164 await asyncio.to_thread(os.mkdir, self._playlists_dir)
165 await super().loaded_in_mass()
166 # run in the background to avoid blocking startup. besides migrating old-style
167 # playlists, this repairs entries whose manually set name or artwork no longer
168 # matches the builtin config, which is not a one-off.
169 # TODO: drop the config->M3U migration after MA 2.9, keep the repair pass
170 self.mass.tasks.register_scheduled_task(
171 task_id="migrate_builtin_playlists",
172 name="Builtin provider playlist migration",
173 handler=self._migrate_playlists,
174 schedule=TaskSchedule.hourly(every=24),
175 initial_delay=60,
176 )
177 # register API commands for manual item management
178 self.mass.register_api_command(
179 "builtin/add_radio", self.add_radio, required_scope=Scope.LIBRARY_WRITE
180 )
181 self.mass.register_api_command(
182 "builtin/add_track", self.add_track, required_scope=Scope.LIBRARY_WRITE
183 )
184
185 @property
186 def is_streaming_provider(self) -> bool:
187 """Return True if the provider is a streaming provider."""
188 return False
189
190 def get_default_library_sync_schedule(self, media_type: MediaType) -> TaskSchedule:
191 """Return the default recurring schedule for builtin library sync tasks."""
192 return TaskSchedule.hourly(every=3)
193
194 async def get_track(self, prov_track_id: str) -> Track:
195 """Get full track details by id."""
196 parsed_item = await self.parse_item(prov_track_id, requested_media_type=MediaType.TRACK)
197 assert isinstance(parsed_item, Track)
198 return parsed_item
199
200 async def get_radio(self, prov_radio_id: str) -> Radio:
201 """Get full radio details by id."""
202 parsed_item = await self.parse_item(prov_radio_id, force_radio=True)
203 assert isinstance(parsed_item, Radio)
204 return parsed_item
205
206 async def get_artist(self, prov_artist_id: str) -> Artist:
207 """Get full artist details by id."""
208 artist = prov_artist_id
209 # this is here for compatibility reasons only
210 return Artist(
211 item_id=artist,
212 provider=self.domain,
213 name=artist,
214 provider_mappings={
215 ProviderMapping(
216 item_id=artist,
217 provider_domain=self.domain,
218 provider_instance=self.instance_id,
219 available=False,
220 )
221 },
222 )
223
224 async def get_playlist(self, prov_playlist_id: str) -> Playlist:
225 """Get full playlist details by id."""
226 if prov_playlist_id in BUILTIN_PLAYLISTS:
227 # this is one of our builtin/default playlists
228 return Playlist(
229 item_id=prov_playlist_id,
230 provider=self.instance_id,
231 name=BUILTIN_PLAYLISTS[prov_playlist_id],
232 translation_key=prov_playlist_id,
233 provider_mappings={
234 ProviderMapping(
235 item_id=prov_playlist_id,
236 provider_domain=self.domain,
237 provider_instance=self.instance_id,
238 )
239 },
240 owner="Music Assistant",
241 is_editable=False,
242 is_dynamic=prov_playlist_id in DYNAMIC_BUILTIN_PLAYLISTS,
243 metadata=MediaItemMetadata(
244 images=UniqueList([DEFAULT_THUMB])
245 if prov_playlist_id in COLLAGE_IMAGE_PLAYLISTS
246 else UniqueList([DEFAULT_THUMB, DEFAULT_FANART]),
247 ),
248 )
249 # user created playlist - read from M3U file on disk
250 playlist_file = os.path.join(self._playlists_dir, f"{prov_playlist_id}.m3u")
251 if not await asyncio.to_thread(os.path.isfile, playlist_file):
252 raise MediaNotFoundError(f"Playlist file not found: {prov_playlist_id}")
253 # read playlist name and image from M3U
254 m3u_data = await self._read_m3u_file(prov_playlist_id)
255 playlist_name = parse_m3u_playlist_name(m3u_data) or prov_playlist_id
256 metadata = MediaItemMetadata()
257 if image_url := parse_m3u_playlist_image(m3u_data):
258 metadata.images = UniqueList(
259 [
260 MediaItemImage(
261 type=ImageType.THUMB,
262 path=image_url,
263 provider=self.domain,
264 remotely_accessible=image_url.startswith("http"),
265 )
266 ]
267 )
268 return Playlist(
269 item_id=prov_playlist_id,
270 provider=self.instance_id,
271 name=playlist_name,
272 provider_mappings={
273 ProviderMapping(
274 item_id=prov_playlist_id,
275 provider_domain=self.domain,
276 provider_instance=self.instance_id,
277 )
278 },
279 owner="Music Assistant",
280 supported_mediatypes={
281 MediaType.AUDIOBOOK,
282 MediaType.PODCAST_EPISODE,
283 MediaType.RADIO,
284 MediaType.SOUND_EFFECT,
285 MediaType.TRACK,
286 },
287 is_editable=True,
288 metadata=metadata,
289 )
290
291 async def get_library_tracks(self) -> AsyncGenerator[Track]:
292 """Retrieve library tracks from the provider."""
293 stored_items: list[StoredItem] = self.mass.config.get(CONF_KEY_TRACKS, [])
294 for item in stored_items:
295 try:
296 yield await self.get_track(item["item_id"])
297 except MediaNotFoundError as err:
298 self.logger.warning("Track %s not found: %s", item, err)
299
300 async def get_library_playlists(self) -> AsyncGenerator[Playlist]:
301 """Retrieve library/subscribed playlists from the provider."""
302 # return user stored playlists from M3U files on disk
303 for filename in await asyncio.to_thread(os.listdir, self._playlists_dir):
304 if not filename.endswith(".m3u"):
305 continue
306 playlist_id = filename[:-4] # strip .m3u extension
307 try:
308 yield await self.get_playlist(playlist_id)
309 except MediaNotFoundError:
310 self.logger.warning("Playlist file %s not found", filename)
311 # return builtin playlists
312 for item_id in BUILTIN_PLAYLISTS:
313 if self.config.get_value(item_id) is False:
314 continue
315 yield await self.get_playlist(item_id)
316
317 async def get_library_radios(self) -> AsyncGenerator[Radio]:
318 """Retrieve library/subscribed radio stations from the provider."""
319 stored_items: list[StoredItem] = self.mass.config.get(CONF_KEY_RADIOS, [])
320 for item in stored_items:
321 try:
322 yield await self.get_radio(item["item_id"])
323 except (MediaNotFoundError, InvalidDataError) as err:
324 self.logger.warning("Radio station %s not found: %s", item, err)
325 yield Radio(
326 item_id=item["item_id"],
327 provider=self.instance_id,
328 name=item["name"],
329 provider_mappings={
330 ProviderMapping(
331 item_id=item["item_id"],
332 provider_domain=self.domain,
333 provider_instance=self.instance_id,
334 available=False,
335 )
336 },
337 )
338
339 async def library_add(self, item: MediaItemType) -> bool:
340 """Add item to provider's library. Return true on success."""
341 if item.media_type == MediaType.TRACK:
342 key = CONF_KEY_TRACKS
343 elif item.media_type == MediaType.RADIO:
344 key = CONF_KEY_RADIOS
345 else:
346 return False
347 stored_item = StoredItem(item_id=item.item_id, name=item.name)
348 if item.image:
349 stored_item["image_url"] = item.image.path
350 stored_items: list[StoredItem] = self.mass.config.get(key, [])
351 # filter out existing
352 stored_items = [x for x in stored_items if x["item_id"] != item.item_id]
353 stored_items.append(stored_item)
354 self.mass.config.set(key, stored_items)
355 return True
356
357 async def library_remove(self, prov_item_id: str, media_type: MediaType) -> bool:
358 """Remove item from provider's library. Return true on success."""
359 if media_type == MediaType.PLAYLIST and prov_item_id in BUILTIN_PLAYLISTS:
360 # user wants to disable/remove one of our builtin playlists
361 # to prevent it comes back, we mark it as disabled in config
362 self._update_config_value(prov_item_id, False)
363 return True
364 if media_type == MediaType.TRACK:
365 # regular manual track URL/path
366 key = CONF_KEY_TRACKS
367 elif media_type == MediaType.RADIO:
368 # regular manual radio URL/path
369 key = CONF_KEY_RADIOS
370 elif media_type == MediaType.PLAYLIST:
371 # user-created playlist removal - delete the M3U file
372 playlist_file = os.path.join(self._playlists_dir, f"{prov_item_id}.m3u")
373 if await asyncio.to_thread(os.path.isfile, playlist_file):
374 async with self._playlist_lock:
375 await asyncio.to_thread(os.remove, playlist_file)
376 return True
377 else:
378 return False
379 stored_items: list[StoredItem] = self.mass.config.get(key, [])
380 stored_items = [x for x in stored_items if x["item_id"] != prov_item_id]
381 self.mass.config.set(key, stored_items)
382 return True
383
384 async def on_item_updated(self, item: MediaItemType) -> None:
385 """
386 Update stored item config when a library item is edited.
387
388 :param item: The updated media item with new metadata.
389 """
390 # find the builtin provider mapping to get the item_id
391 builtin_mapping = next(
392 (pm for pm in item.provider_mappings if pm.provider_domain == self.domain),
393 None,
394 )
395 if not builtin_mapping:
396 return
397
398 if item.media_type == MediaType.PLAYLIST:
399 image_url = item.image.path if item.image else None
400 await self._update_playlist_metadata(builtin_mapping.item_id, item.name, image_url)
401 return
402
403 if item.media_type == MediaType.RADIO:
404 key = CONF_KEY_RADIOS
405 elif item.media_type == MediaType.TRACK:
406 key = CONF_KEY_TRACKS
407 else:
408 return
409
410 # TODO: also allow updating description and other image types
411 stored_items: list[StoredItem] = self.mass.config.get(key, [])
412 for stored_item in stored_items:
413 if stored_item["item_id"] == builtin_mapping.item_id:
414 stored_item["name"] = item.name
415 if item.image:
416 stored_item["image_url"] = item.image.path
417 elif "image_url" in stored_item:
418 del stored_item["image_url"]
419 break
420 self.mass.config.set(key, stored_items)
421
422 async def add_radio(self, url: str, name: str, image_url: str | None = None) -> Radio:
423 """
424 Add a radio station.
425
426 :param url: Stream URL.
427 :param name: Display name.
428 :param image_url: Image URL.
429 """
430 stored_items: list[StoredItem] = self.mass.config.get(CONF_KEY_RADIOS, [])
431 # Remove existing entry with same URL if present
432 stored_items = [x for x in stored_items if x["item_id"] != url]
433 stored_item = StoredItem(item_id=url, name=name)
434 if image_url:
435 stored_item["image_url"] = image_url
436 stored_items.append(stored_item)
437 self.mass.config.set(CONF_KEY_RADIOS, stored_items)
438 # Trigger library sync
439 self.mass.call_later(
440 1,
441 self.mass.music.start_sync,
442 [MediaType.RADIO],
443 [self.instance_id],
444 )
445 return await self.get_radio(url)
446
447 async def add_track(self, url: str, name: str, image_url: str | None = None) -> Track:
448 """
449 Add a track.
450
451 :param url: URL or local path.
452 :param name: Display name.
453 :param image_url: Image URL.
454 """
455 stored_items: list[StoredItem] = self.mass.config.get(CONF_KEY_TRACKS, [])
456 # Remove existing entry with same URL if present
457 stored_items = [x for x in stored_items if x["item_id"] != url]
458 stored_item = StoredItem(item_id=url, name=name)
459 if image_url:
460 stored_item["image_url"] = image_url
461 stored_items.append(stored_item)
462 self.mass.config.set(CONF_KEY_TRACKS, stored_items)
463 # Trigger library sync
464 self.mass.call_later(
465 1,
466 self.mass.music.start_sync,
467 [MediaType.TRACK],
468 [self.instance_id],
469 )
470 return await self.get_track(url)
471
472 async def get_playlist_tracks(
473 self, prov_playlist_id: str, page: int = 0
474 ) -> list[PlaylistPlayableItem]:
475 """Get playlist tracks (paginated, 500 items per page)."""
476 if prov_playlist_id in BUILTIN_PLAYLISTS:
477 if page > 0:
478 return []
479 return list(await self._get_builtin_playlist_tracks(prov_playlist_id))
480 return await self._get_user_playlist_tracks(prov_playlist_id, page)
481
482 async def add_playlist_tracks(self, prov_playlist_id: str, prov_track_ids: list[str]) -> None:
483 """Add track(s) to playlist with full metadata and deduplication."""
484 async with self._get_playlist_lock(prov_playlist_id):
485 m3u_data = await self._read_m3u_file(prov_playlist_id)
486 existing_items = parse_m3u(m3u_data)
487 # build dedup set from existing URIs and provider item_ids
488 existing_item_ids: set[str] = set()
489 for item in existing_items:
490 existing_item_ids.add(item.path)
491 for prov in item.providers:
492 existing_item_ids.add(f"{prov.domain}:{prov.item_id}")
493 entries: list[PlaylistItem] = list(existing_items)
494 for uri in prov_track_ids:
495 if uri in existing_item_ids:
496 continue
497 try:
498 entry = await self._build_m3u_entry_from_uri(uri)
499 except MediaNotFoundError, InvalidDataError, ProviderUnavailableError:
500 self.logger.warning("Can't add %s to playlist - item not found", uri)
501 continue
502 # check dedup against the newly built entry's providers too
503 new_ids = {entry.path}
504 if entry.providers:
505 new_ids.update(f"{p.domain}:{p.item_id}" for p in entry.providers)
506 if new_ids & existing_item_ids:
507 continue
508 existing_item_ids.update(new_ids)
509 entries.append(entry)
510 # write updated M3U file
511 playlist = await self.get_playlist(prov_playlist_id)
512 await self._write_m3u_file(
513 prov_playlist_id,
514 playlist.name,
515 entries,
516 self._get_playlist_image_url(playlist),
517 )
518
519 async def remove_playlist_tracks(
520 self, prov_playlist_id: str, positions_to_remove: tuple[int, ...]
521 ) -> None:
522 """Remove track(s) from playlist."""
523 async with self._get_playlist_lock(prov_playlist_id):
524 m3u_data = await self._read_m3u_file(prov_playlist_id)
525 existing_items = parse_m3u(m3u_data)
526 # remove items by position (1-indexed)
527 for i in sorted(positions_to_remove, reverse=True):
528 del existing_items[i - 1]
529 playlist = await self.get_playlist(prov_playlist_id)
530 await self._write_m3u_file(
531 prov_playlist_id,
532 playlist.name,
533 list(existing_items),
534 self._get_playlist_image_url(playlist),
535 )
536
537 async def create_playlist(self, name: str, media_types: set[MediaType]) -> Playlist:
538 """
539 Create a new playlist on provider with given name.
540
541 The playlist name is used as the filename (sanitized for filesystem safety).
542 """
543 playlist_id = self._sanitize_playlist_id(name)
544 # ensure uniqueness
545 counter = 1
546 base_id = playlist_id
547 while await asyncio.to_thread(
548 os.path.isfile, os.path.join(self._playlists_dir, f"{playlist_id}.m3u")
549 ):
550 playlist_id = f"{base_id} ({counter})"
551 counter += 1
552 # create empty M3U file with header
553 await self._write_m3u_file(playlist_id, name, [])
554 return await self.get_playlist(playlist_id)
555
556 async def import_playlist(self, m3u_data: str) -> Playlist:
557 """
558 Import a playlist from M3U8 format.
559
560 Creates a new playlist and populates it with items from the M3U data.
561 Items with valid MA URIs are added directly. Plain URLs or unresolvable
562 URIs are stored as-is for later matching.
563
564 :param m3u_data: The M3U8 playlist data as a string.
565 """
566 parsed_items = parse_m3u(m3u_data)
567 if not parsed_items:
568 msg = "No items found in M3U data"
569 raise InvalidDataError(msg)
570 playlist_name = parse_m3u_playlist_name(m3u_data) or "Imported Playlist"
571 playlist = await self.create_playlist(
572 playlist_name,
573 media_types={MediaType.TRACK, MediaType.RADIO},
574 )
575 playlist_image_url = parse_m3u_playlist_image(m3u_data)
576 # Write the parsed items directly as the M3U file, preserving all
577 # metadata from the source. This avoids re-resolving items that
578 # already have rich metadata (e.g. exported from another MA instance).
579 await self._write_m3u_file(
580 playlist.item_id,
581 playlist_name,
582 parsed_items,
583 playlist_image_url,
584 )
585 return await self.get_playlist(playlist.item_id)
586
587 async def match_imported_playlist_tracks(
588 self,
589 prov_playlist_id: str,
590 match_providers: list[str] | None = None,
591 ) -> None:
592 """
593 Match imported playlist tracks against available providers.
594
595 Iterates through playlist items whose provider is unavailable,
596 searching other providers for matches using metadata stored in
597 the M3U file. Matched tracks are replaced in-place.
598
599 :param prov_playlist_id: The provider-side playlist ID.
600 :param match_providers: Optional list of provider instance IDs or
601 domains to search. When None, all providers are searched.
602 """
603 m3u_data = await self._read_m3u_file(prov_playlist_id)
604 parsed_items = parse_m3u(m3u_data)
605 if not parsed_items:
606 return
607
608 total = len(parsed_items)
609 matched_count = 0
610 unmatched_count = 0
611 changed = False
612
613 for index, item in enumerate(parsed_items):
614 update_current_task_progress_from_index(
615 index, total, f"Matching track {index + 1}/{total}"
616 )
617 if not item.title:
618 continue
619 # check if the URI's provider is available
620 needs_matching = False
621 media_type = MediaType.TRACK
622 try:
623 media_type, prov_instance, _item_id = await parse_uri(item.path)
624 if media_type == MediaType.RADIO:
625 continue
626 if not self.mass.get_provider(prov_instance):
627 needs_matching = True
628 except Exception:
629 needs_matching = True
630
631 if not needs_matching:
632 continue
633
634 matched_uri = await self._match_track_by_metadata(item, match_providers=match_providers)
635 if matched_uri:
636 item.path = matched_uri
637 changed = True
638 matched_count += 1
639 else:
640 report_current_task_failure(f"No match found for: {item.title}")
641 unmatched_count += 1
642
643 if changed:
644 playlist = await self.get_playlist(prov_playlist_id)
645 await self._write_m3u_file(
646 prov_playlist_id,
647 playlist.name,
648 parsed_items,
649 self._get_playlist_image_url(playlist),
650 )
651
652 self.logger.info(
653 "Import matching: %d matched, %d unmatched out of %d items",
654 matched_count,
655 unmatched_count,
656 total,
657 )
658 update_current_task_progress_from_index(total, total, "Matching complete")
659
660 async def parse_item(
661 self,
662 url: str,
663 force_refresh: bool = False,
664 force_radio: bool = False,
665 requested_media_type: MediaType | None = None,
666 ) -> Track | Radio | SoundEffect:
667 """Parse a plain URL to a Track, Radio, or SoundEffect item."""
668 media_info = await self._get_media_info(url, force_refresh)
669 is_radio = media_info.get("icyname") or not media_info.duration
670 provider_mappings = {
671 ProviderMapping(
672 item_id=url,
673 provider_domain=self.domain,
674 provider_instance=self.instance_id,
675 audio_format=AudioFormat(
676 content_type=ContentType.try_parse(media_info.format),
677 sample_rate=media_info.sample_rate,
678 bit_depth=media_info.bits_per_sample,
679 bit_rate=media_info.bit_rate,
680 ),
681 )
682 }
683 media_item: Track | Radio | SoundEffect
684 if requested_media_type == MediaType.SOUND_EFFECT:
685 media_item = SoundEffect(
686 item_id=url,
687 provider=self.domain,
688 name=media_info.title or url,
689 provider_mappings=provider_mappings,
690 )
691 if media_info.duration:
692 media_item.duration = int(media_info.duration or 0)
693 elif (is_radio or force_radio) and requested_media_type != MediaType.TRACK:
694 # treat as radio, unless a track was explicitly requested: such a track
695 # stays a track, also when its stream carries an ICY name or no duration
696 media_item = Radio(
697 item_id=url,
698 provider=self.domain,
699 name=media_info.get("icyname")
700 or media_info.get("programtitle")
701 or media_info.title
702 or url,
703 provider_mappings=provider_mappings,
704 )
705 else:
706 media_item = Track(
707 item_id=url,
708 provider=self.domain,
709 name=media_info.title or url,
710 duration=int(media_info.duration or 0),
711 artists=UniqueList(
712 [await self.get_artist(artist) for artist in media_info.artists]
713 ),
714 provider_mappings=provider_mappings,
715 )
716
717 if media_info.has_cover_image:
718 media_item.metadata.images = UniqueList(
719 [
720 MediaItemImage(
721 type=ImageType.THUMB,
722 path=url,
723 provider=self.domain,
724 remotely_accessible=False,
725 )
726 ]
727 )
728 if isinstance(media_item, Track | Radio):
729 self._apply_stored_details(media_item)
730 return media_item
731
732 async def resolve_image(self, path: str) -> str | bytes:
733 """
734 Resolve an image from an image path.
735
736 This either returns (a generator to get) raw bytes of the image or
737 a string with an http(s) URL or local path that is accessible from the server.
738 """
739 if path == "logo.png":
740 return MASS_LOGO
741 if path in ("fanart.jpg", "fallback_fanart.jpeg"):
742 return VARIOUS_ARTISTS_FANART
743 if path.startswith(f"{GENRE_ICONS_DIR_NAME}/"):
744 icon_name = path[len(GENRE_ICONS_DIR_NAME) + 1 :]
745 icons_base = RESOURCES_DIR.joinpath(GENRE_ICONS_DIR_NAME)
746 if not is_safe_path(icon_name, str(icons_base)):
747 raise FileNotFoundError(f"Invalid genre icon reference: {path}")
748 return str(icons_base.joinpath(icon_name))
749 return path
750
751 async def get_stream_details(self, item_id: str, media_type: MediaType) -> StreamDetails:
752 """Get stream details for a track, radio stream, or sound effect."""
753 media_info = await self._get_media_info(item_id)
754 is_radio = media_info.get("icyname") or not media_info.duration
755 stream_media_type = (
756 MediaType.SOUND_EFFECT
757 if media_type == MediaType.SOUND_EFFECT
758 else MediaType.RADIO
759 if is_radio
760 else MediaType.TRACK
761 )
762 return StreamDetails(
763 provider=self.instance_id,
764 item_id=item_id,
765 audio_format=AudioFormat(
766 content_type=ContentType.try_parse(media_info.format),
767 sample_rate=media_info.sample_rate,
768 bit_depth=media_info.bits_per_sample,
769 channels=media_info.channels,
770 ),
771 media_type=stream_media_type,
772 stream_type=StreamType.HTTP,
773 path=item_id,
774 can_seek=not is_radio,
775 allow_seek=not is_radio,
776 )
777
778 @staticmethod
779 def _get_playlist_image_url(playlist: Playlist) -> str | None:
780 """Return the playlist-level image URL to persist in the M3U header."""
781 return playlist.image.path if playlist.image else None
782
783 async def _update_playlist_metadata(
784 self, playlist_id: str, new_name: str, image_url: str | None
785 ) -> None:
786 """Update the name and image of a playlist in its M3U file."""
787 if playlist_id in BUILTIN_PLAYLISTS:
788 # builtin playlists are not editable
789 return
790 m3u_data = await self._read_m3u_file(playlist_id)
791 if not m3u_data:
792 return
793 existing_items = parse_m3u(m3u_data)
794 try:
795 await self._write_m3u_file(playlist_id, new_name, list(existing_items), image_url)
796 except OSError as err:
797 self.logger.warning("Failed to update playlist metadata: %s", err)
798
799 async def _match_track_by_metadata(
800 self,
801 item: PlaylistItem,
802 match_providers: list[str] | None = None,
803 ) -> str | None:
804 """
805 Search providers for a track matching the given PlaylistItem metadata.
806
807 Uses ISRC/MusicBrainz ID for exact matching first, then falls back
808 to fuzzy title/artist/duration matching.
809
810 :param item: The PlaylistItem with metadata from the M3U file.
811 :param match_providers: Optional list of provider instance IDs or
812 domains to limit the search.
813 """
814 artist_name, track_name = parse_extinf_title(item.title)
815 if not track_name:
816 return None
817
818 search_query = f"{artist_name} - {track_name}" if artist_name else track_name
819
820 all_providers = self.mass.music.get_unique_providers()
821 if match_providers:
822 provider_domains: dict[str, str] = {}
823 for pid in all_providers:
824 prov = self.mass.get_provider(pid)
825 if prov:
826 provider_domains[pid] = prov.domain
827 all_providers = [
828 pid
829 for pid in all_providers
830 if pid in match_providers or provider_domains.get(pid) in match_providers
831 ]
832
833 best_match: tuple[int, str] | None = None
834
835 for provider_id in all_providers:
836 try:
837 results = await self.mass.music.tracks.search(search_query, provider_id, limit=5)
838 except Exception:
839 self.logger.debug(
840 "Search failed on provider %s for '%s'", provider_id, search_query
841 )
842 continue
843
844 for result in results:
845 if not result.uri:
846 continue
847 score = self._score_track_match(result, item)
848 if score >= 10:
849 self.logger.debug("Exact ID match for '%s' -> %s", search_query, result.uri)
850 return result.uri
851 if score > 0 and (best_match is None or score > best_match[0]):
852 best_match = (score, result.uri)
853
854 if best_match:
855 self.logger.debug(
856 "Matched '%s' -> %s (score=%d)",
857 search_query,
858 best_match[1],
859 best_match[0],
860 )
861 return best_match[1]
862
863 self.logger.info("No match found for '%s'", search_query)
864 return None
865
866 def _score_track_match(
867 self,
868 candidate: Track,
869 item: PlaylistItem,
870 ) -> int:
871 """
872 Score how well a candidate track matches the PlaylistItem metadata.
873
874 Returns 0 for no match, higher scores for better matches.
875 ISRC or MusicBrainz Recording ID match returns 10 (maximum).
876
877 :param candidate: The track from search results.
878 :param item: The PlaylistItem with metadata from the M3U file.
879 """
880 metadata = item.metadata or {}
881 artist_name, track_name = parse_extinf_title(item.title)
882 if not track_name:
883 return 0
884
885 isrc = metadata.get("isrc")
886 mbid = metadata.get("mbid")
887
888 # exact ID matches (cross-provider definitive match)
889 if isrc:
890 candidate_isrc = candidate.get_external_id(ExternalID.ISRC)
891 if candidate_isrc and candidate_isrc.upper() == isrc.upper():
892 return 10
893 if mbid:
894 candidate_mbid = candidate.get_external_id(ExternalID.MB_RECORDING)
895 if candidate_mbid and candidate_mbid.lower() == mbid.lower():
896 return 10
897
898 # media type gate
899 if metadata.get("media_type"):
900 candidate_type = getattr(candidate, "media_type", None)
901 if candidate_type and candidate_type.value != metadata["media_type"]:
902 return 0
903
904 return self._score_fuzzy_metadata(candidate, artist_name, track_name, metadata, item)
905
906 def _score_fuzzy_metadata(
907 self,
908 candidate: Track,
909 artist_name: str | None,
910 track_name: str,
911 metadata: dict[str, str],
912 item: PlaylistItem,
913 ) -> int:
914 """
915 Score fuzzy metadata fields (title, artist, album, duration, version).
916
917 :param candidate: The track from search results.
918 :param artist_name: Parsed artist name from EXTINF, or None.
919 :param track_name: Parsed track title from EXTINF.
920 :param metadata: The #EXTMA metadata dict.
921 :param item: The PlaylistItem (for duration from item.length).
922 """
923 if not compare_strings(candidate.name, track_name, strict=False):
924 return 0
925 score = 1
926
927 if artist_name:
928 candidate_artists = [a.name for a in candidate.artists] if candidate.artists else []
929 if not any(compare_strings(a, artist_name, strict=False) for a in candidate_artists):
930 return 0
931 score += 2
932
933 score += self._score_bonus_fields(candidate, metadata)
934 score += self._score_duration(candidate, item)
935 return score
936
937 @staticmethod
938 def _score_bonus_fields(candidate: Track, metadata: dict[str, str]) -> int:
939 """Score bonus metadata fields: podcast, authors, album, version."""
940 score = 0
941 if metadata.get("podcast"):
942 candidate_podcast = getattr(candidate, "podcast", None)
943 if candidate_podcast and hasattr(candidate_podcast, "name"):
944 if compare_strings(candidate_podcast.name, metadata["podcast"], strict=False):
945 score += 2
946
947 if metadata.get("authors"):
948 candidate_authors = getattr(candidate, "authors", None)
949 if candidate_authors:
950 if compare_strings("; ".join(candidate_authors), metadata["authors"], strict=False):
951 score += 2
952
953 if metadata.get("album"):
954 candidate_album = getattr(candidate, "album", None)
955 if candidate_album and hasattr(candidate_album, "name"):
956 if compare_strings(candidate_album.name, metadata["album"], strict=False):
957 score += 1
958
959 if metadata.get("version"):
960 candidate_version = getattr(candidate, "version", None) or ""
961 if candidate_version and compare_strings(
962 candidate_version, metadata["version"], strict=False
963 ):
964 score += 1
965 elif candidate_version:
966 score -= 1
967
968 return score
969
970 @staticmethod
971 def _score_duration(candidate: Track, item: PlaylistItem) -> int:
972 """Score duration proximity between candidate and playlist item."""
973 try:
974 duration = int(item.length) if item.length else None
975 except ValueError:
976 return 0
977 if duration is None or duration <= 0 or candidate.duration <= 0:
978 return 0
979 diff = abs(candidate.duration - duration)
980 if diff <= 2:
981 return 2
982 if diff <= 5:
983 return 1
984 return 0
985
986 def _get_stored_item(
987 self, item: PlaylistItem, stored_by_media_type: Mapping[str, Mapping[str, StoredItem]]
988 ) -> StoredItem | None:
989 """
990 Return the stored details of a manually added playlist entry, if it is one.
991
992 :param item: The playlist entry as parsed from an M3U file.
993 :param stored_by_media_type: Stored items per media type, each keyed on item_id.
994 """
995 prov_mapping = next((x for x in item.providers if x.domain == self.domain), None)
996 if prov_mapping is None:
997 return None
998 media_type = (item.metadata or {}).get("media_type", "")
999 return stored_by_media_type.get(media_type, {}).get(prov_mapping.item_id)
1000
1001 def _stored_details_differ(
1002 self, item: PlaylistItem, stored_by_media_type: Mapping[str, Mapping[str, StoredItem]]
1003 ) -> bool:
1004 """
1005 Return True when a playlist entry no longer carries its manually set name or image.
1006
1007 :param item: The playlist entry as parsed from an M3U file.
1008 :param stored_by_media_type: Stored items per media type, each keyed on item_id.
1009 """
1010 stored_item = self._get_stored_item(item, stored_by_media_type)
1011 if stored_item is None:
1012 return False
1013 # an M3U file cannot hold the surrounding whitespace of a name, so comparing
1014 # against the raw stored name would report a difference that no rewrite can settle
1015 if stored_item["name"].strip() != (item.metadata or {}).get("name"):
1016 return True
1017 # a stored item without an image is not a difference: cover art from the stream
1018 # is a valid fallback for as long as the user has set no image of their own
1019 if image_url := stored_item.get("image_url"):
1020 # only the thumbnail counts: the same url as another image type still leaves
1021 # the stream's cover art as the one that shows
1022 return not any(
1023 image.type == ImageType.THUMB.value and image.path == image_url
1024 for image in item.images
1025 )
1026 return False
1027
1028 def _restore_stored_details(
1029 self, item: PlaylistItem, stored_by_media_type: Mapping[str, Mapping[str, StoredItem]]
1030 ) -> None:
1031 """
1032 Write the manually set name and image of a playlist entry back into it.
1033
1034 :param item: The playlist entry to update in place.
1035 :param stored_by_media_type: Stored items per media type, each keyed on item_id.
1036 """
1037 stored_item = self._get_stored_item(item, stored_by_media_type)
1038 if stored_item is None:
1039 return
1040 name = stored_item["name"]
1041 item.metadata = {**(item.metadata or {}), "name": name}
1042 # #EXTINF holds "<artists> - <name>" for a track and the plain name for a radio
1043 # station, which has no artists; mirror how the entry would have been written
1044 item.title = f"{', '.join(x.name for x in item.artists)} - {name}" if item.artists else name
1045 if image_url := stored_item.get("image_url"):
1046 item.images = [
1047 ImageInfo(
1048 type=ImageType.THUMB.value,
1049 path=image_url,
1050 provider=self.domain,
1051 remotely_accessible=image_url.startswith("http"),
1052 ),
1053 *(x for x in item.images if x.type != ImageType.THUMB.value),
1054 ]
1055
1056 def _apply_stored_details(self, media_item: Track | Radio) -> None:
1057 """Apply the name and image stored for a manually added track or radio station."""
1058 key = CONF_KEY_RADIOS if isinstance(media_item, Radio) else CONF_KEY_TRACKS
1059 stored_items: list[StoredItem] = self.mass.config.get(key, [])
1060 stored_item = next(
1061 (x for x in stored_items if x["item_id"] == media_item.item_id),
1062 None,
1063 )
1064 if stored_item is None:
1065 return
1066 media_item.name = stored_item["name"]
1067 if image_url := stored_item.get("image_url"):
1068 # the stored image replaces any cover art on the stream, so exactly one
1069 # thumbnail is left to serialise into a playlist entry
1070 media_item.metadata.images = UniqueList(
1071 [
1072 MediaItemImage(
1073 type=ImageType.THUMB,
1074 path=image_url,
1075 provider=self.domain,
1076 remotely_accessible=image_url.startswith("http"),
1077 ),
1078 *(x for x in (media_item.metadata.images or []) if x.type != ImageType.THUMB),
1079 ]
1080 )
1081
1082 async def _resolve_url(self, url: str) -> str:
1083 """
1084 Resolve a URL to the actual stream URL.
1085
1086 ffprobe cannot analyze PLS/M3U files directly as it sees them as text.
1087 This method extracts the actual audio stream URL from playlist files.
1088
1089 :param url: The URL to check and potentially resolve.
1090 :returns: The resolved stream URL, or the original URL if not a playlist.
1091 """
1092 parsed = urlparse(url)
1093 path_lower = parsed.path.lower()
1094 is_playlist = path_lower.endswith(".pls")
1095 if not is_playlist:
1096 return url
1097
1098 try:
1099 playlist_items = await fetch_playlist(self.mass, url, raise_on_hls=False)
1100 for item in playlist_items:
1101 if item.is_url:
1102 return item.path
1103 except (InvalidDataError, IsHLSPlaylist) as err:
1104 self.logger.debug("Failed to resolve playlist URL %s: %s", url, err)
1105
1106 return url
1107
1108 async def _get_media_info(self, url: str, force_refresh: bool = False) -> AudioTags:
1109 """Retrieve mediainfo for url."""
1110 # do we have some cached info for this url ?
1111 cached_info = await self.mass.cache.get(
1112 url, provider=self.instance_id, category=CACHE_CATEGORY_MEDIA_INFO
1113 )
1114 if cached_info and not force_refresh:
1115 return AudioTags.parse(cached_info)
1116 resolved_url = await self._resolve_url(url)
1117 # parse info with ffprobe (and store in cache)
1118 media_info = await async_parse_tags(resolved_url)
1119 if "authSig" in url:
1120 media_info.has_cover_image = False
1121 await self.mass.cache.set(
1122 url, media_info.raw, provider=self.instance_id, category=CACHE_CATEGORY_MEDIA_INFO
1123 )
1124 return media_info
1125
1126 @use_cache(expiration=120, category=CACHE_CATEGORY_PLAYLISTS)
1127 async def _get_builtin_playlist_random_favorite_tracks(self) -> list[Track]:
1128 result: list[Track] = []
1129 res = await self.mass.music.tracks.library_items(
1130 favorite=True, limit=250000, order_by="random_play_count", summary=False
1131 )
1132 for idx, item in enumerate(res, 1):
1133 item.position = idx
1134 result.append(item)
1135 return result
1136
1137 @use_cache(expiration=120, category=CACHE_CATEGORY_PLAYLISTS)
1138 async def _get_builtin_playlist_random_tracks(self) -> list[Track]:
1139 result: list[Track] = []
1140 res = await self.mass.music.tracks.library_items(
1141 limit=500, order_by="random_play_count", summary=False
1142 )
1143 for idx, item in enumerate(res, 1):
1144 item.position = idx
1145 result.append(item)
1146 return result
1147
1148 @use_cache(expiration=3600, category=CACHE_CATEGORY_PLAYLISTS)
1149 async def _get_builtin_playlist_random_album(self) -> list[Track]:
1150 for random_album in await self.mass.music.albums.get_library_items_by_query(
1151 limit=1,
1152 order_by="random",
1153 extra_query_parts=["album_type != :excluded_album_type"],
1154 extra_query_params={"excluded_album_type": "single"},
1155 ):
1156 tracks = await self.mass.music.albums.tracks(
1157 random_album.item_id, random_album.provider
1158 )
1159 for idx, track in enumerate(tracks, 1):
1160 track.position = idx
1161 return tracks
1162 return []
1163
1164 @use_cache(expiration=3600, category=CACHE_CATEGORY_PLAYLISTS)
1165 async def _get_builtin_playlist_random_artist(self) -> list[Track]:
1166 for source in ("library", "top"):
1167 for min_tracks_required in (25, 10, 5, 1):
1168 for random_artist in await self.mass.music.artists.library_items(
1169 limit=25, order_by="random", summary=False
1170 ):
1171 if source == "library":
1172 tracks = await self.mass.music.artists.tracks(
1173 random_artist.item_id, "library"
1174 )
1175 else:
1176 tracks = await self.mass.music.artists.top_tracks(
1177 random_artist.item_id, random_artist.provider
1178 )
1179 if len(tracks) < min_tracks_required:
1180 continue
1181 for idx, track in enumerate(tracks, 1):
1182 track.position = idx
1183 return tracks
1184 return []
1185
1186 @use_cache(expiration=30, category=CACHE_CATEGORY_PLAYLISTS)
1187 async def _get_builtin_playlist_recently_played(self) -> list[Track]:
1188 result: list[Track] = []
1189 recent_tracks = await self.mass.music.recently_played(100, [MediaType.TRACK])
1190 for idx, item in enumerate(recent_tracks, 1):
1191 if not (item_provider := self.mass.get_provider(item.provider)):
1192 continue
1193 track = Track(
1194 item_id=item.item_id,
1195 provider=item.provider,
1196 name=item.name,
1197 provider_mappings={
1198 ProviderMapping(
1199 item_id=item.item_id,
1200 provider_domain=item_provider.domain,
1201 provider_instance=item_provider.instance_id,
1202 )
1203 },
1204 )
1205 if item.image:
1206 track.metadata.add_image(item.image)
1207 track.position = idx
1208 result.append(track)
1209 return result
1210
1211 @use_cache(expiration=60, category=CACHE_CATEGORY_PLAYLISTS)
1212 async def _get_builtin_playlist_recently_added_tracks(self) -> list[Track]:
1213 result: list[Track] = []
1214 recent_tracks = await self.mass.music.recently_added_tracks(100)
1215 for idx, track in enumerate(recent_tracks, 1):
1216 track.position = idx
1217 result.append(track)
1218 return result
1219
1220 async def _get_builtin_playlist_infinite_mix(self) -> list[Track]:
1221 """Return 25 random library tracks for the Infinite Mix dynamic playlist."""
1222 return await self._infinite_mix_tracks(favorite=None)
1223
1224 async def _get_builtin_playlist_infinite_mix_favorites(self) -> list[Track]:
1225 """Return 25 random favorited tracks for the Infinite Mix (favorites) dynamic playlist."""
1226 return await self._infinite_mix_tracks(favorite=True)
1227
1228 async def _infinite_mix_tracks(self, *, favorite: bool | None) -> list[Track]:
1229 """
1230 Return up to 25 random (optionally favorited) library tracks for an Infinite Mix.
1231
1232 :param favorite: Restrict to favorited tracks when True; all library tracks when None.
1233 """
1234 # over-fetch when a recency filter is published so dropping recently-played tracks still
1235 # leaves a full mix; the pool is trimmed back to the mix size after filtering
1236 limit = 25 * 3 if get_track_filter() is not None else 25
1237 candidates = list(
1238 await self.mass.music.tracks.library_items(
1239 favorite=favorite, limit=limit, order_by="random", summary=False
1240 )
1241 )
1242 tracks = filter_tracks(candidates)[:25]
1243 for idx, track in enumerate(tracks, 1):
1244 track.position = idx
1245 return tracks
1246
1247 async def _get_builtin_playlist_tracks(
1248 self, builtin_playlist_id: str
1249 ) -> list[Track] | UniqueList[Track]:
1250 """Get all playlist tracks for given builtin playlist id."""
1251 try:
1252 return await {
1253 ALL_FAVORITE_TRACKS: self._get_builtin_playlist_random_favorite_tracks,
1254 RANDOM_TRACKS: self._get_builtin_playlist_random_tracks,
1255 RANDOM_ALBUM: self._get_builtin_playlist_random_album,
1256 RANDOM_ARTIST: self._get_builtin_playlist_random_artist,
1257 RECENTLY_PLAYED: self._get_builtin_playlist_recently_played,
1258 RECENTLY_ADDED_TRACKS: self._get_builtin_playlist_recently_added_tracks,
1259 INFINITE_MIX: self._get_builtin_playlist_infinite_mix,
1260 INFINITE_MIX_FAVORITES: self._get_builtin_playlist_infinite_mix_favorites,
1261 }[builtin_playlist_id]()
1262 except KeyError:
1263 raise MediaNotFoundError(f"No built in playlist: {builtin_playlist_id}")
1264
1265 async def _read_m3u_file(self, playlist_id: str) -> str:
1266 """Read the raw M3U file content for a playlist."""
1267 playlist_file = os.path.join(self._playlists_dir, f"{playlist_id}.m3u")
1268 if not await asyncio.to_thread(os.path.isfile, playlist_file):
1269 return ""
1270 async with (
1271 self._playlist_lock,
1272 aiofiles.open(playlist_file, encoding="utf-8") as _file,
1273 ):
1274 result: str = await _file.read()
1275 return result
1276
1277 async def _write_m3u_file(
1278 self,
1279 playlist_id: str,
1280 playlist_name: str,
1281 entries: list[PlaylistItem],
1282 playlist_image_url: str | None = None,
1283 ) -> None:
1284 """Write an M3U playlist file to disk."""
1285 m3u_content = generate_m3u(playlist_name, entries, playlist_image_url)
1286 playlist_file = os.path.join(self._playlists_dir, f"{playlist_id}.m3u")
1287 async with (
1288 self._playlist_lock,
1289 aiofiles.open(playlist_file, "w", encoding="utf-8") as _file,
1290 ):
1291 await _file.write(m3u_content)
1292
1293 def _get_playlist_lock(self, playlist_id: str) -> asyncio.Lock:
1294 """Get or create a per-playlist lock for concurrent access protection."""
1295 if playlist_id not in self._playlist_locks:
1296 self._playlist_locks[playlist_id] = asyncio.Lock()
1297 return self._playlist_locks[playlist_id]
1298
1299 async def _resolve_playlist_item(self, item: PlaylistItem) -> MediaItemType | None:
1300 """
1301 Resolve a PlaylistItem to a MediaItem.
1302
1303 Constructs from stored metadata first. If no providers are available,
1304 falls back to a library lookup by domain.
1305 """
1306 media_item = construct_media_item_from_playlist_item(item, self.mass)
1307 if media_item is None:
1308 return None
1309 # if at least one provider mapping is available, we're done
1310 if any(pm.available for pm in media_item.provider_mappings):
1311 return media_item
1312 # all stored provider instances are unavailable - try library lookup by domain
1313 media_type = MediaType((item.metadata or {}).get("media_type", "track"))
1314 if media_type == MediaType.SOUND_EFFECT:
1315 return media_item
1316 media_controller = self.mass.music.get_controller(media_type)
1317 for prov_info in item.providers:
1318 try:
1319 library_item = await media_controller.get_library_item_by_prov_id(
1320 prov_info.item_id, prov_info.domain
1321 )
1322 if library_item is not None:
1323 return library_item
1324 except InvalidDataError, KeyError, NotImplementedError:
1325 continue
1326 # return unresolved media item so the entry still shows in the playlist
1327 return media_item
1328
1329 async def _get_user_playlist_tracks(
1330 self, prov_playlist_id: str, page: int
1331 ) -> list[PlaylistPlayableItem]:
1332 """Get user-created playlist tracks with caching and parallel resolution."""
1333 playlist_file = os.path.join(self._playlists_dir, f"{prov_playlist_id}.m3u")
1334 # use file mtime as cache checksum so edits invalidate the cache
1335 try:
1336 stat = await asyncio.to_thread(os.stat, playlist_file)
1337 cache_checksum = str(int(stat.st_mtime))
1338 except OSError:
1339 cache_checksum = "0"
1340
1341 cache_key = f"playlist_tracks.{prov_playlist_id}.{page}"
1342 cached = await self.mass.cache.get(
1343 cache_key,
1344 provider=self.instance_id,
1345 checksum=cache_checksum,
1346 category=CACHE_CATEGORY_PLAYLISTS,
1347 )
1348 if cached is not None:
1349 # cached data is a list of dicts, deserialize back to media items
1350 return [
1351 cast("PlaylistPlayableItem", media_from_dict(item_dict))
1352 if isinstance(item_dict, dict)
1353 else item_dict
1354 for item_dict in cached
1355 ]
1356
1357 async with self._get_playlist_lock(prov_playlist_id):
1358 m3u_data = await self._read_m3u_file(prov_playlist_id)
1359 all_items = parse_m3u(m3u_data)
1360 page_size = 500
1361 start = page * page_size
1362 if start >= len(all_items):
1363 return []
1364 page_items = all_items[start : start + page_size]
1365
1366 # resolve items in parallel with bounded concurrency
1367 semaphore = asyncio.Semaphore(50)
1368
1369 async def _resolve(index: int, item: PlaylistItem) -> PlaylistPlayableItem | None:
1370 async with semaphore:
1371 try:
1372 media_item = await self._resolve_playlist_item(item)
1373 if media_item is None:
1374 return None
1375 if media_item.media_type not in PLAYLIST_MEDIA_TYPES:
1376 self.logger.warning(
1377 "Unsupported media type in playlist %s: %s",
1378 prov_playlist_id,
1379 type(media_item),
1380 )
1381 return None
1382 playlist_item = cast("PlaylistPlayableItem", media_item)
1383 playlist_item.position = index
1384 return playlist_item
1385 except (
1386 MediaNotFoundError,
1387 InvalidDataError,
1388 ProviderUnavailableError,
1389 ) as err:
1390 self.logger.warning(
1391 "Skipping %s in playlist %s: %s",
1392 item.path,
1393 prov_playlist_id,
1394 str(err),
1395 )
1396 return None
1397
1398 tasks = [_resolve(start + idx + 1, item) for idx, item in enumerate(page_items)]
1399 resolved = await asyncio.gather(*tasks)
1400 result = [item for item in resolved if item is not None]
1401
1402 await self.mass.cache.set(
1403 key=cache_key,
1404 data=result,
1405 expiration=3600 * 24,
1406 provider=self.instance_id,
1407 checksum=cache_checksum,
1408 category=CACHE_CATEGORY_PLAYLISTS,
1409 )
1410 return result
1411
1412 async def _build_m3u_entry_from_uri(self, uri: str) -> PlaylistItem:
1413 """Fetch a media item by URI and convert it to a PlaylistItem with full metadata."""
1414 full_item = await self.mass.music.get_item_by_uri(uri, allow_update_metadata=False)
1415 if not isinstance(full_item, MediaItem):
1416 msg = f"Unsupported media type for playlist: {uri}"
1417 raise InvalidDataError(msg)
1418 return media_item_to_playlist_item(full_item)
1419
1420 @staticmethod
1421 def _sanitize_playlist_id(name: str) -> str:
1422 """Sanitize a playlist name for use as a filename (without extension)."""
1423 # replace invalid filename characters
1424 sanitized = re.sub(r'[<>:"/\\|?*]', "_", name)
1425 # remove leading/trailing spaces and dots
1426 sanitized = sanitized.strip(" .")
1427 return sanitized or "untitled"
1428
1429 async def _migrate_playlists(self) -> None: # noqa: PLR0915
1430 """
1431 Migrate old-style playlists to M3U files and repair incomplete or stale entries.
1432
1433 Raises RuntimeError when too many entries could not be resolved to keep a broken
1434 install from rewriting every playlist.
1435 """
1436 # migrate playlists stored in config to M3U files on disk with enriched metadata
1437 stored_items: list[StoredItem] = self.mass.config.get(CONF_KEY_PLAYLISTS, [])
1438 for stored_item in stored_items:
1439 # keep the original item_id as filename so library DB references stay valid
1440 playlist_id = stored_item["item_id"]
1441 playlist_name = stored_item["name"]
1442 self.logger.info("Migrating playlist '%s' to M3U format...", playlist_name)
1443 update_current_task_progress_text(
1444 f"Migrating playlist '{playlist_name}' to M3U format..."
1445 )
1446 old_file = os.path.join(self._playlists_dir, playlist_id)
1447 # read old URI file and enrich each entry with full metadata
1448 uris: list[str] = []
1449 if await asyncio.to_thread(os.path.isfile, old_file):
1450 async with aiofiles.open(old_file, encoding="utf-8") as _file:
1451 lines = await _file.readlines()
1452 uris = [line.strip() for line in lines if line.strip()]
1453 entries: list[PlaylistItem] = []
1454 for uri in uris:
1455 try:
1456 entries.append(await self._build_m3u_entry_from_uri(uri))
1457 except MediaNotFoundError, InvalidDataError, ProviderUnavailableError:
1458 # parse URI for minimal provider info so the entry is resolvable later
1459 entry = PlaylistItem(path=uri)
1460 if "://" in uri:
1461 try:
1462 domain, rest = uri.split("://", 1)
1463 media_type_str, item_id = rest.split("/", 1)
1464 entry.metadata = {"media_type": media_type_str}
1465 entry.providers = [ProviderMappingInfo(domain=domain, item_id=item_id)]
1466 except ValueError:
1467 pass
1468 entries.append(entry)
1469 self.logger.debug("Could not enrich migrated entry: %s", uri)
1470 # write as {item_id}.m3u with the display name in #PLAYLIST
1471 await self._write_m3u_file(playlist_id, playlist_name, entries)
1472 # clean up old file (without .m3u extension)
1473 if await asyncio.to_thread(os.path.isfile, old_file):
1474 await asyncio.to_thread(os.remove, old_file)
1475 self.logger.debug("Migrated playlist '%s' -> %s.m3u", playlist_name, playlist_id)
1476 # clear old config entries
1477 self.mass.config.remove(CONF_KEY_PLAYLISTS)
1478 # fix (already migrated) user playlists that have unresolved URIs, or entries whose
1479 # manually set name or artwork was lost, by re-saving them with enriched metadata
1480 errors = 0
1481 # built once: a lookup per entry would rescan the entire config list each time
1482 stored_by_media_type = {
1483 MediaType.RADIO.value: {
1484 x["item_id"]: x for x in self.mass.config.get(CONF_KEY_RADIOS, [])
1485 },
1486 MediaType.TRACK.value: {
1487 x["item_id"]: x for x in self.mass.config.get(CONF_KEY_TRACKS, [])
1488 },
1489 }
1490 for filename in await asyncio.to_thread(os.listdir, self._playlists_dir):
1491 if not filename.endswith(".m3u"):
1492 continue
1493 playlist_id = filename[:-4] # strip .m3u extension
1494 m3u_data = await self._read_m3u_file(playlist_id)
1495 playlist = await self.get_playlist(playlist_id)
1496 self.logger.debug("Checking playlist '%s' for unresolved entries...", playlist.name)
1497 update_current_task_progress_text(f"Checking playlist '{playlist.name}'")
1498 all_items = parse_m3u(m3u_data)
1499 has_changes = False
1500 for item in all_items:
1501 force_migration = item.metadata and item.metadata.get("album") and not item.album
1502 unresolved = bool(force_migration) or not (
1503 item.title and item.providers and item.metadata
1504 )
1505 if not unresolved and not self._stored_details_differ(item, stored_by_media_type):
1506 continue
1507 self.logger.debug(
1508 "Found %s entry in playlist '%s': %s",
1509 "unresolved" if unresolved else "outdated",
1510 playlist_id,
1511 item.path,
1512 )
1513 try:
1514 enriched = await self._build_m3u_entry_from_uri(item.path)
1515 item.length = enriched.length
1516 item.title = enriched.title
1517 item.images = enriched.images
1518 item.providers = enriched.providers
1519 item.metadata = enriched.metadata
1520 item.album = enriched.album
1521 item.artists = enriched.artists
1522 item.podcast = enriched.podcast
1523 except (MediaNotFoundError, InvalidDataError, ProviderUnavailableError) as err:
1524 if unresolved:
1525 self.logger.warning(
1526 "Could not enrich playlist entry %s during migration: %s",
1527 item.path,
1528 err,
1529 )
1530 report_current_task_failure(f"Could not enrich playlist entry: {item.path}")
1531 errors += 1
1532 continue
1533 # an outdated entry is still playable, so failing to reach the stream is
1534 # no migration error; restore the stored details without any IO so a
1535 # permanently unreachable stream keeps its name and image
1536 self.logger.debug(
1537 "Could not refresh playlist entry %s, restoring stored details: %s",
1538 item.path,
1539 err,
1540 )
1541 self._restore_stored_details(item, stored_by_media_type)
1542 else:
1543 # writing an entry the refresh did not bring back in step would leave
1544 # it outdated, and every later run would rewrite the file again
1545 if self._stored_details_differ(item, stored_by_media_type):
1546 self._restore_stored_details(item, stored_by_media_type)
1547 self.logger.debug("Enriched playlist entry %s", item.path)
1548 has_changes = True
1549 if has_changes:
1550 await self._write_m3u_file(
1551 playlist_id,
1552 playlist.name,
1553 list(all_items),
1554 self._get_playlist_image_url(playlist),
1555 )
1556 self.logger.info("Updated playlist '%s' with enriched metadata", playlist.name)
1557 if errors > 25:
1558 raise RuntimeError("Too many errors during playlist migration")
1559 self.logger.info("Playlist migration completed with %d errors", errors)
1560 # if there were no errors, we can safely unregister the migration task
1561 if errors == 0 and (current_task_id := get_current_task_id()):
1562 # defer unregistering the scheduled task to avoid cancelling the current task
1563 self.mass.call_later(0, self.mass.tasks.unregister_scheduled_task, current_task_id)
1564