/
/
/
1"""Manage MediaItems of type Playlist."""
2
3from __future__ import annotations
4
5from collections.abc import AsyncGenerator, Sequence
6from contextlib import suppress
7from typing import TYPE_CHECKING, Any, cast
8
9from music_assistant_models.auth import Scope
10from music_assistant_models.enums import MediaType, ProviderFeature
11from music_assistant_models.errors import (
12 InvalidDataError,
13 InvalidProviderURI,
14 MediaNotFoundError,
15 ProviderUnavailableError,
16)
17from music_assistant_models.helpers import create_safe_string
18from music_assistant_models.media_items import Playlist, PlaylistSummary
19
20from music_assistant.constants import DB_TABLE_PLAYLISTS, PLAYLIST_MEDIA_TYPES, PlaylistPlayableItem
21from music_assistant.controllers.tasks.context import (
22 update_current_task_progress,
23 update_current_task_progress_text,
24)
25from music_assistant.controllers.webserver.helpers.auth_middleware import get_current_user
26from music_assistant.helpers.database import UNSET
27from music_assistant.helpers.json import json_loads, serialize_to_json
28from music_assistant.helpers.playlists import (
29 PlaylistItem,
30 generate_m3u,
31 media_item_to_playlist_item,
32)
33from music_assistant.helpers.security import is_safe_name
34from music_assistant.helpers.uri import create_uri, parse_uri
35from music_assistant.helpers.util import guard_single_request
36from music_assistant.models.music_provider import MusicProvider
37
38from .audiobooks import AudiobooksController
39from .base import MediaControllerBase
40from .radio import RadioController
41from .tracks import TracksController
42
43if TYPE_CHECKING:
44 from collections.abc import Mapping
45
46 from music_assistant_models.background_task import BackgroundTask
47
48 from music_assistant import MusicAssistant
49 from music_assistant.providers.builtin import BuiltinProvider
50
51
52def _update_stage_progress(
53 current: int,
54 total: int,
55 start: int,
56 end: int,
57 text: str,
58) -> None:
59 """Update progress for a bounded task stage without resetting overall progress."""
60 if total <= 0:
61 update_current_task_progress_text(text)
62 return
63 progress = start + int((current * (end - start)) / total)
64 update_current_task_progress(min(progress, end), text)
65
66
67class PlaylistController(MediaControllerBase[Playlist]):
68 """Controller managing MediaItems of type Playlist."""
69
70 db_table = DB_TABLE_PLAYLISTS
71 media_type = MediaType.PLAYLIST
72 item_cls = Playlist
73 summary_item_cls = PlaylistSummary
74
75 def __init__(self, mass: MusicAssistant) -> None:
76 """Initialize class."""
77 super().__init__(mass)
78 # register (extra) api handlers
79 api_base = self.api_base
80 self.mass.register_api_command(
81 f"music/{api_base}/create_playlist",
82 self.create_playlist,
83 required_scope=Scope.LIBRARY_WRITE,
84 )
85 self.mass.register_api_command(
86 "music/playlists/playlist_tracks", self.tracks, required_scope=Scope.LIBRARY_READ
87 )
88 self.mass.register_api_command(
89 "music/playlists/add_playlist_tracks",
90 self.add_playlist_tracks,
91 required_scope=Scope.LIBRARY_WRITE,
92 )
93 self.mass.register_api_command(
94 "music/playlists/remove_playlist_tracks",
95 self.remove_playlist_tracks,
96 required_scope=Scope.LIBRARY_WRITE,
97 )
98 self.mass.register_api_command(
99 "music/playlists/export_playlist",
100 self.export_playlist,
101 required_scope=Scope.LIBRARY_READ,
102 )
103 self.mass.register_api_command(
104 "music/playlists/import_playlist",
105 self.import_playlist,
106 required_scope=Scope.LIBRARY_WRITE,
107 )
108
109 @property
110 def summary_query(self) -> tuple[str, dict[str, Any]]:
111 """Return the slim SELECT query used for playlist summary listings."""
112 query = f"""
113 SELECT
114 {self._summary_base_columns()},
115 playlists.owner,
116 playlists.is_editable,
117 playlists.is_dynamic,
118 playlists.supported_mediatypes,
119 playlists.translation_key,
120 playlists.translation_params,
121 json_extract(playlists.metadata, '$.description') AS description,
122 {self._provider_mappings_query()} AS provider_mappings
123 FROM playlists"""
124 return query, {}
125
126 async def tracks(
127 self,
128 item_id: str,
129 provider_instance_id_or_domain: str,
130 force_refresh: bool = False,
131 allow_dynamic_tracks: bool = False,
132 ) -> AsyncGenerator[PlaylistPlayableItem]:
133 """Return playlist tracks for the given provider playlist id."""
134 if provider_instance_id_or_domain == "library":
135 library_item = await self.get_library_item(item_id)
136 provider_instance_id_or_domain, item_id = self._select_provider_id(library_item)
137
138 # Playback/refill requests for dynamic playlists need fresh tracks from the provider.
139 # Browse requests may reuse cached tracks.
140 if allow_dynamic_tracks:
141 force_refresh = True
142
143 # playlist tracks are not stored in the db, we always fetch them (cached) from the
144 # provider. The provider decides how many tracks to return; for a dynamic playlist it
145 # returns a bounded sample/batch and terminates by yielding no further pages.
146 page = 0
147 while True:
148 tracks = await self._get_provider_playlist_tracks(
149 item_id,
150 provider_instance_id_or_domain,
151 page=page,
152 force_refresh=force_refresh,
153 )
154 if not tracks:
155 break
156 for track in tracks:
157 yield track
158 page += 1
159
160 async def create_playlist(
161 self,
162 name: str,
163 media_types: list[MediaType] | None = None,
164 provider_instance_or_domain: str | None = None,
165 ) -> Playlist:
166 """Create new playlist."""
167 # if provider is omitted, just pick builtin provider
168 if provider_instance_or_domain:
169 provider = self.mass.get_provider(provider_instance_or_domain)
170 if provider is None:
171 raise ProviderUnavailableError
172 else:
173 provider = self.mass.get_provider("builtin")
174
175 # Default is track for backwards compatibility.
176 media_types_set = {MediaType.TRACK} if not media_types else set(media_types)
177 if MediaType.ALBUM in media_types_set:
178 # an album is unwrapped, so we remove that and use tracks instead
179 media_types_set.remove(MediaType.ALBUM)
180 media_types_set.add(MediaType.TRACK)
181 if not provider_instance_or_domain and not media_types_set:
182 # builtin can handle all media_types
183 media_types_set.update(
184 (MediaType.AUDIOBOOK, MediaType.PODCAST_EPISODE, MediaType.RADIO)
185 )
186
187 provider = cast("MusicProvider", provider)
188
189 mix_allowed = ProviderFeature.PLAYLIST_CREATE_MIXED in provider.supported_features
190 supported_types: set[MediaType] = set()
191 if (
192 ProviderFeature.PLAYLIST_CREATE in provider.supported_features
193 or ProviderFeature.PLAYLIST_CREATE_TRACKS in provider.supported_features
194 ):
195 # PLAYLIST_CREATE is deprecated
196 supported_types.add(MediaType.TRACK)
197 if ProviderFeature.PLAYLIST_CREATE_AUDIOBOOKS in provider.supported_features:
198 supported_types.add(MediaType.AUDIOBOOK)
199 if ProviderFeature.PLAYLIST_CREATE_PODCAST_EPISODES in provider.supported_features:
200 supported_types.add(MediaType.PODCAST_EPISODE)
201 if ProviderFeature.PLAYLIST_CREATE_RADIOS in provider.supported_features:
202 supported_types.add(MediaType.RADIO)
203
204 if not supported_types:
205 msg = f"Provider {provider.name} does not support creating playlists"
206 raise InvalidDataError(msg)
207
208 if not is_safe_name(name):
209 msg = f"{name} is not a valid Playlist name"
210 raise InvalidDataError(msg)
211
212 if len(media_types_set.difference(supported_types)) > 0:
213 msg = f"Provider {provider.name} only supports {supported_types} in playlists."
214 raise InvalidDataError(msg)
215 if len(media_types_set) > 1 and not mix_allowed:
216 msg = f"Provider {provider.name} does not support mixed media_types in playlists."
217 raise InvalidDataError(msg)
218
219 # create playlist on the provider
220 playlist = await provider.create_playlist(name, media_types=media_types_set)
221 for prov_mapping in playlist.provider_mappings:
222 # when manually creating a playlist, it's always in the library
223 prov_mapping.in_library = True
224 # add the new playlist to the library
225 return await self.add_item_to_library(playlist, False)
226
227 async def add_playlist_tracks(
228 self, db_playlist_id: str | int, uris: list[str]
229 ) -> BackgroundTask:
230 """
231 Queue adding items to a playlist.
232
233 :param db_playlist_id: Library playlist id.
234 :param uris: Item URIs to add to the playlist.
235 :return: Managed background task for the requested playlist update.
236 """
237 playlist_name = str(db_playlist_id)
238 with suppress(MediaNotFoundError):
239 playlist_name = (await self.get_library_item(int(db_playlist_id))).name
240 user = get_current_user()
241 return self.mass.tasks.run_background_task(
242 name=f"Add items to playlist {playlist_name}",
243 handler=lambda: self._handle_add_playlist_tracks(db_playlist_id, uris),
244 translation_key="add_playlist_tracks",
245 translation_owner=self.translation_owner,
246 translation_args=[playlist_name],
247 user_id=user.user_id if user else None,
248 metadata={
249 "task_domain": "playlist_add_tracks",
250 "playlist_id": str(db_playlist_id),
251 "playlist_name": playlist_name,
252 "item_count": len(uris),
253 },
254 allow_retry=True,
255 priority=True,
256 )
257
258 async def add_playlist_track(self, db_playlist_id: str | int, track_uri: str) -> None:
259 """Add (single) track to playlist."""
260 await self._handle_add_playlist_tracks(db_playlist_id, [track_uri])
261
262 async def remove_playlist_tracks(
263 self, db_playlist_id: str | int, positions_to_remove: tuple[int, ...]
264 ) -> BackgroundTask:
265 """
266 Queue removing items from a playlist.
267
268 :param db_playlist_id: Library playlist id.
269 :param positions_to_remove: Provider playlist positions to remove.
270 :return: Managed background task for the requested playlist update.
271 """
272 playlist_name = str(db_playlist_id)
273 with suppress(MediaNotFoundError):
274 playlist_name = (await self.get_library_item(int(db_playlist_id))).name
275 user = get_current_user()
276 return self.mass.tasks.run_background_task(
277 name=f"Remove items from playlist {playlist_name}",
278 handler=lambda: self._handle_remove_playlist_tracks(
279 db_playlist_id, positions_to_remove
280 ),
281 translation_key="remove_playlist_tracks",
282 translation_owner=self.translation_owner,
283 translation_args=[playlist_name],
284 user_id=user.user_id if user else None,
285 metadata={
286 "task_domain": "playlist_remove_tracks",
287 "playlist_id": str(db_playlist_id),
288 "playlist_name": playlist_name,
289 "item_count": len(positions_to_remove),
290 },
291 priority=True,
292 )
293
294 async def match_providers(self, db_item: Playlist) -> None:
295 """
296 Try to find match on all (streaming) providers for the provided (database) item.
297
298 This is used to link objects of different providers/qualities together.
299 """
300 # playlists can only be matched on the same provider (if not unique)
301 if self.mass.music.match_provider_instances(db_item):
302 await self.add_provider_mappings(db_item.item_id, db_item.provider_mappings)
303
304 async def export_playlist(self, db_playlist_id: str | int) -> str:
305 """
306 Export a playlist to M3U8 format.
307
308 :param db_playlist_id: The library database ID of the playlist.
309 """
310 db_id = int(db_playlist_id)
311 playlist = await self.get_library_item(db_id)
312 if not playlist:
313 msg = f"Playlist with id {db_id} not found"
314 raise MediaNotFoundError(msg)
315 items: list[PlaylistItem] = []
316 async for track in self.tracks(
317 item_id=str(db_id),
318 provider_instance_id_or_domain="library",
319 ):
320 items.append(media_item_to_playlist_item(track))
321 playlist_image_url = playlist.image.path if playlist.image else None
322 return generate_m3u(playlist.name, items, playlist_image_url)
323
324 async def import_playlist(
325 self,
326 m3u_data: str,
327 library_matching: bool = False,
328 match_providers: list[str] | None = None,
329 ) -> Playlist:
330 """
331 Import a playlist from M3U8 format.
332
333 Creates a new builtin playlist from the provided M3U data.
334
335 :param m3u_data: The M3U8 playlist data as a string.
336 :param library_matching: When True, attempt to find tracks by searching
337 providers using metadata when the original URI's provider is not
338 available. Defaults to False.
339 :param match_providers: Optional list of provider instance IDs or domains
340 to search when library_matching is enabled.
341 """
342 provider = self.mass.get_provider("builtin")
343 if not provider or not isinstance(provider, MusicProvider):
344 raise ProviderUnavailableError("Builtin provider is not available")
345 builtin_prov = cast("BuiltinProvider", provider)
346 playlist = await builtin_prov.import_playlist(m3u_data)
347 for prov_mapping in playlist.provider_mappings:
348 prov_mapping.in_library = True
349 db_playlist = await self.add_item_to_library(playlist, False)
350 if library_matching:
351 prov_playlist_id = playlist.item_id
352 user = get_current_user()
353 self.mass.tasks.run_background_task(
354 name=f"Import playlist {db_playlist.name}",
355 handler=lambda: builtin_prov.match_imported_playlist_tracks(
356 prov_playlist_id, match_providers
357 ),
358 translation_key="import_playlist_matching",
359 translation_owner=self.translation_owner,
360 translation_args=[db_playlist.name],
361 user_id=user.user_id if user else None,
362 metadata={
363 "task_domain": "playlist_import_matching",
364 "playlist_id": str(db_playlist.item_id),
365 "playlist_name": db_playlist.name,
366 },
367 allow_retry=True,
368 allow_cancel=True,
369 priority=True,
370 )
371 return db_playlist
372
373 def _verify_update_allowed(self, current_item: Playlist, update: Playlist) -> None:
374 """
375 Verify that the update is allowed from a security perspective.
376
377 Prevents updating item_id for non-streaming providers to prevent path traversal attacks.
378 """
379 # Build lookup dict of current mappings: provider_instance -> item_id
380 current_mappings = {
381 mapping.provider_instance: mapping.item_id for mapping in current_item.provider_mappings
382 }
383
384 # Check if any existing mapping's item_id has been modified for non-streaming providers
385 for update_mapping in update.provider_mappings:
386 # Only check if this is an existing mapping being modified
387 if update_mapping.provider_instance in current_mappings:
388 current_item_id = current_mappings[update_mapping.provider_instance]
389
390 # Disallow item_id changes for filesystem-based providers (filesystem, builtin)
391 if (
392 current_item_id != update_mapping.item_id
393 and update_mapping.provider_instance.startswith(("filesystem", "builtin"))
394 ):
395 msg = (
396 f"Updating item_id is not allowed for filesystem-based providers: "
397 f"attempted to change '{current_item_id}' to '{update_mapping.item_id}'"
398 )
399 raise InvalidDataError(msg)
400
401 async def _add_library_item(self, item: Playlist, overwrite_existing: bool = False) -> int:
402 """Add a new record to the database."""
403 db_id = await self.mass.music.database.insert(
404 self.db_table,
405 {
406 "name": item.name,
407 "sort_name": item.sort_name,
408 # persist the localizable name key + its params so the localized name survives
409 # the library round-trip (e.g. builtin playlists, Spotify's per-account
410 # "Liked Songs {0}"). params are re-stamped from the provider on each sync.
411 "translation_key": item.translation_key,
412 "translation_params": serialize_to_json(item.translation_params)
413 if item.translation_params
414 else None,
415 "owner": item.owner,
416 "is_editable": item.is_editable,
417 "favorite": item.favorite,
418 "metadata": serialize_to_json(item.metadata),
419 "search_name": create_safe_string(item.name, True, True),
420 "search_sort_name": create_safe_string(item.sort_name or "", True, True),
421 "timestamp_added": int(item.date_added.timestamp()) if item.date_added else UNSET,
422 "supported_mediatypes": serialize_to_json(item.supported_mediatypes),
423 "is_dynamic": item.is_dynamic,
424 },
425 )
426 # update/set external id lookup table
427 await self.set_external_ids(db_id, item.external_ids)
428 # update/set provider_mappings table
429 await self.set_provider_mappings(db_id, item.provider_mappings)
430 self.logger.debug("added %s to database (id: %s)", item.name, db_id)
431 return db_id
432
433 async def _update_library_item(
434 self, item_id: str | int, update: Playlist, overwrite: bool = False
435 ) -> None:
436 """Update existing record in the database."""
437 db_id = int(item_id) # ensure integer
438 cur_item = await self.get_library_item(db_id)
439 self._verify_update_allowed(cur_item, update)
440 metadata = update.metadata if overwrite else cur_item.metadata.update(update.metadata)
441 cur_item.external_ids.update(update.external_ids)
442 name = update.name if overwrite else cur_item.name
443 sort_name = update.sort_name if overwrite else cur_item.sort_name or update.sort_name
444 # adopt the synced item's translation_key + params (as a unit) when it supplies a key, so
445 # the localized name follows the provider, existing rows backfill, and a stale param (e.g.
446 # a renamed Spotify account) self-heals; otherwise keep what we have (unless overwriting).
447 if overwrite or update.translation_key is not None:
448 translation_key = update.translation_key
449 translation_params = update.translation_params
450 else:
451 translation_key = cur_item.translation_key
452 translation_params = cur_item.translation_params
453 await self.mass.music.database.update(
454 self.db_table,
455 {"item_id": db_id},
456 {
457 # always prefer name/owner from updated item here
458 "name": name,
459 "sort_name": sort_name,
460 "translation_key": translation_key,
461 "translation_params": serialize_to_json(translation_params)
462 if translation_params
463 else None,
464 "owner": update.owner or cur_item.owner,
465 "is_editable": update.is_editable,
466 "metadata": serialize_to_json(metadata),
467 "search_name": create_safe_string(name, True, True),
468 "search_sort_name": create_safe_string(sort_name or "", True, True),
469 "supported_mediatypes": serialize_to_json(update.supported_mediatypes),
470 "is_dynamic": update.is_dynamic,
471 "timestamp_added": int(update.date_added.timestamp())
472 if update.date_added
473 else UNSET,
474 },
475 )
476 # update/set external id lookup table
477 await self.set_external_ids(
478 db_id, update.external_ids if overwrite else cur_item.external_ids
479 )
480 # update/set provider_mappings table
481 provider_mappings = (
482 update.provider_mappings
483 if overwrite
484 else {*update.provider_mappings, *cur_item.provider_mappings}
485 )
486 await self.set_provider_mappings(db_id, provider_mappings, overwrite)
487 self.logger.debug("updated %s in database: (id %s)", update.name, db_id)
488
489 @guard_single_request
490 async def _get_provider_playlist_tracks(
491 self,
492 item_id: str,
493 provider_instance_id_or_domain: str,
494 page: int = 0,
495 force_refresh: bool = False,
496 ) -> Sequence[PlaylistPlayableItem]:
497 """Return playlist tracks for the given provider playlist id."""
498 assert provider_instance_id_or_domain != "library"
499 if not (provider := self.mass.get_provider(provider_instance_id_or_domain)):
500 return []
501 provider = cast("MusicProvider", provider)
502 async with self.mass.cache.handle_refresh(force_refresh):
503 return await provider.get_playlist_tracks(item_id, page=page)
504
505 async def _handle_add_playlist_tracks(self, db_playlist_id: str | int, uris: list[str]) -> None:
506 """Handle adding playlist items inside a managed task."""
507 # ruff: noqa: PLR0915
508 total_requested = len(uris)
509 update_current_task_progress(0, "Preparing playlist update")
510 db_id = int(db_playlist_id) # ensure integer
511 playlist = await self.get_library_item(db_id)
512 if not playlist:
513 msg = f"Playlist with id {db_id} not found"
514 raise MediaNotFoundError(msg)
515 if not playlist.is_editable:
516 msg = f"Playlist {playlist.name} is not editable"
517 raise InvalidDataError(msg)
518 # Validate uris to prevent code injection
519 for index, uri in enumerate(uris, start=1):
520 _update_stage_progress(
521 index,
522 total_requested,
523 0,
524 10,
525 f"Validating request {index}/{total_requested}",
526 )
527 # Prevent code injection via newlines in URIs
528 if "\n" in uri or "\r" in uri:
529 msg = "Invalid URI: newlines not allowed"
530 raise InvalidProviderURI(msg)
531 await parse_uri(uri)
532 # grab all existing track ids in the playlist so we can check for duplicates
533 # use _select_provider_id to respect user's provider filter
534 playlist_prov_instance, playlist_prov_item_id = self._select_provider_id(playlist)
535 playlist_prov = self.mass.get_provider(playlist_prov_instance)
536 if not playlist_prov or not playlist_prov.available:
537 raise ProviderUnavailableError(f"Provider {playlist_prov_instance} is not available")
538 playlist_prov = cast("MusicProvider", playlist_prov)
539
540 if ProviderFeature.PLAYLIST_TRACKS_EDIT not in playlist_prov.supported_features:
541 msg = f"Provider {playlist_prov.name} does not support editing playlists"
542 raise InvalidDataError(msg)
543
544 # sets to track existing tracks
545 cur_playlist_track_ids: set[str] = set()
546 cur_playlist_track_uris: set[str] = set()
547
548 # collect current track IDs and URIs
549 update_current_task_progress_text("Loading current playlist items")
550 async for item in self.tracks(playlist.item_id, playlist.provider):
551 if item.item_id:
552 cur_playlist_track_ids.add(item.item_id)
553 if item.uri:
554 cur_playlist_track_uris.add(item.uri)
555
556 # unwrap URIs to individual track URIs
557 unwrapped_uris: list[str] = []
558 for index, uri in enumerate(uris, start=1):
559 _update_stage_progress(
560 index,
561 total_requested,
562 10,
563 35,
564 f"Expanding request {index}/{total_requested}",
565 )
566 # URI could be a playlist or album uri, unwrap it
567 if not ("://" in uri and len(uri.split("/")) >= 4):
568 # NOT a music assistant-style uri (provider://media_type/item_id)
569 self.logger.warning(
570 "Not adding %s to playlist %s - not a valid uri", uri, playlist.name
571 )
572 continue
573 # music assistant-style uri
574 # provider://media_type/item_id
575 provider_instance_id_or_domain, rest = uri.split("://", 1)
576 media_type_str, item_id = rest.split("/", 1)
577 media_type = MediaType(media_type_str)
578 if media_type == MediaType.ALBUM:
579 album_tracks = await self.mass.music.albums.tracks(
580 item_id, provider_instance_id_or_domain
581 )
582 for track in album_tracks:
583 if track.uri is not None:
584 unwrapped_uris.append(track.uri)
585 elif media_type == MediaType.PLAYLIST:
586 async for item in self.tracks(item_id, provider_instance_id_or_domain):
587 if item.uri is not None:
588 unwrapped_uris.append(item.uri)
589 elif media_type in PLAYLIST_MEDIA_TYPES:
590 unwrapped_uris.append(uri)
591 else:
592 self.logger.warning(
593 "Not adding %s to playlist %s - media type not supported in playlists",
594 uri,
595 playlist.name,
596 )
597 continue
598
599 # work out the track id's that need to be added
600 # filter out duplicates and items that not exist on the provider.
601 ids_to_add: list[str] = []
602 total_candidates = len(unwrapped_uris)
603 for index, uri in enumerate(unwrapped_uris, start=1):
604 _update_stage_progress(
605 index,
606 total_candidates,
607 35,
608 85,
609 f"Matching item {index}/{total_candidates}",
610 )
611 # skip if item already in the playlist
612 if uri in cur_playlist_track_uris:
613 self.logger.info(
614 "Not adding %s to playlist %s - it already exists",
615 uri,
616 playlist.name,
617 )
618 continue
619
620 # special: the builtin provider can handle uri's from all providers (with uri as id)
621 if playlist_prov.domain == "builtin":
622 ids_to_add.append(uri)
623 continue
624
625 # parse uri for further processing
626 media_type, provider_instance_id_or_domain, item_id = await parse_uri(uri)
627
628 if media_type not in playlist.supported_mediatypes:
629 self.logger.warning(
630 "Not adding %s to playlist %s, "
631 "the target playlist doesn't support this media type.",
632 uri,
633 playlist.name,
634 )
635 continue
636
637 # skip if item already in the playlist
638 if item_id in cur_playlist_track_ids:
639 self.logger.warning(
640 "Not adding %s to playlist %s - it already exists",
641 uri,
642 playlist.name,
643 )
644 continue
645
646 # if target playlist is an exact provider match, we can add it
647 if provider_instance_id_or_domain in (playlist_prov.instance_id, playlist_prov.domain):
648 ids_to_add.append(item_id)
649 continue
650
651 if media_type == MediaType.PODCAST_EPISODE:
652 # in practice we should not be able to reach here but guard just in case
653 self.logger.warning(
654 "Not adding %s to playlist %s - "
655 "podcast episodes must be added to a provider-specific playlist",
656 uri,
657 playlist.name,
658 )
659 continue
660
661 # not exact match - try to get a match for the item on the playlist's provider
662 full_item: PlaylistPlayableItem
663 controller = cast(
664 "AudiobooksController | RadioController | TracksController",
665 self.mass.music.get_controller(media_type),
666 )
667 if media_type == MediaType.TRACK:
668 assert isinstance(controller, TracksController) # for type checking
669 full_item = await controller.get(
670 item_id,
671 provider_instance_id_or_domain,
672 allow_update_metadata=False,
673 recursive=provider_instance_id_or_domain != "library",
674 )
675 else:
676 full_item = await controller.get(
677 item_id,
678 provider_instance_id_or_domain,
679 allow_update_metadata=False,
680 )
681 track_prov_domains = {x.provider_domain for x in full_item.provider_mappings}
682 if (
683 playlist_prov.is_streaming_provider
684 and playlist_prov.domain not in track_prov_domains
685 ):
686 # try to match the track to the playlist's provider
687 full_item.provider_mappings.update(
688 await controller.match_provider(
689 full_item, # type: ignore[arg-type]
690 playlist_prov,
691 strict=False,
692 )
693 )
694
695 # a track can contain multiple versions on the same provider
696 # simply sort by quality and just add the first available version
697 for item_mapping in sorted(
698 full_item.provider_mappings, key=lambda x: x.quality, reverse=True
699 ):
700 if not item_mapping.available:
701 continue
702 if item_mapping.item_id in cur_playlist_track_ids:
703 break # already existing in the playlist
704 item_prov = self.mass.get_provider(item_mapping.provider_instance)
705 if not item_prov:
706 continue
707 track_version_uri = create_uri(
708 media_type,
709 item_prov.instance_id,
710 item_mapping.item_id,
711 )
712 if track_version_uri in cur_playlist_track_uris:
713 self.logger.warning(
714 "Not adding %s to playlist %s - it already exists",
715 full_item.name,
716 playlist.name,
717 )
718 break # already existing in the playlist
719 # Add item to provider-specific playlist
720 if item_prov.instance_id == playlist_prov.instance_id:
721 if item_mapping.item_id not in ids_to_add:
722 ids_to_add.append(item_mapping.item_id)
723 self.logger.info(
724 "Adding %s to playlist %s",
725 full_item.name,
726 playlist.name,
727 )
728 break
729 else:
730 self.logger.warning(
731 "Can't add %s to playlist %s - it is not available on provider %s",
732 full_item.name,
733 playlist.name,
734 playlist_prov.name,
735 )
736
737 if not ids_to_add:
738 update_current_task_progress(100, "No new playlist items to add")
739 return
740
741 # actually add the tracks to the playlist on the provider
742 update_current_task_progress(90, f"Adding {len(ids_to_add)} item(s) to playlist")
743 await playlist_prov.add_playlist_tracks(playlist_prov_item_id, ids_to_add)
744 # reset 'last_refresh' to force a refresh of the playlist's metadata
745 # in the next scheduled run of the playlist metadata task
746 playlist.metadata.last_refresh = None
747 await self.update_item_in_library(db_playlist_id, playlist)
748 update_current_task_progress(100, f"Added {len(ids_to_add)} item(s) to playlist")
749
750 async def _handle_remove_playlist_tracks(
751 self, db_playlist_id: str | int, positions_to_remove: tuple[int, ...]
752 ) -> None:
753 """Handle removing playlist items inside a managed task."""
754 db_id = int(db_playlist_id) # ensure integer
755 playlist = await self.get_library_item(db_id)
756 if not playlist:
757 msg = f"Playlist with id {db_id} not found"
758 raise MediaNotFoundError(msg)
759 if not playlist.is_editable:
760 msg = f"Playlist {playlist.name} is not editable"
761 raise InvalidDataError(msg)
762 # use _select_provider_id to respect user's provider filter
763 playlist_prov_instance, playlist_prov_item_id = self._select_provider_id(playlist)
764 provider = self.mass.get_provider(playlist_prov_instance)
765 if not provider or not isinstance(provider, MusicProvider):
766 raise ProviderUnavailableError(f"Provider {playlist_prov_instance} is not available")
767 if ProviderFeature.PLAYLIST_TRACKS_EDIT not in provider.supported_features:
768 msg = f"Provider {provider.name} does not support editing playlists"
769 raise InvalidDataError(msg)
770 await provider.remove_playlist_tracks(playlist_prov_item_id, positions_to_remove)
771 # reset 'last_refresh' to force a refresh of the playlist's metadata
772 # in the next scheduled run of the playlist metadata task
773 playlist.metadata.last_refresh = None
774 await self.update_item_in_library(db_playlist_id, playlist)
775
776 def _parse_summary_row(self, db_row: Mapping[str, Any]) -> PlaylistSummary:
777 """Parse a raw summary db row into a PlaylistSummary object."""
778 item = cast("PlaylistSummary", super()._parse_summary_row(db_row))
779 item.owner = db_row["owner"]
780 item.is_editable = bool(db_row["is_editable"])
781 item.is_dynamic = bool(db_row["is_dynamic"])
782 item.metadata.description = db_row["description"]
783 item.supported_mediatypes = {
784 MediaType(x) for x in json_loads(db_row["supported_mediatypes"])
785 }
786 if translation_key := db_row["translation_key"]:
787 item.translation_key = translation_key
788 if translation_params := db_row["translation_params"]:
789 item.translation_params = json_loads(translation_params)
790 return item
791