tuijam 16 KB

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