tuijam 19 KB

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