Repository: MarshalX/yandex-music-api

Issue:
Can't fetch self-added track
Команда fetch_track для некоторых треков, добавленных в плейлист "мне нравится" с помощью самостоятельной загрузки, падает с ошибкой

```py
user_id = "surkovcyrille"  # Аккаунт, которому принадлежит API-токен
liked_tracks = client.users_likes_tracks(user_id=user_id)
liked_tracks = liked_tracks.fetch_tracks()
```

или

```py
user_id = "surkovcyrille"  # Аккаунт, которому принадлежит API-токен
liked_tracks = client.users_likes_tracks(user_id=user_id)
for track in liked_tracks:
    track = track.fetch_track()
```

Вызывает ошибку
```
Traceback (most recent call last):
  File "/Users/rulka/Programming/projects/yandex_music/test.py", line 123, in <module>
    get_playlist("surkovcyrille")
  File "/Users/rulka/Programming/projects/yandex_music/test.py", line 17, in get_playlist
    liked_tracks = liked_tracks.fetch_tracks()
                   ^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/yandex_music/tracks_list.py", line 49, in fetch_tracks
    return self.client.tracks(self.tracks_ids)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/yandex_music/client.py", line 70, in wrapper
    result = method(*args, **kwargs)
             ^^^^^^^^^^^^^^^^^^^^^^^
  File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/yandex_music/client.py", line 1962, in tracks
    return self._get_list('track', track_ids, {'with-positions': str(with_positions)}, *args, **kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/yandex_music/client.py", line 1901, in _get_list
    return de_list[object_type](result, self)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/yandex_music/track/track.py", line 517, in de_list
    return [cls.de_json(track, client) for track in data]
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/yandex_music/track/track.py", line 517, in <listcomp>
    return [cls.de_json(track, client) for track in data]
            ^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/yandex_music/track/track.py", line 490, in de_json
    data['artists'] = Artist.de_list(data.get('artists'), client)
                      ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/yandex_music/artist/artist.py", line 306, in de_list
    artists.append(cls.de_json(artist, client))
                   ^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/yandex_music/artist/artist.py", line 288, in de_json
    return cls(client=client, **data)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^
TypeError: Artist.__init__() missing 1 required positional argument: 'id'
```

Совсем недавно все корректно работало. Наверное Яндекс что-то обновил в своем API

Hints from issue comments:

Solution patch:
diff --git a/yandex_music/artist/artist.py b/yandex_music/artist/artist.py
index fa4d435..09f5d9e 100644
--- a/yandex_music/artist/artist.py
+++ b/yandex_music/artist/artist.py
@@ -1,6 +1,7 @@
 from typing import TYPE_CHECKING, Any, List, Optional, Union
 
 from yandex_music import YandexMusicModel
+from yandex_music.exceptions import IdMissingError
 from yandex_music.utils import model
 
 if TYPE_CHECKING:
@@ -23,7 +24,7 @@ class Artist(YandexMusicModel):
     """Класс, представляющий исполнителя.
 
     Attributes:
-        id (:obj:`int`): Уникальный идентификатор.
+        id (:obj:`int`, optional): Уникальный идентификатор.
         error (:obj:`str`, optional): Сообщение об ошибке с объяснением почему не вернуло исполнителя.
         reason (:obj:`str`, optional): Причина отсутствия исполнителя (сообщение об ошибке).
         name (:obj:`str`, optional): Название.
@@ -57,7 +58,7 @@ class Artist(YandexMusicModel):
         client (:obj:`yandex_music.Client`): Клиент Yandex Music.
     """
 
-    id: int
+    id: Optional[int] = None
     error: Optional[str] = None
     reason: Optional[str] = None
     name: Optional[str] = None
@@ -92,6 +93,21 @@ class Artist(YandexMusicModel):
     def __post_init__(self) -> None:
         self._id_attrs = (self.id, self.name, self.cover)
 
