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 +1 -0
- ymtui/api/__init__.py +1 -0
- ymtui/api/client.py +282 -0
- ymtui/app.py +495 -0
- ymtui/config.py +33 -0
- ymtui/i18n.py +215 -0
- ymtui/mpris.py +226 -0
- ymtui/player.py +115 -0
- ymtui/screens/__init__.py +1 -0
- ymtui/screens/help_screen.py +25 -0
- ymtui/screens/main_screen.py +406 -0
- ymtui/state.py +44 -0
- ymtui/styles/app.tcss +171 -0
- ymtui/widgets/__init__.py +1 -0
- ymtui/widgets/library.py +110 -0
- ymtui/widgets/now_playing.py +153 -0
- ymtui/widgets/search.py +30 -0
- ymtui/widgets/tracklist.py +160 -0
- ymtui-0.1.0.dist-info/METADATA +182 -0
- ymtui-0.1.0.dist-info/RECORD +23 -0
- ymtui-0.1.0.dist-info/WHEEL +4 -0
- ymtui-0.1.0.dist-info/entry_points.txt +2 -0
- ymtui-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|