tuijam 14 KB

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