tuijam 21 KB

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