+    @property
+    def id_required(self) -> int:
+        """Возвращает ID исполнителя, удостоверяясь, что он указан.
+
+        Raises:
+            IdMissingError: Если ID исполнителя не установлен.
+
+        Returns:
+            :obj:`int`: Уникальный идентификатор исполнителя.
+        """
+        if self.id is None:
+            raise IdMissingError('Artist.id is required')
+
+        return self.id
+
     def get_op_image_url(self, size: str = '200x200') -> str:
         """Возвращает URL OP обложки.
 
@@ -219,72 +235,72 @@ class Artist(YandexMusicModel):
     def like(self, *args: Any, **kwargs: Any) -> bool:
         """Сокращение для::
 
-        client.users_likes_artists_add(artist.id, user.id *args, **kwargs)
+        client.users_likes_artists_add(artist.id_required, user.id *args, **kwargs)
         """
         assert self.valid_client(self.client)
-        return self.client.users_likes_artists_add(self.id, self.client.account_uid, *args, **kwargs)
+        return self.client.users_likes_artists_add(self.id_required, self.client.account_uid, *args, **kwargs)
 
     async def like_async(self, *args: Any, **kwargs: Any) -> bool:
         """Сокращение для::
 
-        await client.users_likes_artists_add(artist.id, user.id *args, **kwargs)
+        await client.users_likes_artists_add(artist.id_required, user.id *args, **kwargs)
         """
         assert self.valid_async_client(self.client)
-        return await self.client.users_likes_artists_add(self.id, self.client.account_uid, *args, **kwargs)
+        return await self.client.users_likes_artists_add(self.id_required, self.client.account_uid, *args, **kwargs)
 
     def dislike(self, *args: Any, **kwargs: Any) -> bool:
         """Сокращение для::
 
-        client.users_likes_artists_remove(artist.id, user.id *args, **kwargs)
+        client.users_likes_artists_remove(artist.id_required, user.id *args, **kwargs)
         """
         assert self.valid_client(self.client)
-        return self.client.users_likes_artists_remove(self.id, self.client.account_uid, *args, **kwargs)
+        return self.client.users_likes_artists_remove(self.id_required, self.client.account_uid, *args, **kwargs)
 
     async def dislike_async(self, *args: Any, **kwargs: Any) -> bool:
         """Сокращение для::
 
-        await client.users_likes_artists_remove(artist.id, user.id *args, **kwargs)
+        await client.users_likes_artists_remove(artist.id_required, user.id *args, **kwargs)
         """
         assert self.valid_async_client(self.client)
-        return await self.client.users_likes_artists_remove(self.id, self.client.account_uid, *args, **kwargs)
+        return await self.client.users_likes_artists_remove(self.id_required, self.client.account_uid, *args, **kwargs)
 
     def get_tracks(self, page: int = 0, page_size: int = 20, *args: Any, **kwargs: Any) -> Optional['ArtistTracks']:
         """Сокращение для::
 
-        client.artists_tracks(artist.id, page, page_size, *args, **kwargs)
+        client.artists_tracks(artist.id_required, page, page_size, *args, **kwargs)
         """
         assert self.valid_client(self.client)
-        return self.client.artists_tracks(self.id, page, page_size, *args, **kwargs)
+        return self.client.artists_tracks(self.id_required, page, page_size, *args, **kwargs)
 
     async def get_tracks_async(
         self, page: int = 0, page_size: int = 20, *args: Any, **kwargs: Any
     ) -> Optional['ArtistTracks']:
         """Сокращение для::
 
-        await client.artists_tracks(artist.id, page, page_size, *args, **kwargs)
+        await client.artists_tracks(artist.id_required, page, page_size, *args, **kwargs)
         """
         assert self.valid_async_client(self.client)
-        return await self.client.artists_tracks(self.id, page, page_size, *args, **kwargs)
+        return await self.client.artists_tracks(self.id_required, page, page_size, *args, **kwargs)
 
     def get_albums(
         self, page: int = 0, page_size: int = 20, sort_by: str = 'year', *args: Any, **kwargs: Any
     ) -> Optional['ArtistAlbums']:
         """Сокращение для::
 
