diff --git a/Navidrome.py b/Navidrome.py new file mode 100644 index 0000000..5c0c4ac --- /dev/null +++ b/Navidrome.py @@ -0,0 +1,60 @@ +import requests +import urllib.parse + +import SubSonic + + +""" +Navidrome has some API endpoints that are not included in the SubSonic or OpenSubSonic standards. +Nor are they documented anywhere I can find, the NavidromeAPI class is a collection of methods to interact with this hidden API. +The endpoints are reverse engineered from observing requests made by the Navidrome WebUI. +""" +class NavidromeAPI: + username: str + password: str + base_url: str + + auth_token: str + auth_header: dict = {} + + def __init__(self, u: str, p: str, base_url: str): + self.username = u + self.password = p + self.base_url = base_url + + self.auth_token = self._login() + self.auth_header['X-Nd-Authorization'] = f"Bearer {self.auth_token}" + + def _encodePayload(self, payload: dict) -> str: + return urllib.parse.urlencode(payload, quote_via=urllib.parse.quote_plus) + + def _login(self) -> str: + url = self.base_url + '/auth/login' + + payload = { + 'username': self.username, + 'password': self.password + } + + response = requests.post(url, json=payload) + + j = response.json() + + return j['token'] + + def getAllSongs(self, end: int, start: int, order: str = 'ASC', sort: str = 'title') -> list[SubSonic.Song]: + url = self.base_url + '/api/song?' + + payload = { + "_end": end, + "_order": order, + "_sort": sort, + "_start": start + } + + response = requests.get(url + self._encodePayload(payload), headers=self.auth_header) + print(len(response.content)) + + return [SubSonic.Song(song_d) for song_d in response.json()] + + diff --git a/app.py b/app.py index e9846b0..7dd15ba 100644 --- a/app.py +++ b/app.py @@ -23,6 +23,7 @@ BITRATE = int(BITRATE) if BITRATE else BITRATE IMAGE_MODE = config.getboolean('GENERAL', 'GUI') import SubSonic +import Navidrome os.environ['PYGAME_HIDE_SUPPORT_PROMPT'] = "hide" import pygame @@ -34,7 +35,7 @@ from textual import on, events, work from textual.binding import Binding from textual.reactive import reactive from textual.app import App, ComposeResult, RenderResult -from textual.widgets import Footer, Header, OptionList, DataTable, ProgressBar, Label, Input, Placeholder, Static, Rule, Button +from textual.widgets import Footer, Header, OptionList, DataTable, ProgressBar, Label, Input, Placeholder, Static, Rule, Button, ListView, ListItem from textual.widget import Widget from textual.containers import Horizontal, Vertical, Container, Center from textual.screen import Screen, ModalScreen @@ -46,9 +47,12 @@ from PIL import Image, ImageFile ImageFile.LOAD_TRUNCATED_IMAGES = True -u, p = [s.strip() for s in open('auth', 'r').read().split(',')] +user, passw = [s.strip() for s in open('auth', 'r').read().split(',')] + +MUSIC_SERVER_URL = 'https://music.0x01fe.net' + +conn = SubSonic.SubpyConn(user, passw, MUSIC_SERVER_URL) -conn = SubSonic.SubpyConn(u, p, "https://music.0x01fe.net") pygame.mixer.init() @@ -197,7 +201,7 @@ class ArtistAlbumsGUI(Screen): yield Label(Text("F1 ", style="bold orange").append_text(Text("Player", style="white")), classes="headerb") yield Label(Text("* F2 ", style="bold orange").append_text(Text("Artists", style="white")), classes="headerb") yield Label(Text("F3 ", style="bold orange").append_text(Text("Albums", style="white")), classes="headerb") - yield Label(Text("F4 ", style="bold orange").append_text(Text("Favourites", style="white")), classes="headerb") + yield Label(Text("F4 ", style="bold orange").append_text(Text("Songs", style="white")), classes="headerb") yield Label(Text("F5 ", style="bold orange").append_text(Text("Playlists", style="white")), classes="headerb") yield Label(classes='artistNameHeader') yield Rule() @@ -297,7 +301,7 @@ class Artists(Screen): yield Label(Text("F1 ", style="bold orange").append_text(Text("Player", style="white")), classes="headerb") yield Label(Text("* F2 ", style="bold orange").append_text(Text("Artists", style="white")), classes="headerb") yield Label(Text("F3 ", style="bold orange").append_text(Text("Albums", style="white")), classes="headerb") - yield Label(Text("F4 ", style="bold orange").append_text(Text("Favourites", style="white")), classes="headerb") + yield Label(Text("F4 ", style="bold orange").append_text(Text("Songs", style="white")), classes="headerb") yield Label(Text("F5 ", style="bold orange").append_text(Text("Playlists", style="white")), classes="headerb") with Vertical(id="artistContainer"): yield Input(placeholder="Search") @@ -438,7 +442,7 @@ class ArtistsGUI(Screen): yield Label(Text("F1 ", style="bold orange").append_text(Text("Player", style="white")), classes="headerb") yield Label(Text("* F2 ", style="bold orange").append_text(Text("Artists", style="white")), classes="headerb") yield Label(Text("F3 ", style="bold orange").append_text(Text("Albums", style="white")), classes="headerb") - yield Label(Text("F4 ", style="bold orange").append_text(Text("Favourites", style="white")), classes="headerb") + yield Label(Text("F4 ", style="bold orange").append_text(Text("Songs", style="white")), classes="headerb") yield Label(Text("F5 ", style="bold orange").append_text(Text("Playlists", style="white")), classes="headerb") yield Input(placeholder="Search", id="artistSearchBar") with Horizontal(): @@ -565,7 +569,7 @@ class Albums(Screen): yield Label(Text("F1 ", style="bold orange").append_text(Text("Player", style="white")), classes="headerb") yield Label(Text("F2 ", style="bold orange").append_text(Text("Artists", style="white")), classes="headerb") yield Label(Text("* F3 ", style="bold orange").append_text(Text("Albums", style="white")), classes="headerb") - yield Label(Text("F4 ", style="bold orange").append_text(Text("Favourites", style="white")), classes="headerb") + yield Label(Text("F4 ", style="bold orange").append_text(Text("Songs", style="white")), classes="headerb") yield Label(Text("F5 ", style="bold orange").append_text(Text("Playlists", style="white")), classes="headerb") with Vertical(id="albumContainer"): yield Input(placeholder="Search", id="albumSearchBar") @@ -610,7 +614,7 @@ class Playlists(Screen): yield Label(Text("F1 ", style="bold orange").append_text(Text("Player", style="white")), classes="headerb") yield Label(Text("F2 ", style="bold orange").append_text(Text("Artists", style="white")), classes="headerb") yield Label(Text("F3 ", style="bold orange").append_text(Text("Albums", style="white")), classes="headerb") - yield Label(Text("F4 ", style="bold orange").append_text(Text("Favourites", style="white")), classes="headerb") + yield Label(Text("F4 ", style="bold orange").append_text(Text("Songs", style="white")), classes="headerb") yield Label(Text("* F5 ", style="bold orange").append_text(Text("Playlists", style="white")), classes="headerb") yield DataTable() yield Footer() @@ -703,12 +707,24 @@ class PlaylistSongs(Screen): class SongsScreen(Screen): BINDINGS = [ - + ("p", "play_song()", "Play Song"), + ("q", "queue_song()", "Queue Song"), + ("m", "shuffle_mix()", 'Shuffle Play'), + ('a', "page_backward()", "Prev. Page"), + ('d', "page_forward()", "Next Page") ] songs: list[SubSonic.Song] selected_song_id: str | None = None + # 0 = All, 1 = Favourites + mode: int = 0 + + page: dict[int, int] = { + 0: 0, + 1: 0 + } + def compose(self) -> ComposeResult: yield Header(icon='') @@ -716,15 +732,87 @@ class SongsScreen(Screen): yield Label(Text("F1 ", style="bold orange").append_text(Text("Player", style="white")), classes="headerb") yield Label(Text("F2 ", style="bold orange").append_text(Text("Artists", style="white")), classes="headerb") yield Label(Text("F3 ", style="bold orange").append_text(Text("Albums", style="white")), classes="headerb") - yield Label(Text("* F4 ", style="bold orange").append_text(Text("Favourites", style="white")), classes="headerb") + yield Label(Text("* F4 ", style="bold orange").append_text(Text("Songs", style="white")), classes="headerb") yield Label(Text("F5 ", style="bold orange").append_text(Text("Playlists", style="white")), classes="headerb") with Horizontal(id='songsScreen'): - yield Placeholder(id='songModeSelectorList') - yield Placeholder(id='songDatatable') + yield ListView( + ListItem(Label('All')), + ListItem(Label('Favourites')), + id='songModeSelectorList' + ) + yield DataTable(id='songDatatable') yield Footer() + def on_mount(self) -> None: + app.sub_title = "Songs" + + self.update_datatable() + # TODO @0x01fe : find better way to do space + + @on(ListView.Selected) + def mode_selected(self, e: ListView.Selected) -> None: + self.mode = e.index + self.update_datatable() + + def _get_offset(self) -> int: + return (self.page[self.mode]) * 30 + + def action_shuffle_mix(self) -> None: + if self.mode == 0: + q = conn.getRandomSongs(size=200) + app.play_song(q.pop(0)) + app.song_queue = q + elif self.mode == 1: + q = self.songs.copy() + random.shuffle(q) + app.play_song(q.pop(0)) + app.song_queue = q + + def action_page_forward(self) -> None: + self.page[self.mode] += 1 + self.update_datatable() + + def action_page_backward(self) -> None: + if self.page[self.mode] - 1 < 0: + return + + self.page[self.mode] -= 1 + self.update_datatable() + + def update_datatable(self) -> None: + datatable = self.query_one(DataTable) + + if self.mode == 0: + self.update_all(datatable) + elif self.mode == 1: + self.update_favorites(datatable) + + def update_favorites(self, dt: DataTable) -> None: + self.songs = conn.getStarredSongs() + + space = 30 + offset = self._get_offset() + + dt.clear() + + for song in self.songs[offset:offset + space]: + dt.add_row(song.title, song.album, song.artist, song.playCount, song.duration) + + def update_all(self, dt: DataTable) -> None: + space = 30 + + offset = self._get_offset() + self.songs = app.navidrome.getAllSongs(offset + space, offset) + + dt.cursor_type = "row" + dt.clear() + dt.add_columns("Title", "Album", "Artist", "Plays", "Duration") + + for song in self.songs: + dt.add_row(song.title, song.album, song.artist, song.playCount, song.duration) + class Favourites(Screen): BINDINGS = [ ('p', 'play()', 'Play Song'), @@ -741,7 +829,7 @@ class Favourites(Screen): yield Label(Text("F1 ", style="bold orange").append_text(Text("Player", style="white")), classes="headerb") yield Label(Text("F2 ", style="bold orange").append_text(Text("Artists", style="white")), classes="headerb") yield Label(Text("F3 ", style="bold orange").append_text(Text("Albums", style="white")), classes="headerb") - yield Label(Text("* F4 ", style="bold orange").append_text(Text("Favourites", style="white")), classes="headerb") + yield Label(Text("* F4 ", style="bold orange").append_text(Text("Songs", style="white")), classes="headerb") yield Label(Text("F5 ", style="bold orange").append_text(Text("Playlists", style="white")), classes="headerb") yield DataTable() yield Footer() @@ -970,7 +1058,7 @@ class Player(Screen): yield Label(Text("* F1 ", style="bold orange").append_text(Text("Player", style="white")), classes="headerb") yield Label(Text("F2 ", style="bold orange").append_text(Text("Artists", style="white")), classes="headerb") yield Label(Text("F3 ", style="bold orange").append_text(Text("Albums", style="white")), classes="headerb") - yield Label(Text("F4 ", style="bold orange").append_text(Text("Favourites", style="white")), classes="headerb") + yield Label(Text("F4 ", style="bold orange").append_text(Text("Songs", style="white")), classes="headerb") yield Label(Text("F5 ", style="bold orange").append_text(Text("Playlists", style="white")), classes="headerb") with Center(id='playerTrackTitleBox'): @@ -1064,7 +1152,7 @@ class SubPy(App): Binding('f1', "switch_mode('main')", 'Overview', show=False), Binding('f2', "switch_mode('artists')", "Artists", show=False), Binding('f3', "switch_mode('albums')", "Albums", show=False), - Binding('f4', "switch_mode('favourites')", "Favourites", show=False), + Binding('f4', "switch_mode('songs')", "Songs", show=False), Binding('f5', "switch_mode('playlists')", "Playlists", show=False) ] @@ -1072,7 +1160,7 @@ class SubPy(App): "main" : Player, "artists" : ArtistsGUI, "albums" : Albums, - "favourites" : SongsScreen, + "songs" : SongsScreen, "playlists" : Playlists } @@ -1084,6 +1172,8 @@ class SubPy(App): is_paused: bool = False place: int = 0 + navidrome: Navidrome.NavidromeAPI + music_command_queue: multiprocessing.Queue check_playing_timer: Timer | None = None @@ -1095,6 +1185,8 @@ class SubPy(App): self.check_music_commands = self.set_interval(2/3, self._music_player_loop, pause=False) + self.navidrome = Navidrome.NavidromeAPI(user, passw, MUSIC_SERVER_URL) + @work(exclusive=True) async def play_song(self, song: SubSonic.Song) -> None: self.currently_playing = song diff --git a/main.tcss b/main.tcss index 4cf6c21..c3f24c4 100644 --- a/main.tcss +++ b/main.tcss @@ -174,6 +174,15 @@ QueueScreen { width: 0.2fr; } +#songModeSelectorList Label { + padding: 1 2; +} + #songDatatable { + margin: 1 0 0 1; width: 1fr; } + +# SongsScreen { +# background: red; +# }