diff --git a/app.py b/app.py index 6139a97..cfbfeb0 100644 --- a/app.py +++ b/app.py @@ -1,27 +1,23 @@ import pyray as pr import math from ctypes import c_float -from gapless_player import GaplessPlayer, song_data_to_Song, server, client, Song +from gapless_player import GaplessPlayer, Song, song_data_to_Song from scrolling_text import ScrollingText +import numpy as np +import threading +import time +import os +from jelly import server, client # --- Configuration Constants --- INITIAL_SCREEN_WIDTH = 240 INITIAL_SCREEN_HEIGHT = 240 -TARGET_FPS = 60 +TARGET_FPS =60 # --- State Variables --- state = { "screen_width": INITIAL_SCREEN_WIDTH, "screen_height": INITIAL_SCREEN_HEIGHT, - "current_time": 120.0, - "total_time": 300.0, - "is_playing": True, - # 3D Camera State - "camera": None, - "render_texture": None, - # Assets - "album_texture": None, - "album_model": None, } # --- Utility Functions --- @@ -37,7 +33,7 @@ def format_time_mm_ss(seconds): def get_progress_bar_rect(screen_width, screen_height): width = screen_width - height = 5 + height = screen_height*0.021 x = (screen_width - width) / 2 y = screen_height - height return pr.Rectangle(x, y, width, height) @@ -53,7 +49,7 @@ def draw_progress_bar(rect, current_time, total_time): progress_width = rect.width * progress_ratio pr.draw_rectangle( int(rect.x), - int(rect.y), + int(rect.y)+1, int(progress_width), int(rect.height), pr.Color(200, 50, 50, 255), @@ -70,10 +66,9 @@ def draw_progress_bar(rect, current_time, total_time): # pr.WHITE, # ) - -# Initialization pr.set_config_flags(pr.ConfigFlags.FLAG_WINDOW_RESIZABLE) -# pr.set_config_flags(pr.FLAG_MSAA_4X_HINT) +pr.set_config_flags(pr.FLAG_MSAA_4X_HINT) +#pr.set_config_flags(pr.FLAG_FULLSCREEN_MODE) pr.init_window(state["screen_width"], state["screen_height"], "UgPod") pr.set_target_fps(TARGET_FPS) @@ -81,149 +76,54 @@ player = GaplessPlayer() print("add queue") -# 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("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"])) - -print(client.jellyfin.get_item("dab6efb24bb2372794d2b4fb53a12376")) # player.add_to_queue( # Song( -# "music/pink floyd/dark side of the moon/01 Speak to Me.flac", -# "Speak to Me", -# "The Dark Side Of The Moon", -# "music/albumcover.png", -# "Pink Floyd", +# "/mnt/HDD/Downloads/72 Pantera.wav", +# "Bullseye", +# 1, +# "KDrew", +# "", +# "KDrew", # ) # ) -player.add_to_queue( - song_data_to_Song( - client.jellyfin.get_item("99067e877d91be1a66eb5a7ff2f4128f"), server - ) -) -player.add_to_queue( - song_data_to_Song( - client.jellyfin.get_item("916eda422f48efd8705f29e0600a3e60"), server - ) -) -player.add_to_queue( - song_data_to_Song( - client.jellyfin.get_item("5e1067d59ed98979ad12a58548b27b83"), server - ) -) -player.add_to_queue( - song_data_to_Song( - client.jellyfin.get_item("8bcf8240d12aa5c3b14dc3b57f32fef7"), server - ) + +albums = client.jellyfin.user_items( + params={ + "IncludeItemTypes": "MusicAlbum", + "SearchTerm": "Dawn FM", # album name + "Recursive": True, + }, ) +album = albums["Items"][0] # pick the album you want +album_id = album["Id"] -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 - ) + +tracks = client.jellyfin.user_items( + params={ + "ParentId": album_id, + "IncludeItemTypes": "Audio", + "SortBy": "IndexNumber", + "SortOrder": "Ascending", + }, ) -# 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') +for track in tracks["Items"]: + player.add_to_queue( + song_data_to_Song( + track, server + ) + ) + print("add queue done") +player.load_state("data/player.json") +close_event = threading.Event() +def save_state_loop(): + while not close_event.wait(10): + player.save_state("data/player.lock.json") + os.rename("data/player.lock.json", "data/player.json") +# save_state_thread = threading.Thread(target=save_state_loop) +# save_state_thread.start() current_path = None texture = None @@ -255,7 +155,6 @@ def draw_play_pause_button(pos: pr.Vector2, size: pr.Vector2, is_playing: bool) pr.draw_rectangle_rec(rect, pr.fade(pr.BLACK, 0.4)) if pr.is_mouse_button_pressed(pr.MOUSE_LEFT_BUTTON): clicked = True - cx = pos.x + size.x / 2 cy = pos.y + size.y / 2 @@ -269,7 +168,7 @@ def draw_play_pause_button(pos: pr.Vector2, size: pr.Vector2, is_playing: bool) left_x = cx - bar_width - bar_width * 0.4 right_x = cx + bar_width * 0.4 - top_y = cy - bar_height / 2 + top_y = 1+cy - bar_height / 2 pr.draw_rectangle( int(left_x), @@ -297,10 +196,8 @@ def draw_play_pause_button(pos: pr.Vector2, size: pr.Vector2, is_playing: bool) title = ScrollingText( - text="Aphex Twin - Xtal", - font_size=15, - speed=35, - pause_time=1.2, + "", + 15 ) # --- Main Game Loop --- @@ -309,6 +206,8 @@ while not pr.window_should_close(): current_width = pr.get_screen_width() current_height = pr.get_screen_height() + if pr.is_key_pressed(pr.KEY_F11): + pr.toggle_fullscreen() if pr.is_key_pressed(pr.KeyboardKey.KEY_SPACE): if player.playing: player.pause() @@ -325,41 +224,44 @@ while not pr.window_should_close(): progress_rect = get_progress_bar_rect(current_width, current_height) - pr.draw_text( - "UgPod", - int(current_width * 0.05), - int(current_height * 0.05), - int(current_height * 0.05), - pr.SKYBLUE, - ) + # pr.draw_text( + # "UgPod", + # int(current_width * 0.05), + # int(current_height * 0.05), + # int(current_height * 0.05), + # pr.SKYBLUE, + # ) current_song = player.get_current_song() - max_size = int(current_height * 0.075) + draw_progress_bar( + progress_rect, + player.position, + (current_song and current_song.duration) or 0.0, + ) if current_song: load_texture(current_song.album_cover_path) - title_size = pr.Vector2(current_width-int(current_width * 0.05 + max_size * 1.1), 30) + title_font_size = int(current_height*0.05) + album_cover_size = int(min(current_width, current_height*0.7)) + title.speed = title_font_size*2.5 + title_size = pr.Vector2(current_width-int(current_height * 0.01)*2, title_font_size) title.update(dt,title_size) - title.set_text(f"{current_song.name} - {current_song.artist_name}") - title.draw(pr.Vector2(int(current_width * 0.05 + max_size * 1.1),int(current_height * 0.8)),title_size) + title.set_text(f"{current_song.name} - {current_song.artist_name}", title_font_size) + title.draw(pr.Vector2(int(current_height * 0.01),int(current_height * 0.8)),title_size) # pr.draw_text( # , # , # int(current_height * 0.03), # pr.WHITE, # ) - draw_progress_bar( - progress_rect, - player.position, - current_song.duration, - ) + points = player.oscilloscope_data_points if texture is not None: - scale = min(max_size / texture.width, max_size / texture.height) + scale = min(album_cover_size / texture.width, album_cover_size / texture.height) dest_rect = pr.Rectangle( - int(current_width * 0.05), - int(current_height * 0.8), + current_width//2 - album_cover_size//2, + (current_height*0.8)//2 - album_cover_size//2, texture.width * scale, texture.height * scale, ) @@ -369,6 +271,146 @@ while not pr.window_should_close(): pr.draw_texture_pro( texture, src_rect, dest_rect, pr.Vector2(0, 0), 0.0, pr.WHITE ) + else: + clip = pr.Rectangle(int(current_width//2 - album_cover_size//2), + int((current_height*0.8)//2 - album_cover_size//2), + int(album_cover_size), + int(album_cover_size)) + pr.begin_scissor_mode( + int(clip.x), + int(clip.y), + int(clip.width), + int(clip.height), + ) + pr.draw_rectangle( + int(clip.x), + int(clip.y), + int(clip.width), + int(clip.height), pr.BLACK) + + # cx = current_width * 0.5+1 + # cy = current_height * 0.4+1 + + # MAX_LEN = album_cover_size * 0.25 # tune this + # MIN_ALPHA = 10 + # MAX_ALPHA = 255 + + # for i in range(len(points) - 1): + # x1 = cx + points[i][0] * album_cover_size * 0.5 + # y1 = cy + -points[i][1] * album_cover_size * 0.5 + # x2 = cx + points[i+1][0] * album_cover_size * 0.5 + # y2 = cy + -points[i+1][1] * album_cover_size * 0.5 + + # dx = x2 - x1 + # dy = y2 - y1 + # length = (dx * dx + dy * dy) ** 0.5 + + # # 1.0 = short line, 0.0 = long line + # t = max(0.0, min(1.0, 1.0 - (length / MAX_LEN)))*math.pow(i/len(points), 2) + + # alpha = int(MIN_ALPHA + t * (MAX_ALPHA - MIN_ALPHA)) + + # color = pr.Color(255, 255, 255, alpha) + + # pr.draw_line(int(x1), int(y1), int(x2), int(y2), color) + # draw background square + if len(points) >= 2: + samples = np.fromiter( + ((p[0] + p[1]) * 0.5 for p in points), + dtype=np.float32 + ) + + # Guard: FFT must have meaningful size + if samples.size > 128: + + + rect_x = int(current_width // 2 - album_cover_size // 2) + rect_y = int((current_height * 0.8) // 2 - album_cover_size // 2) + + # ---- FFT ---- + + FFT_SIZE = min(samples.size, 2048) + window = np.hanning(FFT_SIZE) + + fft = np.fft.rfft(samples[:FFT_SIZE] * window) + magnitudes = np.abs(fft) + + # remove DC component (important for visuals) + magnitudes[0] = 0.0 + + # ---- LOG BINNING ---- + + num_bars = album_cover_size//10 + num_bins = magnitudes.size + + # logarithmic bin edges (low end stretched) + log_min = 1 + log_max = math.log10(num_bins) + + log_edges = np.logspace( + math.log10(log_min), + log_max, + num_bars + 1 + ).astype(int) + + bar_values = np.zeros(num_bars, dtype=np.float32) + + for i in range(num_bars): + start = log_edges[i] + end = log_edges[i + 1] + + if end <= start: + continue + + bar_values[i] = np.mean(magnitudes[start:end]) + +# ---- STATIC SCALING ---- + + # Instead of normalizing to the max of the frame, we scale by the FFT size. + # For a Hanning windowed FFT, dividing by (FFT_SIZE / 4) maps + # maximum possible volume roughly to 1.0. + bar_values = bar_values / (FFT_SIZE / 4.0) + + # ---- DRAW ---- + + def map_to_screen(val): + return rect_x + (math.log10(max(1, val)) / log_max) * album_cover_size + + spacing = 0 + + for i in range(num_bars): + # 1. Calculate integer pixel boundaries first + # This ensures the right edge of one bar is exactly the left edge of the next + x_start_int = int(map_to_screen(log_edges[i])) + x_end_int = int(map_to_screen(log_edges[i+1])) + + # 2. Width is the difference between these fixed integer points + w = (x_end_int - x_start_int) - spacing + + value = bar_values[i] + h = int(min(1.0, value) * album_cover_size) + + # 3. Anchor to bottom + y = (rect_y + album_cover_size) - h + + alpha = min(1.0, ((value+1)**2)-1) + r = 255 + g = 0 + b = 0 + + # Keep alpha at 255 (fully opaque) + color = pr.Color(r, g, b, int(255 * alpha)) + + # 4. Draw the bar + # Use max(1, w) to ensure high-frequency bars don't disappear on small screens + pr.draw_rectangle( + x_start_int, + int(y), + max(1, int(w)), + h, + color + ) + pr.end_scissor_mode() pos = pr.Vector2(current_width * 0.5 - current_height * 0.05, current_height * 0.9-progress_rect.height) size = pr.Vector2(current_height * 0.1, current_height * 0.1) @@ -385,3 +427,5 @@ if texture is not None: pr.unload_texture(texture) pr.close_window() +close_event.set() +# save_state_thread.join() \ No newline at end of file diff --git a/gapless_player.py b/gapless_player.py index 3143d40..065badb 100644 --- a/gapless_player.py +++ b/gapless_player.py @@ -1,14 +1,11 @@ -from jellyfin_apiclient_python import JellyfinClient import json import uuid import subprocess -from dotenv import load_dotenv import os import time import ffmpeg import requests import threading -from urllib.parse import urlencode, urljoin import subprocess import numpy as np import sounddevice as sd @@ -18,17 +15,67 @@ import sys import io import fcntl from dataclasses import dataclass +import numpy as np +from collections import deque +from jelly import server, client +from urllib.parse import urlencode, urljoin import requests from pathlib import Path import mimetypes -os.makedirs("logs", exist_ok=True) +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": client_data["UserId"], + "Container": "flac", + "AudioCodec": "flac", # <-- IMPORTANT + "api_key": client_data["AccessToken"], + } + + query = urlencode(params) + 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( + item_id, + url, + data["Name"], + data["RunTimeTicks"] / 10_000_000, + data["Album"], + saved_path, + data["AlbumArtist"], + ) + +#os.makedirs("logs", exist_ok=True) os.makedirs("data", exist_ok=True) os.makedirs("data/images", exist_ok=True) @dataclass class Song: + id: str url: str name: str duration: float @@ -38,18 +85,16 @@ class Song: class GaplessPlayer: - def __init__(self, samplerate: int = 44100, channels: int = 2): + def __init__(self, samplerate: int = 96000, channels: int = 2): self.samplerate = samplerate self.channels = channels - self.next_preload_state = 0 - self.closed = False self.playing = False self.position = 0.0 - self.song_list = [] + self.song_list: list[Song] = [] self.current_song_in_list = -1 @@ -62,6 +107,7 @@ class GaplessPlayer: callback=self._callback, ) self.stream.start() + self.oscilloscope_data_points = deque(maxlen=samplerate//60) def get_current_song(self): if self.current_song_in_list >= 0 and self.current_song_in_list < len( @@ -92,6 +138,8 @@ class GaplessPlayer: stderr=subprocess.PIPE, ) + print("yo") + # --- make stdout non-blocking --- fd = proc.stdout.fileno() flags = fcntl.fcntl(fd, fcntl.F_GETFL) @@ -169,7 +217,7 @@ class GaplessPlayer: if song: 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: @@ -200,6 +248,13 @@ class GaplessPlayer: self.position += len(data) / (self.samplerate * self.channels * 2) if self.position >= current_song.duration - 10: self.preload_next_threaded() + else: + next_song = self.get_next_song() + if next_song and next_song.ffmpeg: + if next_song.ffmpeg.poll() is None: + next_song.ffmpeg.kill() + next_song.ffmpeg = None + next_song.preload_state = 0 if current_song.ffmpeg.poll() is not None and len(data) < needed: if round(self.position, 2) >= current_song.duration - 0.1: self._start_next() @@ -223,76 +278,52 @@ class GaplessPlayer: ) data += new_data else: + # if current_song.ffmpeg and current_song.ffmpeg.poll() is not None: + # current_song.ffmpeg.kill() + # current_song.ffmpeg = None current_song.ffmpeg = self._open_ffmpeg( current_song, self.position ) + samples = np.frombuffer(data, dtype=np.int16) + left = samples[0::2] + right = samples[1::2] + norm = 32769.0 + x = left / norm + y = right / norm + + points = list(zip(x, y)) + # step = max(1, len(points) // 1000) + # points = points[::step] + self.oscilloscope_data_points.extend(points) outdata[: len(data)] = data outdata[len(data) :] = b"\x00" * (needed - len(data)) + def save_state(self, path): + with open(path,"w") as f: + data = { + "queue": [song.id for song in self.song_list], + "current_song": self.current_song_in_list, + "position": self.position + } + json.dump(data, f) + def load_state(self, path): + try: + with open(path,"r") as f: + data = json.load(f) + self.song_list = [] + for song in data["queue"]: + songOBJ = song_data_to_Song(client.jellyfin.get_item(song), server) + songOBJ.ffmpeg = None + songOBJ.preload_state = 0 + self.song_list.append(songOBJ) + + self.current_song_in_list = data['current_song'] + self.seek(data['position']) + except: + return -album_covers = [] - - -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": client_data["UserId"], - "Container": "flac", - "AudioCodec": "flac", # <-- IMPORTANT - "api_key": client_data["AccessToken"], - } - - query = urlencode(params) - 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["RunTimeTicks"] / 10_000_000, - data["Album"], - saved_path, - data["AlbumArtist"], - ) - - -client = JellyfinClient() -load_dotenv() - -client.config.app("UgPod", "0.0.1", "UgPod prototype", "UgPod_prototype_1") -client.config.data["auth.ssl"] = True - -client.auth.connect_to_address(os.getenv("host")) -client.auth.login(os.getenv("URL"), os.getenv("username"), os.getenv("password")) - -credentials = client.auth.credentials.get_credentials() -server = credentials["Servers"][0] -print(json.dumps(server)) - # while True: # duration = player.playback_info_to_duration(player.playback_info) diff --git a/jelly.py b/jelly.py new file mode 100644 index 0000000..7ef87c1 --- /dev/null +++ b/jelly.py @@ -0,0 +1,41 @@ +from jellyfin_apiclient_python import JellyfinClient +import os +from dotenv import load_dotenv +import json + +load_dotenv() + + +album_covers = {} + +client = JellyfinClient() +client.config.app("UgPod", "0.0.1", "UgPod prototype", "UgPod_prototype_1") +client.config.data["auth.ssl"] = True + +try: + with open("data/auth.json", "r") as f: + credentials = json.load(f) + + client.authenticate(credentials, discover=False) + + # 🔴 THIS IS THE MISSING STEP + server = credentials["Servers"][0] + client.config.data["auth.server"] = server["Id"] + client.config.data["auth.servers"] = credentials["Servers"] + + client.start() + + server = credentials["Servers"][0] + + assert server["Address"].startswith("http") + print("Server address:", server["Address"]) + print("Server ID:", server["Id"]) +except: + print("authenticating") + client.auth.connect_to_address(os.getenv("host")) + client.auth.login(os.getenv("URL"), os.getenv("username"), os.getenv("password")) + + credentials = client.auth.credentials.get_credentials() +# with open("data/auth.json", 'w') as f: +# json.dump(credentials, f) +server = credentials["Servers"][0] \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 371124d..3394a1a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,6 +11,7 @@ ffmpeg-python==0.2.0 frozenlist==1.8.0 future==1.0.0 idna==3.11 +inflection==0.5.1 jellyfin-apiclient-python==1.11.0 multidict==6.7.0 numpy==2.3.5 @@ -18,8 +19,7 @@ propcache==0.4.1 pycparser==2.23 pyee==13.0.0 python-dotenv==1.2.1 -python-vlc==3.0.21203 -raylib==5.5.0.3 +raylib_drm==5.5.0.4 requests==2.32.5 sounddevice==0.5.3 typing_extensions==4.15.0 diff --git a/run-fb.sh b/run-fb.sh new file mode 100755 index 0000000..68679ef --- /dev/null +++ b/run-fb.sh @@ -0,0 +1,2 @@ +export LIBGL_ALWAYS_SOFTWARE=1 +python3 app.py \ No newline at end of file diff --git a/scrolling_text.py b/scrolling_text.py index 81e94e1..5ce6d0f 100644 --- a/scrolling_text.py +++ b/scrolling_text.py @@ -15,13 +15,13 @@ class ScrollingText: self.pause_time = pause_time self.color = color self.text = None - self.set_text(text) - + self.set_text(text, font_size) - def set_text(self, text: str): - if text == self.text: + def set_text(self, text: str, font_size: int): + if text == self.text and font_size == self.font_size: return self.text = text + self.font_size = font_size self.text_width = pr.measure_text(self.text, self.font_size) self.reset() @@ -32,7 +32,7 @@ class ScrollingText: def update(self, dt: float, size: pr.Vector2): if self.text_width <= size.x: - return + return self.reset() self.timer += dt @@ -43,7 +43,7 @@ class ScrollingText: else: self.offset += self.speed * dt - if self.offset >= self.text_width + 20: + if self.offset >= self.text_width + self.font_size*2.5: self.reset() def draw(self, pos: pr.Vector2, size: pr.Vector2): @@ -69,7 +69,7 @@ class ScrollingText: if self.text_width > size.x: pr.draw_text( self.text, - int(pos.x - self.offset + self.text_width + 20), + int(pos.x - self.offset + self.text_width + self.font_size*2.5), int(y), self.font_size, self.color,