diff --git a/.gitignore b/.gitignore index 150bb7e..1230b0d 100644 --- a/.gitignore +++ b/.gitignore @@ -175,4 +175,5 @@ cython_debug/ .pypirc music -logs \ No newline at end of file +logs +data \ No newline at end of file diff --git a/app.py b/app.py index d25357f..a569769 100644 --- a/app.py +++ b/app.py @@ -1,7 +1,7 @@ import pyray as pr import math from ctypes import c_float -from gapless_player import GaplessPlayer, build_jellyfin_audio_url, server, client +from gapless_player import GaplessPlayer, song_data_to_Song, server, client # --- Configuration Constants --- INITIAL_SCREEN_WIDTH = 800 @@ -176,34 +176,53 @@ pr.set_target_fps(TARGET_FPS) player = GaplessPlayer() print("add queue") -player.add_to_queue(build_jellyfin_audio_url(server["address"], client.jellyfin.get_item("dab6efb24bb2372794d2b4fb53a12376")["Id"], server["AccessToken"], server["UserId"])) -player.add_to_queue(build_jellyfin_audio_url(server["address"], client.jellyfin.get_item("58822c0fc47ec63ba798ba4f04ea3cf3")["Id"], server["AccessToken"], server["UserId"])) -player.add_to_queue(build_jellyfin_audio_url(server["address"], client.jellyfin.get_item("6382005f9dbae8d187d80a5cdca3e7a6")["Id"], server["AccessToken"], server["UserId"])) -player.add_to_queue(build_jellyfin_audio_url(server["address"], client.jellyfin.get_item("a5d2453e07a4998ea20e957c44f90be6")["Id"], server["AccessToken"], server["UserId"])) -player.add_to_queue(build_jellyfin_audio_url(server["address"], client.jellyfin.get_item("398d481a7b85287ad200578b5ab997b0")["Id"], server["AccessToken"], server["UserId"])) -player.add_to_queue(build_jellyfin_audio_url(server["address"], client.jellyfin.get_item("f9f32ca67be7f83139cee3c66e1e4965")["Id"], server["AccessToken"], server["UserId"])) -player.add_to_queue(build_jellyfin_audio_url(server["address"], client.jellyfin.get_item("2f651e103b1fd22ea2f202d6f3398b36")["Id"], server["AccessToken"], server["UserId"])) -player.add_to_queue(build_jellyfin_audio_url(server["address"], client.jellyfin.get_item("164b95968ab1a725fff060fa8c351cc8")["Id"], server["AccessToken"], server["UserId"])) -player.add_to_queue(build_jellyfin_audio_url(server["address"], client.jellyfin.get_item("38a6c21561f54d284a6acad89a3ea8b0")["Id"], server["AccessToken"], server["UserId"])) -player.add_to_queue(build_jellyfin_audio_url(server["address"], client.jellyfin.get_item("631aeddb0557fef65f49463abb20ad7f")["Id"], server["AccessToken"], server["UserId"])) +# player.add_to_queue(build_jellyfin_audio_url(server["address"], , server["AccessToken"], server["UserId"])) +# player.add_to_queue(build_jellyfin_audio_url(server["address"], client.jellyfin.get_item("58822c0fc47ec63ba798ba4f04ea3cf3")["Id"], server["AccessToken"], server["UserId"])) +# player.add_to_queue(build_jellyfin_audio_url(server["address"], client.jellyfin.get_item("6382005f9dbae8d187d80a5cdca3e7a6")["Id"], server["AccessToken"], server["UserId"])) +# player.add_to_queue(build_jellyfin_audio_url(server["address"], client.jellyfin.get_item("a5d2453e07a4998ea20e957c44f90be6")["Id"], server["AccessToken"], server["UserId"])) +# player.add_to_queue(build_jellyfin_audio_url(server["address"], client.jellyfin.get_item("398d481a7b85287ad200578b5ab997b0")["Id"], server["AccessToken"], server["UserId"])) +# player.add_to_queue(build_jellyfin_audio_url(server["address"], client.jellyfin.get_item("f9f32ca67be7f83139cee3c66e1e4965")["Id"], server["AccessToken"], server["UserId"])) +# player.add_to_queue(build_jellyfin_audio_url(server["address"], client.jellyfin.get_item("2f651e103b1fd22ea2f202d6f3398b36")["Id"], server["AccessToken"], server["UserId"])) +# player.add_to_queue(build_jellyfin_audio_url(server["address"], client.jellyfin.get_item("164b95968ab1a725fff060fa8c351cc8")["Id"], server["AccessToken"], server["UserId"])) -player.add_to_queue(build_jellyfin_audio_url(server["address"], client.jellyfin.get_item("3d611c8664c5b2072edbf46da2a76c89")["Id"], server["AccessToken"], server["UserId"])) -player.add_to_queue(build_jellyfin_audio_url(server["address"], client.jellyfin.get_item("66559c40d5904944a3f97198d0297894")["Id"], server["AccessToken"], server["UserId"])) -player.add_to_queue(build_jellyfin_audio_url(server["address"], client.jellyfin.get_item("84b75eeb5c8e862d002bae05d2671b1b")["Id"], server["AccessToken"], server["UserId"])) -player.add_to_queue(build_jellyfin_audio_url(server["address"], client.jellyfin.get_item("7ef66992426093252696e1d8666a22e4")["Id"], server["AccessToken"], server["UserId"])) -player.add_to_queue(build_jellyfin_audio_url(server["address"], client.jellyfin.get_item("f37982227942d3df031381e653ec5790")["Id"], server["AccessToken"], server["UserId"])) -player.add_to_queue(build_jellyfin_audio_url(server["address"], client.jellyfin.get_item("0e8fc5fcf119de0439f5a15a4f255c5c")["Id"], server["AccessToken"], server["UserId"])) -player.add_to_queue('music/pink floyd/dark side of the moon/01 Speak to Me.flac')#(build_jellyfin_audio_url(server["address"], client.jellyfin.get_item("99067e877d91be1a66eb5a7ff2f4128f")["Id"], server["AccessToken"], server["UserId"])) -player.add_to_queue('music/pink floyd/dark side of the moon/02 Breathe (In the Air).flac')#(build_jellyfin_audio_url(server["address"], client.jellyfin.get_item("916eda422f48efd8705f29e0600a3e60")["Id"], server["AccessToken"], server["UserId"])) -player.add_to_queue('music/pink floyd/dark side of the moon/03 On the Run.flac')#(build_jellyfin_audio_url(server["address"], client.jellyfin.get_item("5e1067d59ed98979ad12a58548b27b83")["Id"], server["AccessToken"], server["UserId"])) -player.add_to_queue('music/pink floyd/dark side of the moon/04 Time.flac')#(build_jellyfin_audio_url(server["address"], client.jellyfin.get_item("8bcf8240d12aa5c3b14dc3b57f32fef7")["Id"], server["AccessToken"], server["UserId"])) -player.add_to_queue('music/pink floyd/dark side of the moon/05 The Great Gig in the Sky.flac') -player.add_to_queue('music/pink floyd/dark side of the moon/06 Money.flac') -player.add_to_queue('music/pink floyd/dark side of the moon/07 Us and Them.flac') -player.add_to_queue('music/pink floyd/dark side of the moon/08 Any Colour You Like.flac') -player.add_to_queue('music/pink floyd/dark side of the moon/09 Brain Damage.flac') -player.add_to_queue('music/pink floyd/dark side of the moon/10 Eclipse.flac') +# player.add_to_queue(build_jellyfin_audio_url(server["address"], client.jellyfin.get_item("38a6c21561f54d284a6acad89a3ea8b0")["Id"], server["AccessToken"], server["UserId"])) +# player.add_to_queue(build_jellyfin_audio_url(server["address"], client.jellyfin.get_item("631aeddb0557fef65f49463abb20ad7f")["Id"], server["AccessToken"], server["UserId"])) + +# player.add_to_queue(build_jellyfin_audio_url(server["address"], client.jellyfin.get_item("3d611c8664c5b2072edbf46da2a76c89")["Id"], server["AccessToken"], server["UserId"])) +# player.add_to_queue(build_jellyfin_audio_url(server["address"], client.jellyfin.get_item("66559c40d5904944a3f97198d0297894")["Id"], server["AccessToken"], server["UserId"])) +# player.add_to_queue(build_jellyfin_audio_url(server["address"], client.jellyfin.get_item("84b75eeb5c8e862d002bae05d2671b1b")["Id"], server["AccessToken"], server["UserId"])) +# player.add_to_queue(build_jellyfin_audio_url(server["address"], client.jellyfin.get_item("7ef66992426093252696e1d8666a22e4")["Id"], server["AccessToken"], server["UserId"])) +# player.add_to_queue(build_jellyfin_audio_url(server["address"], client.jellyfin.get_item("f37982227942d3df031381e653ec5790")["Id"], server["AccessToken"], server["UserId"])) +# player.add_to_queue(build_jellyfin_audio_url(server["address"], client.jellyfin.get_item("0e8fc5fcf119de0439f5a15a4f255c5c")["Id"], server["AccessToken"], server["UserId"])) + +player.add_to_queue(song_data_to_Song(client.jellyfin.get_item("dab6efb24bb2372794d2b4fb53a12376"), server)) +player.add_to_queue(song_data_to_Song(client.jellyfin.get_item("58822c0fc47ec63ba798ba4f04ea3cf3"), server)) +player.add_to_queue(song_data_to_Song(client.jellyfin.get_item("6382005f9dbae8d187d80a5cdca3e7a6"), server)) +player.add_to_queue(song_data_to_Song(client.jellyfin.get_item("a5d2453e07a4998ea20e957c44f90be6"), server)) +player.add_to_queue(song_data_to_Song(client.jellyfin.get_item("398d481a7b85287ad200578b5ab997b0"), server)) +player.add_to_queue(song_data_to_Song(client.jellyfin.get_item("f9f32ca67be7f83139cee3c66e1e4965"), server)) +player.add_to_queue(song_data_to_Song(client.jellyfin.get_item("2f651e103b1fd22ea2f202d6f3398b36"), server)) +player.add_to_queue(song_data_to_Song(client.jellyfin.get_item("164b95968ab1a725fff060fa8c351cc8"), server)) +player.add_to_queue(song_data_to_Song(client.jellyfin.get_item("38a6c21561f54d284a6acad89a3ea8b0"), server)) +player.add_to_queue(song_data_to_Song(client.jellyfin.get_item("631aeddb0557fef65f49463abb20ad7f"), server)) +player.add_to_queue(song_data_to_Song(client.jellyfin.get_item("3d611c8664c5b2072edbf46da2a76c89"), server)) +player.add_to_queue(song_data_to_Song(client.jellyfin.get_item("66559c40d5904944a3f97198d0297894"), server)) +player.add_to_queue(song_data_to_Song(client.jellyfin.get_item("84b75eeb5c8e862d002bae05d2671b1b"), server)) +player.add_to_queue(song_data_to_Song(client.jellyfin.get_item("7ef66992426093252696e1d8666a22e4"), server)) +player.add_to_queue(song_data_to_Song(client.jellyfin.get_item("f37982227942d3df031381e653ec5790"), server)) +player.add_to_queue(song_data_to_Song(client.jellyfin.get_item("0e8fc5fcf119de0439f5a15a4f255c5c"), server)) + +# player.add_to_queue('music/pink floyd/dark side of the moon/01 Speak to Me.flac')#(build_jellyfin_audio_url(server["address"], client.jellyfin.get_item("99067e877d91be1a66eb5a7ff2f4128f")["Id"], server["AccessToken"], server["UserId"])) +# player.add_to_queue('music/pink floyd/dark side of the moon/02 Breathe (In the Air).flac')#(build_jellyfin_audio_url(server["address"], client.jellyfin.get_item("916eda422f48efd8705f29e0600a3e60")["Id"], server["AccessToken"], server["UserId"])) +# player.add_to_queue('music/pink floyd/dark side of the moon/03 On the Run.flac')#(build_jellyfin_audio_url(server["address"], client.jellyfin.get_item("5e1067d59ed98979ad12a58548b27b83")["Id"], server["AccessToken"], server["UserId"])) +# player.add_to_queue('music/pink floyd/dark side of the moon/04 Time.flac')#(build_jellyfin_audio_url(server["address"], client.jellyfin.get_item("8bcf8240d12aa5c3b14dc3b57f32fef7")["Id"], server["AccessToken"], server["UserId"])) +# player.add_to_queue('music/pink floyd/dark side of the moon/05 The Great Gig in the Sky.flac') +# player.add_to_queue('music/pink floyd/dark side of the moon/06 Money.flac') +# player.add_to_queue('music/pink floyd/dark side of the moon/07 Us and Them.flac') +# player.add_to_queue('music/pink floyd/dark side of the moon/08 Any Colour You Like.flac') +# player.add_to_queue('music/pink floyd/dark side of the moon/09 Brain Damage.flac') +# player.add_to_queue('music/pink floyd/dark side of the moon/10 Eclipse.flac') print("add queue done") # Initial setup @@ -214,6 +233,23 @@ state["camera"] = setup_3d_environment(int(render_rect.width), int(render_rect.h # LOAD THE ASSETS state["album_texture"], state["album_model"] = load_album_assets() +current_path = None +texture = None + +def load_texture(path): + global texture, current_path + + if not path: + return + + if path == current_path: + return + + if texture is not None: + pr.unload_texture(texture) + + texture = pr.load_texture(path) + current_path = path # --- Main Game Loop --- while not pr.window_should_close(): # 1. Update @@ -226,8 +262,6 @@ while not pr.window_should_close(): render_rect = get_3d_render_area(current_width, current_height) pr.unload_render_texture(state["render_texture"]) state["render_texture"] = pr.load_render_texture(int(render_rect.width), int(render_rect.height)) - - delta_time = pr.get_frame_time() if pr.is_key_pressed(pr.KeyboardKey.KEY_SPACE): if player.playing: @@ -270,15 +304,53 @@ while not pr.window_should_close(): pr.draw_texture_pro(state["render_texture"].texture, source_rect, render_rect, pr.Vector2(0, 0), 0.0, pr.WHITE) - pr.draw_rectangle_lines_ex(render_rect, 3, pr.LIME) + pr.draw_rectangle_lines_ex(render_rect, 3, pr.WHITE) - draw_progress_bar(progress_rect, player.position, player.playback_info_to_duration(player.playback_info)) - - pr.draw_text(f"Status: {'Playing' if player.playing else 'Paused'} (SPACE)", - int(current_width * 0.05), int(current_height * 0.9), int(current_height * 0.03), pr.LIME) + current_song = player.get_current_song() + load_texture(current_song and current_song.album_cover_path) + + max_size = int(current_height * 0.075) + pr.draw_text((current_song and f"{current_song.name} - {current_song.artist_name}") or "", + int(current_width * 0.05+max_size*1.1), int(current_height * 0.8), int(current_height * 0.03), pr.WHITE) + + draw_progress_bar(progress_rect, player.position, player.song_to_duration(player.get_current_song())) + + + pr.draw_text(f"Status: {'Playing' if player.playing else 'Paused'} (SPACE)", + int(current_width * 0.05), int(current_height * 0.9), int(current_height * 0.03), pr.WHITE) + + + if texture is not None: + + scale = min( + max_size / texture.width, + max_size / texture.height + ) + + dest_rect = pr.Rectangle( + int(current_width * 0.05), + int(current_height * 0.8), + texture.width * scale, + texture.height * scale + ) + + src_rect = pr.Rectangle(0, 0, texture.width, texture.height) + + pr.draw_texture_pro( + texture, + src_rect, + dest_rect, + pr.Vector2(0, 0), + 0.0, + pr.WHITE + ) pr.end_drawing() +# Cleanup +if texture is not None: + pr.unload_texture(texture) + # --- De-initialization --- pr.unload_texture(state["album_texture"]) # Unload the texture pr.unload_model(state["album_model"]) # Unload the model/mesh diff --git a/gapless_player.py b/gapless_player.py index 01d00e0..0b1c371 100644 --- a/gapless_player.py +++ b/gapless_player.py @@ -18,16 +18,20 @@ import sys import io import fcntl from dataclasses import dataclass +import requests +from pathlib import Path +import mimetypes os.makedirs("logs", exist_ok=True) +os.makedirs("data", exist_ok=True) +os.makedirs("data/images", exist_ok=True) @dataclass class Song: url: str - duration: float name: str album_name:str - album_cover:str + album_cover_path:str artist_name:str @@ -36,12 +40,6 @@ class GaplessPlayer: self.samplerate = samplerate self.channels = channels - self.proc = None - self.next_proc = None - - self.current_song: Song = None - self.next_file: Song = None - self.next_preload_state = 0 self.closed=False @@ -62,15 +60,18 @@ class GaplessPlayer: callback=self._callback ) self.stream.start() + + def get_current_song(self): + if self.current_song_in_list >= 0 and self.current_song_in_list= 0 and self.current_song_in_list +1 < len(self.song_list): + return self.song_list[self.current_song_in_list +1] + return None + def load_song(self, song:Song): + if song: + song.playback_info = self.get_stream_info(song.url) + song.ffmpeg = self._open_ffmpeg(song) + song.preload_state = 2 + + def preload_next_threaded(self): + next_song = self.get_next_song() + if not next_song or next_song.preload_state: return + next_song.preload_state = 1 + threading.Thread(target=self.load_song, args=(next_song,)).start() def get_stream_info(self, url): """Return duration in seconds for the track""" try: @@ -115,64 +164,21 @@ class GaplessPlayer: stderr=subprocess.PIPE, text=True ) - return json.loads(result.stdout) except Exception as e: print("ffprobe error:", e) return None - def add_to_queue(self, song:Song): - self.current_song_in_list.append(song) - - def play(self): - with self.lock: - if not self.playing: - if not self.proc and self.current_file: - self.proc = self._open_ffmpeg(self.current_file, self.position) - self.playing = True - - def pause(self): - with self.lock: - if self.proc: - self.proc.kill() - self.proc = None - self.playing = False - - def _start_next(self): - # Kill old pipeline - if self.proc: - self.proc.kill() - - # Move next pipeline into active - self.position = 0.0 - self.proc = self.next_proc - self.current_file = self.next_file - self.playback_info = self.next_playback_info - self.next_proc=None - self.next_playback_info = None - self.next_preload_state = 0 - - def preload_next(self): - self.next_file = self.song_queue.get() - self.next_playback_info = self.get_stream_info(self.next_file) - self.next_proc = self._open_ffmpeg(self.next_file) - self.next_preload_state = 2 - - def preload_next_threaded(self): - if self.next_preload_state: return - self.next_preload_state = 1 - threading.Thread(target=self.preload_next).start() - - def playback_info_to_duration(self, info): - if info is None: return 0.0 - if "streams" in info: - for s in info["streams"]: + def song_to_duration(self, song): + if not song or not song.playback_info: return 0.0 + if "streams" in song.playback_info: + for s in song.playback_info["streams"]: if "duration" in s: return float(s["duration"]) - if "format" in info and "duration" in info["format"]: - return float(info["format"]["duration"]) + if "format" in song.playback_info and "duration" in song.playback_info["format"]: + return float(song.playback_info["format"]["duration"]) return 0.0 @@ -181,67 +187,78 @@ class GaplessPlayer: needed = frames * self.channels * 2 data = b'' if self.playing: - if self.proc is None: - if self.next_preload_state==2: - self._start_next() - elif self.next_preload_state == 0: - self.preload_next_threaded() - else: + current_song = self.get_current_song() + if not current_song or current_song.ffmpeg is None: + next_song = self.get_next_song() + if next_song: + if next_song.preload_state==2: + self._start_next() + elif next_song.preload_state == 0: + self.preload_next_threaded() + elif current_song: try: - data = self.proc.stdout.read(needed) or b'' + data = current_song.ffmpeg.stdout.read(needed) or b'' except BlockingIOError: pass self.position += len(data) / (self.samplerate * self.channels * 2) - if self.position >= self.playback_info_to_duration(self.playback_info)-10: + if self.position >= self.song_to_duration(current_song)-10: self.preload_next_threaded() - if self.proc.poll() is not None and len(data)= self.playback_info_to_duration(self.playback_info)-0.1: + if current_song.ffmpeg.poll() is not None and len(data)= self.song_to_duration(current_song)-0.1: self._start_next() - if self.proc is not None and self.proc.poll() is None: + current_song = self.get_current_song() + if current_song and current_song.ffmpeg is not None and current_song.ffmpeg.poll() is None: try: - new_data = self.proc.stdout.read(needed-len(data)) or b'' + new_data = current_song.ffmpeg.stdout.read(needed-len(data)) or b'' except BlockingIOError: new_data = b'' self.position += len(new_data) / (self.samplerate * self.channels * 2) data += new_data else: - self.proc = self._open_ffmpeg(self.current_file, self.position) + current_song.ffmpeg = self._open_ffmpeg(current_song, self.position) outdata[:len(data)] = data outdata[len(data):] = b'\x00'*(needed-len(data)) -def build_jellyfin_audio_url( - base_url: str, - item_id: str, - api_key: str, - user_id: str, - container: str = "flac", - audio_codec: str = "flac", - bitrate: int | None = None, - media_source_id: str | None = None, -) -> str: - """ - Build a Jellyfin audio stream URL using urllib.parse. - """ +def song_data_to_Song( + data, + client_data +) -> Song: + # """ + # Build a Jellyfin audio stream URL using urllib.parse. + # """ + item_id = data["Id"] path = f"/Audio/{item_id}/universal" - params = { - "UserId": user_id, - "Container": container, - "AudioCodec": audio_codec, # <-- IMPORTANT - "api_key": api_key, + "UserId": client_data["UserId"], + "Container": "flac", + "AudioCodec": "flac", # <-- IMPORTANT + "api_key": client_data["AccessToken"], } - if bitrate is not None: - params["Bitrate"] = bitrate - - if media_source_id is not None: - params["MediaSourceId"] = media_source_id - query = urlencode(params) - return urljoin(base_url, path) + "?" + query + url = urljoin(client_data["address"], path) + "?" + query + + album_cover_url = urljoin(client_data["address"], f"/Items/{data["AlbumId"]}/Images/Primary") + + r = requests.get(album_cover_url) + r.raise_for_status() + + content_type = r.headers.get("Content-Type") # e.g. "image/jpeg" + + ext = mimetypes.guess_extension(content_type) # ".jpg" + + if ext is None: + ext = ".jpg" # safe fallback for album art + + saved_path = Path("data","images", data["AlbumId"] + ext).as_posix() + + with open(saved_path, "wb") as f: + f.write(r.content) + + return Song(url,data["Name"],data["Album"], saved_path, data["AlbumArtist"]) client = JellyfinClient()