tuijam 19 KB

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