-        client.artists_direct_albums(artist.id, page, page_size, sort_by, *args, **kwargs)
+        client.artists_direct_albums(artist.id_required, page, page_size, sort_by, *args, **kwargs)
         """
         assert self.valid_client(self.client)
-        return self.client.artists_direct_albums(self.id, page, page_size, sort_by, *args, **kwargs)
+        return self.client.artists_direct_albums(self.id_required, page, page_size, sort_by, *args, **kwargs)
 
     async def get_albums_async(
         self, page: int = 0, page_size: int = 20, sort_by: str = 'year', *args: Any, **kwargs: Any
     ) -> Optional['ArtistAlbums']:
         """Сокращение для::
 
-        await client.artists_direct_albums(artist.id, page, page_size, sort_by, *args, **kwargs)
+        await client.artists_direct_albums(artist.id_required, page, page_size, sort_by, *args, **kwargs)
         """
         assert self.valid_async_client(self.client)
-        return await self.client.artists_direct_albums(self.id, page, page_size, sort_by, *args, **kwargs)
+        return await self.client.artists_direct_albums(self.id_required, page, page_size, sort_by, *args, **kwargs)
 
     @classmethod
     def de_json(cls, data: 'JSONType', client: 'ClientType') -> Optional['Artist']:
@@ -328,6 +344,8 @@ class Artist(YandexMusicModel):
 
     # camelCase псевдонимы
 
+    #: Псевдоним для :attr:`id_required`
+    idRequired = id_required
     #: Псевдоним для :attr:`get_op_image_url`
     getOpImageUrl = get_op_image_url
     #: Псевдоним для :attr:`get_og_image_url`
diff --git a/yandex_music/exceptions.py b/yandex_music/exceptions.py
index 64dee8d..9dd1fc0 100644
--- a/yandex_music/exceptions.py
+++ b/yandex_music/exceptions.py
@@ -12,6 +12,10 @@ class InvalidBitrateError(YandexMusicError):
     """Класс исключения, вызываемого при попытке загрузки трека с недоступным битрейтом."""
 
 
+class IdMissingError(YandexMusicError):
+    """Класс исключения, вызываемого при попытке использования отсутствующего ID."""
+
+
 class NetworkError(YandexMusicError):
     """Базовый класс исключений, вызываемых для ошибок, связанных с запросами к серверу."""
 


Test patch:
diff --git a/tests/test_artist.py b/tests/test_artist.py
index 180fc3b..6b1ddb2 100644
--- a/tests/test_artist.py
+++ b/tests/test_artist.py
@@ -1,4 +1,7 @@
+import pytest
+
 from yandex_music import Artist
+from yandex_music.exceptions import IdMissingError
 
 
 class TestArtist:
@@ -70,10 +73,14 @@ class TestArtist:
         assert Artist.de_list([], client) == []
 
     def test_de_json_required(self, client, cover):
-        json_dict = {'id': self.id}
-        artist = Artist.de_json(json_dict, client)
+        # We don't have any required fields anymore,
+        #   so just make sure we don't throw any errors.
+        Artist.de_json({}, client)
 
-        assert artist.id == self.id
+    def test_de_json_ugc(self, client):
+        # An example of UGC artist from #663
+        artist = Artist.de_json({'name': self.name}, client)
+        assert artist.name == self.name
 
     def test_de_json_all(
         self, client, cover, counts, ratings, link, track_without_artists, description, artist_decomposed
@@ -154,3 +161,10 @@ class TestArtist:
         assert a is not b
 
         assert a == c
+
+    def test_id_required(self, client):
+        artist = Artist.de_json({'name': self.name}, client)
+
+        # Make sure we throw an error if we try to access the id_required property when id is None
+        with pytest.raises(IdMissingError):
+            _ = artist.id_required