tuijam 17 KB

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