tuijam 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678
  1. #!/usr/bin/env python3
  2. from os.path import join, expanduser
  3. from itertools import zip_longest
  4. import urwid
  5. import gmusicapi
  6. import logging
  7. def sec_to_min_sec(sec_tot):
  8. if sec_tot is None:
  9. return 0, 0
  10. else:
  11. min_ = int(sec_tot // 60)
  12. sec = int(sec_tot % 60)
  13. return min_, sec
  14. class MusicObject:
  15. @staticmethod
  16. def to_ui(*txts, weights=()):
  17. first, *rest = [(weight, str(txt)) for weight, txt in zip_longest(weights, txts, fillvalue=1)]
  18. items = [('weight', first[0], urwid.SelectableIcon(first[1], 0))]
  19. for weight, line in rest:
  20. items.append(('weight', weight, urwid.Text(line)))
  21. line = urwid.Columns(items)
  22. line = urwid.AttrMap(line, 'search normal', 'search select')
  23. return line
  24. @staticmethod
  25. def header_ui(*txts, weights=()):
  26. header = urwid.Columns([('weight', weight, urwid.Text(('header', txt)))
  27. for weight, txt in zip_longest(weights, txts, fillvalue=1)])
  28. return urwid.AttrMap(header, 'header_bg')
  29. class Song(MusicObject):
  30. ui_weights = (1, 1, 1, 0.2)
  31. def __init__(self, title, album, albumId, artist, artistId, id_, type_, length):
  32. self.title = title
  33. self.album = album
  34. self.albumId = albumId
  35. self.artist = artist
  36. self.artistId = artistId
  37. self.id = id_
  38. self.type = type_
  39. self.length = length
  40. def __repr__(self):
  41. return f'<Song title:{self.title}, album:{self.album}, artist:{self.artist}>'
  42. def __str__(self):
  43. return f'{self.title} by {self.artist}'
  44. def fmt_str(self):
  45. return [('np_song', f'{self.title} '), 'by ', ('np_artist', f'{self.artist}')]
  46. def ui(self):
  47. return self.to_ui(self.title, self.album, self.artist,
  48. '{:02d}:{:02d}'.format(*self.length),
  49. weights=self.ui_weights)
  50. @classmethod
  51. def header(cls):
  52. return MusicObject.header_ui('Title', 'Album', 'Artist', 'Length', weights=cls.ui_weights)
  53. @staticmethod
  54. def from_dict(d):
  55. try:
  56. title = d['title']
  57. album = d['album']
  58. albumId = d['albumId']
  59. artist = d['artist']
  60. artistId = d['artistId'][0]
  61. try:
  62. id_ = d['id']
  63. type_ = 'library'
  64. except KeyError:
  65. id_ = d['storeId']
  66. type_ = 'store'
  67. length = sec_to_min_sec(int(d['durationMillis']) / 1000)
  68. return Song(title, album, albumId, artist, artistId, id_, type_, length)
  69. except KeyError as e:
  70. logging.exception(f"Missing Key {e} in dict \n{d}")
  71. return None
  72. class Album(MusicObject):
  73. def __init__(self, title, artist, artistId, year, id_):
  74. self.title = title
  75. self.artist = artist
  76. self.artistId = artistId
  77. self.year = year
  78. self.id = id_
  79. def __repr__(self):
  80. return f'<Album title:{self.title}, artist:{self.artist}, year:{self.year}>'
  81. def ui(self):
  82. return self.to_ui(self.title, self.artist, self.year)
  83. @staticmethod
  84. def header():
  85. return MusicObject.header_ui('Album', 'Artist', 'Year')
  86. @staticmethod
  87. def from_dict(d):
  88. try:
  89. title = d['name']
  90. artist = d['albumArtist']
  91. artistId = d['artistId'][0]
  92. try:
  93. year = d['year']
  94. except KeyError:
  95. year = ''
  96. id_ = d['albumId']
  97. return Album(title, artist, artistId, year, id_)
  98. except KeyError as e:
  99. logging.exception(f"Missing Key {e} in dict \n{d}")
  100. return None
  101. class Artist(MusicObject):
  102. def __init__(self, name, id_):
  103. self.name = name
  104. self.id = id_
  105. def __repr__(self):
  106. return f'<Artist name:{self.name}>'
  107. def ui(self):
  108. return self.to_ui(self.name)
  109. @staticmethod
  110. def header():
  111. return MusicObject.header_ui('Artist')
  112. @staticmethod
  113. def from_dict(d):
  114. try:
  115. name = d['name']
  116. id_ = d['artistId']
  117. return Artist(name, id_)
  118. except KeyError as e:
  119. logging.exception(f"Missing Key {e} in dict \n{d}")
  120. return None
  121. class SearchInput(urwid.Edit):
  122. def __init__(self, app):
  123. self.app = app
  124. super().__init__('search > ', multiline=False, allow_tab=False)
  125. def keypress(self, size, key):
  126. if key == 'enter':
  127. txt = self.edit_text
  128. if txt:
  129. self.set_edit_text('')
  130. self.app.search(txt)
  131. return None
  132. else:
  133. size = (size[0],)
  134. return super().keypress(size, key)
  135. class SearchPanel(urwid.ListBox):
  136. def __init__(self, app):
  137. self.app = app
  138. self.walker = urwid.SimpleFocusListWalker([])
  139. self.history = []
  140. self.search_results = ([], [], [])
  141. super().__init__(self.walker)
  142. def keypress(self, size, key):
  143. if key == 'q':
  144. selected = self.selected_search_obj()
  145. if selected:
  146. if type(selected) == Song:
  147. self.app.queue_panel.add_song_to_queue(selected)
  148. elif type(selected) == Album:
  149. self.app.queue_panel.add_album_to_queue(selected)
  150. elif key == 'e':
  151. if self.selected_search_obj() is not None:
  152. self.app.expand(self.selected_search_obj())
  153. elif key == 'backspace':
  154. self.back()
  155. elif key == 'r':
  156. if self.selected_search_obj() is not None:
  157. self.app.create_radio_station(self.selected_search_obj())
  158. elif key == 'j':
  159. super().keypress(size, 'down')
  160. elif key == 'k':
  161. super().keypress(size, 'up')
  162. else:
  163. super().keypress(size, key)
  164. def back(self):
  165. if self.history:
  166. self.set_search_results(*self.history.pop())
  167. def update_search_results(self, songs, albums, artists):
  168. self.history.append(self.search_results)
  169. self.set_search_results(songs, albums, artists)
  170. def set_search_results(self, songs, albums, artists):
  171. songs = [obj for obj in songs if obj is not None]
  172. albums = [obj for obj in albums if obj is not None]
  173. artists = [obj for obj in artists if obj is not None]
  174. self.search_results = (songs, albums, artists)
  175. self.walker.clear()
  176. if artists:
  177. self.walker.append(Artist.header())
  178. for artist in artists:
  179. self.walker.append(artist.ui())
  180. if albums:
  181. self.walker.append(Album.header())
  182. for album in albums:
  183. self.walker.append(album.ui())
  184. if songs:
  185. self.walker.append(Song.header())
  186. for song in songs:
  187. self.walker.append(song.ui())
  188. if self.walker:
  189. self.walker.set_focus(1)
  190. def selected_search_obj(self):
  191. focus_id = self.walker.get_focus()[1]
  192. songs, albums, artists = self.search_results
  193. try:
  194. if artists:
  195. focus_id -= 1
  196. if focus_id < len(artists):
  197. return artists[focus_id]
  198. focus_id -= len(artists)
  199. if albums:
  200. focus_id -= 1
  201. if focus_id < len(albums):
  202. return albums[focus_id]
  203. focus_id -= len(albums)
  204. focus_id -= 1
  205. return songs[focus_id]
  206. except (IndexError, TypeError):
  207. return None
  208. class PlayBar(urwid.ProgressBar):
  209. # Class stolen from: https://github.com/and3rson/clay/blob/master/clay/playbar.py
  210. vol_inds = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']
  211. def __init__(self, app, *args, **kwargs):
  212. super(PlayBar, self).__init__(*args, **kwargs)
  213. self.app = app
  214. def get_prog_tot(self):
  215. curr_time_s = self.app.player.time_pos
  216. rem_time_s = self.app.player.time_remaining
  217. if curr_time_s is None or rem_time_s is None:
  218. return 0, 0
  219. else:
  220. progress = int(curr_time_s)
  221. total = int(curr_time_s + rem_time_s)
  222. return progress, total
  223. def get_text(self):
  224. if self.app.current_song is None:
  225. return 'Idle'
  226. progress, total = self.get_prog_tot()
  227. # return [u' \u25B6 ' if self.app.play_state == 'play' else u' \u25A0 ',
  228. # ('np_artist', self.app.current_song.artist),
  229. # ' - ',
  230. # ('np_song', self.app.current_song.title),
  231. # ' [{:02d}:{:02d} / {:02d}:{:02d}]'.format(progress // 60,
  232. # progress % 60,
  233. # total // 60,
  234. # total % 60)
  235. # ]
  236. return ' {} {} - {} [{:02d}:{:02d} / {:02d}:{:02d}] {}'.format(
  237. '▶' if self.app.play_state == 'play' else '■',
  238. self.app.current_song.artist,
  239. self.app.current_song.title,
  240. progress // 60,
  241. progress % 60,
  242. total // 60,
  243. total % 60,
  244. self.vol_inds[self.app.volume]
  245. )
  246. def update(self):
  247. self._invalidate()
  248. progress, total = self.get_prog_tot()
  249. if progress >= 0 and total > 0:
  250. percent = progress / total * 100
  251. self.set_completion(percent)
  252. else:
  253. self.set_completion(0)
  254. def render(self, size, focus=False):
  255. """
  256. Render the progress bar - fixed implementation.
  257. For details see https://github.com/urwid/urwid/pull/261
  258. """
  259. (maxcol,) = size
  260. txt = urwid.Text(self.get_text(), self.text_align, urwid.widget.CLIP)
  261. c = txt.render((maxcol,))
  262. cf = float(self.current) * maxcol / self.done
  263. ccol_dirty = int(cf)
  264. ccol = len(c._text[0][:ccol_dirty].decode(
  265. 'utf-8', 'ignore'
  266. ).encode(
  267. 'utf-8'
  268. ))
  269. cs = 0
  270. if self.satt is not None:
  271. cs = int((cf - ccol) * 8)
  272. if ccol < 0 or (ccol == 0 and cs == 0):
  273. c._attr = [[(self.normal, maxcol)]]
  274. elif ccol >= maxcol:
  275. c._attr = [[(self.complete, maxcol)]]
  276. elif cs and c._text[0][ccol] == " ":
  277. t = c._text[0]
  278. cenc = self.eighths[cs].encode("utf-8")
  279. c._text[0] = t[:ccol] + cenc + t[ccol + 1:]
  280. a = []
  281. if ccol > 0:
  282. a.append((self.complete, ccol))
  283. a.append((self.satt, len(cenc)))
  284. if maxcol - ccol - 1 > 0:
  285. a.append((self.normal, maxcol - ccol - 1))
  286. c._attr = [a]
  287. c._cs = [[(None, len(c._text[0]))]]
  288. else:
  289. c._attr = [[(self.complete, ccol),
  290. (self.normal, maxcol - ccol)]]
  291. return c
  292. class QueuePanel(urwid.ListBox):
  293. def __init__(self, app):
  294. self.app = app
  295. self.walker = urwid.SimpleFocusListWalker([])
  296. self.queue = []
  297. super().__init__(self.walker)
  298. def add_song_to_queue(self, song):
  299. if song:
  300. self.queue.append(song)
  301. self.walker.append(song.ui())
  302. def add_album_to_queue(self, album):
  303. album_info = self.app.g_api.get_album_info(album.id)
  304. for track in album_info['tracks']:
  305. song = Song.from_dict(track)
  306. if song:
  307. self.queue.append(song)
  308. self.walker.append(song.ui())
  309. def drop(self, idx):
  310. if 0 <= idx < len(self.queue):
  311. self.queue.pop(idx)
  312. self.walker.pop(idx)
  313. def clear(self, idx):
  314. self.queue.clear()
  315. self.walker.clear()
  316. def swap(self, idx1, idx2):
  317. if (0 <= idx1 < len(self.queue)) and (0 <= idx2 < len(self.queue)):
  318. obj1, obj2 = self.queue[idx1], self.queue[idx2]
  319. self.queue[idx1], self.queue[idx2] = obj2, obj1
  320. ui1, ui2 = self.walker[idx1], self.walker[idx2]
  321. self.walker[idx1], self.walker[idx2] = ui2, ui1
  322. def shuffle(self):
  323. from random import shuffle as rshuffle
  324. rshuffle(self.queue)
  325. self.walker.clear()
  326. for s in self.queue:
  327. self.walker.append(s.ui())
  328. def play_next(self):
  329. if self.walker:
  330. self.walker.pop(0)
  331. self.app.play(self.queue.pop(0))
  332. else:
  333. self.app.stop()
  334. def selected_queue_obj(self):
  335. try:
  336. focus_id = self.walker.get_focus()[1]
  337. return self.queue[focus_id]
  338. except (IndexError, TypeError):
  339. return None
  340. def keypress(self, size, key):
  341. focus_id = self.walker.get_focus()[1]
  342. if focus_id is None:
  343. return super().keypress(size, key)
  344. if key == 'u':
  345. self.swap(focus_id, focus_id-1)
  346. self.keypress(size, 'up')
  347. elif key == 'd':
  348. self.swap(focus_id, focus_id+1)
  349. self.keypress(size, 'down')
  350. elif key == 'delete':
  351. self.drop(focus_id)
  352. elif key == 'j':
  353. super().keypress(size, 'down')
  354. elif key == 'k':
  355. super().keypress(size, 'up')
  356. elif key == 'e':
  357. self.app.expand(self.selected_queue_obj())
  358. elif key == ' ':
  359. if self.app.play_state == 'stop':
  360. self.play_next()
  361. else:
  362. self.app.toggle_play()
  363. else:
  364. return super().keypress(size, key)
  365. class App(urwid.Pile):
  366. palette = [
  367. ('header', 'white,underline', 'black'),
  368. ('header_bg', 'white', 'black'),
  369. ('line', 'white', ''),
  370. ('search normal', 'white', ''),
  371. ('search select', '', '', '', 'white', '#D32'),
  372. ('region_bg normal', '', ''),
  373. ('region_bg select', '', 'black'),
  374. ('progress', '', '', '', '#FFF', '#F54'),
  375. ('progress_remaining', '', '', '', '#FFF', '#444'),
  376. ]
  377. def __init__(self):
  378. self.read_config()
  379. self.g_api = gmusicapi.Mobileclient(debug_logging=False)
  380. self.g_api.login(self.email, self.password, self.device_id)
  381. import mpv
  382. self.player = mpv.MPV()
  383. self.player.volume = 100
  384. self.volume = 8
  385. @self.player.event_callback('end_file')
  386. def callback(event):
  387. if event['event']['reason'] == 0:
  388. self.queue_panel.play_next()
  389. self.search_panel = SearchPanel(self)
  390. search_panel_wrapped = urwid.LineBox(self.search_panel, title='Search Results')
  391. search_panel_wrapped = urwid.AttrMap(search_panel_wrapped, 'region_bg normal', 'region_bg select')
  392. self.search_panel_wrapped = search_panel_wrapped
  393. self.playbar = PlayBar(self, 'progress_remaining', 'progress', current=0, done=100)
  394. # self.now_playing = urwid.Text('')
  395. # self.progress = urwid.Text('0:00/0:00', align='right')
  396. # status_line = urwid.Columns([('weight', 3, self.now_playing),
  397. # ('weight', 1, self.progress)])
  398. self.queue_panel = QueuePanel(self)
  399. queue_panel_wrapped = urwid.LineBox(self.queue_panel, title='Queue')
  400. queue_panel_wrapped = urwid.AttrMap(queue_panel_wrapped, 'region_bg normal', 'region_bg select')
  401. self.queue_panel_wrapped = queue_panel_wrapped
  402. self.search_input = urwid.Edit('> ', multiline=False)
  403. self.search_input = SearchInput(self)
  404. urwid.Pile.__init__(self, [('weight', 12, search_panel_wrapped),
  405. ('pack', self.playbar),
  406. ('weight', 7, queue_panel_wrapped),
  407. ('pack', self.search_input)
  408. ])
  409. self.set_focus(self.search_input)
  410. self.play_state = 'stop'
  411. self.current_song = None
  412. self.history = []
  413. def read_config(self):
  414. import yaml
  415. config_file = join(expanduser('~'), '.config', 'tuijam', 'config.yaml')
  416. with open(config_file) as f:
  417. config = yaml.load(f.read())
  418. self.email = config['email']
  419. self.password = config['password']
  420. self.device_id = config['device_id']
  421. def refresh(self, *args, **kwargs):
  422. if self.play_state == 'play' and self.player.eof_reached:
  423. self.queue_panel.play_next()
  424. self.playbar.update()
  425. self.loop.draw_screen()
  426. if self.play_state == 'play':
  427. self.schedule_refresh()
  428. def schedule_refresh(self, dt=0.5):
  429. self.loop.set_alarm_in(dt, self.refresh)
  430. def play(self, song):
  431. self.current_song = song
  432. url = self.g_api.get_stream_url(song.id)
  433. self.player.play(url)
  434. self.player.pause = False
  435. self.play_state = 'play'
  436. self.playbar.update()
  437. self.history.append(song)
  438. self.schedule_refresh()
  439. def stop(self):
  440. self.current_song = None
  441. self.player.pause = True
  442. self.play_state = 'stop'
  443. self.playbar.update()
  444. def seek(self, dt):
  445. try:
  446. self.player.seek(dt)
  447. except SystemError:
  448. pass
  449. def toggle_play(self):
  450. if self.play_state == 'play':
  451. self.player.pause = True
  452. self.play_state = 'pause'
  453. self.playbar.update()
  454. elif self.play_state == 'pause':
  455. self.player.pause = False
  456. self.play_state = 'play'
  457. self.playbar.update()
  458. self.schedule_refresh()
  459. elif self.play_state == 'stop':
  460. self.queue_panel.play_next()
  461. def volume_down(self):
  462. self.volume = max([0, self.volume-1])
  463. self.player.volume = int(self.volume * 100 / 8)
  464. self.playbar.update()
  465. def volume_up(self):
  466. self.volume = min([8, self.volume+1])
  467. self.player.volume = int(self.volume * 100 / 8)
  468. self.playbar.update()
  469. def keypress(self, size, key):
  470. if key == 'tab':
  471. current_focus = self.focus
  472. if current_focus == self.search_panel_wrapped:
  473. self.set_focus(self.queue_panel_wrapped)
  474. elif current_focus == self.queue_panel_wrapped:
  475. self.set_focus(self.search_input)
  476. else:
  477. self.set_focus(self.search_panel_wrapped)
  478. elif key == 'shift tab':
  479. current_focus = self.focus
  480. if current_focus == self.search_panel_wrapped:
  481. self.set_focus(self.search_input)
  482. elif current_focus == self.queue_panel_wrapped:
  483. self.set_focus(self.search_panel_wrapped)
  484. else:
  485. self.set_focus(self.queue_panel_wrapped)
  486. elif key == 'ctrl p':
  487. self.toggle_play()
  488. elif key == 'ctrl q':
  489. self.stop()
  490. elif key == 'ctrl n':
  491. self.queue_panel.play_next()
  492. elif key == 'ctrl r':
  493. self.search_panel.update_search_results(self.history, [], [])
  494. elif key == 'ctrl s':
  495. self.queue_panel.shuffle()
  496. elif self.focus != self.search_input:
  497. if key == '>':
  498. self.seek(10)
  499. elif key == '<':
  500. self.seek(-10)
  501. elif key in '-_':
  502. self.volume_down()
  503. elif key in '+=':
  504. self.volume_up()
  505. else:
  506. return self.focus.keypress(size, key)
  507. else:
  508. return self.focus.keypress(size, key)
  509. def expand(self, obj):
  510. if obj is None:
  511. return
  512. if type(obj) == Song:
  513. album_info = self.g_api.get_album_info(obj.albumId)
  514. songs = [Song.from_dict(track) for track in album_info['tracks']]
  515. albums = [Album.from_dict(album_info)]
  516. artists = [Artist(obj.artist, obj.artistId)]
  517. elif type(obj) == Album:
  518. album_info = self.g_api.get_album_info(obj.id)
  519. songs = [Song.from_dict(track) for track in album_info['tracks']]
  520. albums = [obj]
  521. artists = [Artist(obj.artist, obj.artistId)]
  522. else: # Artist
  523. artist_info = self.g_api.get_artist_info(obj.id)
  524. songs = [Song.from_dict(track) for track in artist_info['topTracks']]
  525. albums = [Album.from_dict(album) for album in artist_info.get('albums', [])]
  526. artists = [Artist.from_dict(artist) for artist in artist_info['related_artists']]
  527. artists.insert(0, obj)
  528. self.search_panel.update_search_results(songs, albums, artists)
  529. def search(self, query):
  530. results = self.g_api.search(query)
  531. songs = [Song.from_dict(hit['track']) for hit in results['song_hits']]
  532. albums = [Album.from_dict(hit['album']) for hit in results['album_hits']]
  533. artists = [Artist.from_dict(hit['artist']) for hit in results['artist_hits']]
  534. self.search_panel.update_search_results(songs, albums, artists)
  535. self.set_focus(self.search_panel_wrapped)
  536. def create_radio_station(self, obj):
  537. if type(obj) == Song:
  538. station_name = obj.title + " radio"
  539. station_id = self.g_api.create_station(station_name, track_id=obj.id)
  540. elif type(obj) == Album:
  541. station_name = obj.title + " radio"
  542. station_id = self.g_api.create_station(station_name, album_id=obj.id)
  543. else: # Artist
  544. station_name = obj.name + " radio"
  545. station_id = self.g_api.create_station(station_name, artist_id=obj.id)
  546. song_dicts = self.g_api.get_station_tracks(station_id, num_tracks=50)
  547. for song_dict in song_dicts:
  548. self.queue_panel.add_song_to_queue(Song.from_dict(song_dict))
  549. def cleanup(self):
  550. self.player.quit()
  551. del self.player
  552. self.g_api.logout()
  553. if __name__ == '__main__':
  554. log_file = join(expanduser('~'), '.config', 'tuijam', 'log.txt')
  555. logging.basicConfig(filename=log_file, filemode='w', level=logging.ERROR)
  556. app = App()
  557. import signal
  558. def handle_sigint(signum, frame):
  559. raise urwid.ExitMainLoop()
  560. signal.signal(signal.SIGINT, handle_sigint)
  561. loop = urwid.MainLoop(app, app.palette)
  562. app.loop = loop
  563. loop.screen.set_terminal_properties(256)
  564. try:
  565. loop.run()
  566. except Exception as e:
  567. logging.exception(e)
  568. print("Something bad happened! :( see log file ($HOME/.config/tuijam/log.txt) for more information.")
  569. app.cleanup()