ymtui 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ymtui/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Yandex Music console player."""
ymtui/api/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Yandex Music API package."""
ymtui/api/client.py ADDED
@@ -0,0 +1,282 @@
1
+ """Thin wrapper around yandex-music Client."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Optional
5
+
6
+ from yandex_music import Client, Playlist, Track
7
+
8
+
9
+ class YMClient:
10
+ WAVE_STATION = 'user:onyourwave' # «Моя волна» personal radio
11
+
12
+ def __init__(self, token: str) -> None:
13
+ self._client = Client(token).init()
14
+ self._liked_ids: set[str] | None = None
15
+ self._metatags = None
16
+ self._metatags_fetched = False
17
+
18
+ # ------------------------------------------------------------------
19
+ # Account
20
+ # ------------------------------------------------------------------
21
+
22
+ @property
23
+ def account(self):
24
+ return self._client.me.account
25
+
26
+ @property
27
+ def display_name(self) -> str:
28
+ acc = self.account
29
+ return acc.display_name or acc.login or 'ymtui'
30
+
31
+ # ------------------------------------------------------------------
32
+ # Track collections
33
+ # ------------------------------------------------------------------
34
+
35
+ def liked_tracks(self) -> list[Track]:
36
+ likes = self._client.users_likes_tracks()
37
+ if not likes:
38
+ return []
39
+ # fetch_tracks() resolves the short references into full Track objects
40
+ try:
41
+ tracks = likes.fetch_tracks()
42
+ except Exception:
43
+ tracks = [t.track for t in likes if t.track]
44
+ self._liked_ids = {str(t.id) for t in tracks if t.id}
45
+ return tracks
46
+
47
+ def liked_ids(self) -> set[str]:
48
+ """Return the set of liked track ids (cached, lazily fetched)."""
49
+ if self._liked_ids is None:
50
+ try:
51
+ likes = self._client.users_likes_tracks()
52
+ self._liked_ids = {str(t.id) for t in (likes or [])}
53
+ except Exception:
54
+ self._liked_ids = set()
55
+ return self._liked_ids
56
+
57
+ def playlists(self) -> list[Playlist]:
58
+ return self._client.users_playlists_list() or []
59
+
60
+ def feed_playlists(self) -> list[Playlist]:
61
+ """Smart 'Daily' playlists from the personal feed (Плейлист дня, Дежавю…)."""
62
+ try:
63
+ feed = self._client.feed()
64
+ except Exception:
65
+ return []
66
+ out: list[Playlist] = []
67
+ for generated in (feed.generated_playlists or []):
68
+ playlist = getattr(generated, 'data', None)
69
+ if playlist is not None and getattr(playlist, 'kind', None) is not None:
70
+ out.append(playlist)
71
+ return out
72
+
73
+ def new_playlists(self) -> list[Playlist]:
74
+ """Editorial 'new playlists' from the landing page."""
75
+ return self._landing_entities('new-playlists', 'kind')
76
+
77
+ def new_releases(self):
78
+ """New album releases from the landing page (list of Album)."""
79
+ return self._landing_entities('new-releases', 'id')
80
+
81
+ def _landing_entities(self, block: str, id_attr: str) -> list:
82
+ try:
83
+ result = self._client.landing(blocks=[block])
84
+ except Exception:
85
+ return []
86
+ if not result or not result.blocks:
87
+ return []
88
+ out = []
89
+ for b in result.blocks:
90
+ for entity in (b.entities or []):
91
+ data = entity.data
92
+ if data is not None and getattr(data, id_attr, None) is not None:
93
+ out.append(data)
94
+ return out
95
+
96
+ def metatag_tags(self, tree_title: str) -> list[tuple[str, str]]:
97
+ """Return (tag_id, title) pairs for a metatag tree (Genres/Moods/…)."""
98
+ if not self._metatags_fetched:
99
+ try:
100
+ self._metatags = self._client.metatags()
101
+ except Exception:
102
+ self._metatags = None
103
+ self._metatags_fetched = True
104
+ trees = (self._metatags.trees if self._metatags else None) or []
105
+ for tree in trees:
106
+ if tree.title == tree_title:
107
+ return [
108
+ (leaf.tag, leaf.title)
109
+ for leaf in (tree.leaves or [])
110
+ if getattr(leaf, 'tag', None)
111
+ ]
112
+ return []
113
+
114
+ def metatag_playlists(self, tag: str, limit: int = 30) -> list[Playlist]:
115
+ """Playlists for a metatag (e.g. 'Джаз')."""
116
+ try:
117
+ result = self._client.metatag_playlists(tag, limit=limit)
118
+ except Exception:
119
+ return []
120
+ return (result.playlists or []) if result else []
121
+
122
+ def album_tracks(self, album_id) -> list[Track]:
123
+ """Full track list of an album (flattened across volumes/discs)."""
124
+ try:
125
+ album = self._client.albums_with_tracks(album_id)
126
+ except Exception:
127
+ return []
128
+ tracks: list[Track] = []
129
+ for volume in (album.volumes or []):
130
+ tracks.extend(volume)
131
+ return tracks
132
+
133
+ def playlist_tracks(self, kind: int | str, user_id: Optional[str] = None) -> list[Track]:
134
+ # A playlist that was deleted server-side raises here — treat as empty.
135
+ try:
136
+ uid = user_id or self._client.me.account.uid
137
+ playlist = self._client.users_playlists(kind=kind, user_id=uid)
138
+ except Exception:
139
+ return []
140
+ if not playlist or not playlist.tracks:
141
+ return []
142
+ # Short references inside a playlist need resolving into full Tracks
143
+ try:
144
+ return [ts.fetch_track() for ts in playlist.tracks]
145
+ except Exception:
146
+ return [t.track for t in playlist.tracks if t.track]
147
+
148
+ def chart(self, region: str = 'russia') -> list[Track]:
149
+ result = self._client.chart(region)
150
+ if not result or not result.chart or not result.chart.tracks:
151
+ return []
152
+ return [t.track for t in result.chart.tracks if t.track]
153
+
154
+ # ------------------------------------------------------------------
155
+ # «Моя волна» — personal infinite radio (rotor station)
156
+ # ------------------------------------------------------------------
157
+
158
+ def wave_batch(self, queue: Optional[str] = None) -> tuple[list[Track], Optional[str]]:
159
+ """Return (tracks, batch_id) for the next wave batch.
160
+
161
+ ``queue`` is the id of the last played track, used to continue the
162
+ stream; pass ``None`` to start a fresh wave.
163
+ """
164
+ try:
165
+ result = self._client.rotor_station_tracks(self.WAVE_STATION, queue=queue)
166
+ except Exception:
167
+ return [], None
168
+ if not result:
169
+ return [], None
170
+ tracks = [s.track for s in (result.sequence or []) if s.track]
171
+ return tracks, result.batch_id
172
+
173
+ def wave_radio_started(self, batch_id: Optional[str]) -> None:
174
+ try:
175
+ self._client.rotor_station_feedback_radio_started(
176
+ self.WAVE_STATION, from_='ymtui', batch_id=batch_id
177
+ )
178
+ except Exception:
179
+ pass
180
+
181
+ def wave_track_started(self, track_id: str, batch_id: Optional[str]) -> None:
182
+ try:
183
+ self._client.rotor_station_feedback_track_started(
184
+ self.WAVE_STATION, track_id, batch_id=batch_id
185
+ )
186
+ except Exception:
187
+ pass
188
+
189
+ def wave_track_finished(self, track_id: str, seconds: float, batch_id: Optional[str]) -> None:
190
+ try:
191
+ self._client.rotor_station_feedback_track_finished(
192
+ self.WAVE_STATION, track_id, total_played_seconds=float(seconds or 0),
193
+ batch_id=batch_id,
194
+ )
195
+ except Exception:
196
+ pass
197
+
198
+ def wave_skip(self, track_id: str, seconds: float, batch_id: Optional[str]) -> None:
199
+ try:
200
+ self._client.rotor_station_feedback_skip(
201
+ self.WAVE_STATION, track_id, total_played_seconds=float(seconds or 0),
202
+ batch_id=batch_id,
203
+ )
204
+ except Exception:
205
+ pass
206
+
207
+ def history_tracks(self, limit: int = 50) -> list[Track]:
208
+ """Recently played tracks, most-recent first."""
209
+ try:
210
+ history = self._client.music_history(full_models_count=limit)
211
+ except Exception:
212
+ return []
213
+ ids: list[str] = []
214
+ for tab in (history.history_tabs or []) if history else []:
215
+ for group in (tab.items or []):
216
+ for item in (group.tracks or []):
217
+ if getattr(item, 'type', None) != 'track':
218
+ continue
219
+ item_id = getattr(item.data, 'item_id', None)
220
+ tid = getattr(item_id, 'track_id', None) if item_id else None
221
+ if tid and str(tid) not in ids:
222
+ ids.append(str(tid))
223
+ ids = ids[:limit]
224
+ if not ids:
225
+ return []
226
+ try:
227
+ tracks = self._client.tracks(ids)
228
+ except Exception:
229
+ return []
230
+ by_id = {str(t.id): t for t in tracks if t and t.id}
231
+ return [by_id[i] for i in ids if i in by_id]
232
+
233
+ def search_tracks(self, query: str, limit: int = 50) -> list[Track]:
234
+ if not query.strip():
235
+ return []
236
+ try:
237
+ result = self._client.search(query, type_='track', nocorrect=False)
238
+ except Exception:
239
+ return []
240
+ if not result or not result.tracks or not result.tracks.results:
241
+ return []
242
+ return list(result.tracks.results)[:limit]
243
+
244
+ # ------------------------------------------------------------------
245
+ # Likes
246
+ # ------------------------------------------------------------------
247
+
248
+ def toggle_like(self, track: Track) -> bool:
249
+ """Like or unlike a track. Returns the new liked state."""
250
+ tid = str(track.id)
251
+ liked = self.liked_ids()
252
+ try:
253
+ if tid in liked:
254
+ track.dislike()
255
+ liked.discard(tid)
256
+ return False
257
+ track.like()
258
+ liked.add(tid)
259
+ return True
260
+ except Exception:
261
+ return tid in liked
262
+
263
+ def is_liked(self, track: Track) -> bool:
264
+ return str(track.id) in self.liked_ids()
265
+
266
+ # ------------------------------------------------------------------
267
+ # Streaming
268
+ # ------------------------------------------------------------------
269
+
270
+ def get_stream_url(self, track: Track) -> Optional[str]:
271
+ """Return the best available direct stream URL for a track."""
272
+ try:
273
+ info_list = track.get_download_info(get_direct_links=True)
274
+ mp3_options = [i for i in info_list if i.codec == 'mp3']
275
+ if mp3_options:
276
+ best = max(mp3_options, key=lambda x: x.bitrate_in_kbps)
277
+ return best.direct_link
278
+ if info_list:
279
+ return info_list[0].direct_link
280
+ except Exception:
281
+ return None
282
+ return None