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