YTMusic: Fix disc/track number + favourite status (#2491)
authorMarvin Schenkel <marvinschenkel@gmail.com>
Wed, 15 Oct 2025 21:42:58 +0000 (23:42 +0200)
committerGitHub <noreply@github.com>
Wed, 15 Oct 2025 21:42:58 +0000 (23:42 +0200)
music_assistant/controllers/media/albums.py
music_assistant/providers/ytmusic/__init__.py
music_assistant/providers/ytmusic/helpers.py

index 0bb634dacb443a7d59575cda8807e840ccc89aff..b18e4c35e92be91ed43dd90564e5feab66563352 100644 (file)
@@ -249,6 +249,28 @@ class AlbumsController(MediaControllerBase[Album]):
                 provider_mapping.item_id, provider_mapping.provider_instance
             )
             for provider_track in provider_tracks:
+                # In some cases (looking at you YTM) the disc/track number is not obtained from
+                # library_tracks. Ensure to update the disc/track number when interacting with
+                # album tracks
+                db_track = next(
+                    (
+                        x
+                        for x in db_items
+                        if x.sort_name == provider_track.sort_name
+                        and x.version == provider_track.version
+                    ),
+                    None,
+                )
+                if (
+                    db_track
+                    and db_track.track_number == 0
+                    and db_track.track_number != provider_track.track_number
+                ):
+                    await self._set_album_track(
+                        db_id=library_album.item_id,
+                        db_track_id=db_track.item_id,
+                        track=provider_track,
+                    )
                 if provider_track.item_id in unique_ids:
                     continue
                 unique_id = f"{provider_track.disc_number}.{provider_track.track_number}"
@@ -443,6 +465,19 @@ class AlbumsController(MediaControllerBase[Album]):
         )
         return ItemMapping.from_item(db_artist)
 
+    async def _set_album_track(self, db_id: int, db_track_id: int, track: Track) -> None:
+        """Store Album Track info."""
+        # write (or update) record in album_tracks table
+        await self.mass.music.database.insert_or_replace(
+            DB_TABLE_ALBUM_TRACKS,
+            {
+                "album_id": db_id,
+                "track_id": db_track_id,
+                "track_number": track.track_number,
+                "disc_number": track.disc_number,
+            },
+        )
+
     async def match_providers(self, db_album: Album) -> None:
         """Try to find match on all (streaming) providers for the provided (database) album.
 
index c9d2e42496a43c915f2bdac44baf46ee995fe183..425830499cacac32a44fb611beb316d40637f770 100644 (file)
@@ -337,9 +337,9 @@ class YoutubeMusicProvider(MusicProvider):
         if not album_obj.get("tracks"):
             return []
         tracks = []
-        for track_obj in album_obj["tracks"]:
+        for track_number, track_obj in enumerate(album_obj["tracks"], 1):
             try:
-                track = self._parse_track(track_obj=track_obj)
+                track = self._parse_track(track_obj=track_obj, track_number=track_number)
             except InvalidDataError:
                 continue
             tracks.append(track)
@@ -752,9 +752,10 @@ class YoutubeMusicProvider(MusicProvider):
                     item_id=str(album_id),
                     provider_domain=self.domain,
                     provider_instance=self.instance_id,
-                    url=f"{YTM_DOMAIN}/playlist?list={album_id}",
+                    url=f"{YTM_DOMAIN}/playlist?list={album_obj.get('audioPlaylistId')}",
                 )
             },
+            favorite=album_obj.get("likeStatus", "INDIFFERENT") == "LIKE",
         )
         if album_obj.get("year") and album_obj["year"].isdigit():
             album.year = album_obj["year"]
@@ -816,6 +817,7 @@ class YoutubeMusicProvider(MusicProvider):
                     url=f"{YTM_DOMAIN}/channel/{artist_id}",
                 )
             },
+            favorite=artist_obj.get("likeStatus", "INDIFFERENT") == "LIKE",
         )
         if "description" in artist_obj:
             artist.metadata.description = artist_obj["description"]
@@ -846,6 +848,7 @@ class YoutubeMusicProvider(MusicProvider):
                 )
             },
             is_editable=is_editable,
+            favorite=playlist_obj.get("likeStatus", "INDIFFERENT") == "LIKE",
         )
         if "description" in playlist_obj:
             playlist.metadata.description = playlist_obj["description"]
@@ -863,7 +866,7 @@ class YoutubeMusicProvider(MusicProvider):
             playlist.owner = self.name
         return playlist
 
-    def _parse_track(self, track_obj: dict) -> Track:
+    def _parse_track(self, track_obj: dict, track_number: int = 0) -> Track:
         """Parse a YT Track response to a Track model object."""
         if not track_obj.get("videoId"):
             msg = "Track is missing videoId"
@@ -885,8 +888,12 @@ class YoutubeMusicProvider(MusicProvider):
                     ),
                 )
             },
-            disc_number=0,  # not supported on YTM?
-            track_number=track_obj.get("trackNumber", 0),
+            favorite=track_obj.get("likeStatus", "INDIFFERENT") == "LIKE",
+            # Disc info is not available in YTM
+            disc_number=0,
+            # Track number is "sometimes" available in the track object, otherwise approach
+            # by counting album tracks when fetching full album details
+            track_number=track_obj.get("trackNumber") or track_number or 0,
         )
 
         if track_obj.get("artists"):
index 8fca80f83d12b2d05b185e8832d625864ac45770..c50e15bdd772caef38a6b55436c288d064212300 100644 (file)
@@ -42,6 +42,22 @@ async def get_album(prov_album_id: str, language: str = "en") -> dict[str, str]:
 
     def _get_album():
         ytm = ytmusicapi.YTMusic(language=language)
+        album = ytm.get_album(browseId=prov_album_id)
+        if "audioPlaylistId" in album:
+            # Track id's from album tracks do not match with actual album tracks. E.g. a track
+            # points to the videoId of the original version, while we want the album version
+            album_playlist = ytm.get_playlist(playlistId=album["audioPlaylistId"], limit=None)
+            # Do some basic checks
+            if len(album_playlist.get("tracks", [])) != len(album.get("tracks", [])):
+                return album
+            # Move the correct track info to the album tracks
+            playlist_tracks_by_title = {t.get("title"): t for t in album_playlist.get("tracks", [])}
+            for album_track in album.get("tracks", []):
+                if playlist_track := playlist_tracks_by_title.get(album_track.get("title")):
+                    album_track["videoId"] = playlist_track["videoId"]
+                    album_track["isAvailable"] = playlist_track.get("isAvailable", True)
+                    album_track["likeStatus"] = playlist_track.get("likeStatus", "INDIFFERENT")
+            return album
         return ytm.get_album(browseId=prov_album_id)
 
     return await asyncio.to_thread(_get_album)