make the GUI in raylib

This commit is contained in:
William Bell
2025-12-10 20:36:05 +00:00
parent 2fe284a806
commit 4b07780adf
7 changed files with 552 additions and 201 deletions

2
.gitignore vendored
View File

@@ -174,3 +174,5 @@ cython_debug/
# PyPI configuration file # PyPI configuration file
.pypirc .pypirc
music
logs

View File

@@ -208,7 +208,7 @@ If you develop a new program, and you want it to be of the greatest possible use
To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the “copyright” line and a pointer to where the full notice is found. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the “copyright” line and a pointer to where the full notice is found.
FinPod UgPod
Copyright (C) 2025 Ugric Copyright (C) 2025 Ugric
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
@@ -221,7 +221,7 @@ Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode:
FinPod Copyright (C) 2025 Ugric UgPod Copyright (C) 2025 Ugric
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details.

View File

@@ -1,2 +1,13 @@
# FinPod # UgPod
A desktop music player for Jellyfin with gapless transitions between songs.
This whole project is extremely early in development, there is and will be bugs for a while, but I hope to make it usable.
I'm mainly working on this because I haven't found a good desktop Jellyfin client that has gapless transitions. I also wanna make my own mp3 player for online and offline Jellyfin use, however I thought about it and wanted to turn it into a desktop app too.
# Image(s)
(sorry extremely early development screenshots)
![screenshot of main page](images/screenshot002.png)

447
app.py
View File

@@ -1,233 +1,308 @@
from jellyfin_apiclient_python import JellyfinClient import pyray as pr
import json import math
import uuid from ctypes import c_float
import subprocess from player import FFQueuePlayer, build_jellyfin_audio_url, server, client
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
import threading
import queue
import sys
import io
class FFQueuePlayer: # --- Configuration Constants ---
def __init__(self, samplerate=44100, channels=2): INITIAL_SCREEN_WIDTH = 800
self.samplerate = samplerate INITIAL_SCREEN_HEIGHT = 600
self.channels = channels TARGET_FPS = 60
self.proc = None # --- State Variables ---
self.next_proc = None 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
}
self.current_file = None # --- Utility Functions ---
self.next_file = None
self.next_preloaded = False def format_time_mm_ss(seconds):
"""Converts a time in seconds to an 'MM:SS' string format."""
seconds = int(seconds)
minutes = seconds // 60
seconds_remainder = seconds % 60
return f"{minutes:02d}:{seconds_remainder:02d}"
self.closed=False # --- Dynamic Layout Functions ---
self.playing = False def get_3d_render_area(screen_width, screen_height):
self.position = 0.0 ASPECT_WIDTH = 2.0
self.duration = 1.0 ASPECT_HEIGHT = 1.0
self.next_duration=1.0 ASPECT_RATIO = ASPECT_WIDTH / ASPECT_HEIGHT
self.song = 0 max_available_width = screen_width * 0.7
max_available_height = screen_height * 0.5
self.song_queue = queue.Queue() if (max_available_width / max_available_height) > ASPECT_RATIO:
self.swap_pending = False height = max_available_height
width = height * ASPECT_RATIO
else:
width = max_available_width
height = width / ASPECT_RATIO
self.lock = threading.Lock() x = (screen_width - width) / 2
y = screen_height * 0.15
self.stream = sd.RawOutputStream( return pr.Rectangle(x, y, width, height)
samplerate=self.samplerate,
channels=self.channels,
dtype="int16",
callback=self._callback
)
self.stream.start()
def _open_ffmpeg(self, url, seek=0): def get_progress_bar_rect(screen_width, screen_height):
self.song+=1 width = screen_width * 0.7
return subprocess.Popen( height = screen_height * 0.03
[ x = (screen_width - width) / 2
"ffmpeg", y = screen_height * 0.75
"-ss", str(seek), return pr.Rectangle(x, y, width, height)
"-i", url,
"-f", "s16le",
"-ac", str(self.channels),
"-ar", str(self.samplerate),
"-loglevel", "verbose",
"-"
],
stdout=subprocess.PIPE,
stderr=open(str(self.song)+".txt", "wb")
)
def seek(self, pos): def draw_progress_bar(rect, current_time, total_time):
with self.lock: if total_time > 0:
self.proc = self._open_ffmpeg(self.current_file, pos) progress_ratio = current_time / total_time
self.position = pos else:
progress_ratio = 0.0
def close(self): pr.draw_rectangle_rec(rect, pr.Color(100, 100, 100, 255))
self.closed=True progress_width = rect.width * progress_ratio
self.stream.close() pr.draw_rectangle(int(rect.x), int(rect.y), int(progress_width), int(rect.height), pr.Color(200, 50, 50, 255))
def get_duration(self, url): pr.draw_rectangle_lines_ex(rect, 2, pr.Color(50, 50, 50, 255))
"""Return duration in seconds for the track"""
try:
result = subprocess.run(
[
"ffprobe",
"-v", "quiet",
"-print_format", "json",
"-show_format",
"-show_streams",
url
],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True
)
info = json.loads(result.stdout) time_text = f"{format_time_mm_ss(current_time)} / {format_time_mm_ss(total_time)}"
text_width = pr.measure_text(time_text, int(rect.height * 0.7))
pr.draw_text(time_text,
int(rect.x + rect.width / 2 - text_width / 2),
int(rect.y + rect.height * 0.15),
int(rect.height * 0.7),
pr.WHITE)
# Prefer stream duration → fallback to format duration # --- ASSET MANAGEMENT ---
if "streams" in info:
for s in info["streams"]:
if "duration" in s:
return float(s["duration"])
if "format" in info and "duration" in info["format"]: def load_album_assets():
return float(info["format"]["duration"]) """Loads the texture, creates the 3D model, and applies the flipped texture."""
except Exception as e: # 1. Load Image
print("ffprobe error:", e) try:
image = pr.load_image("music/albumcover.png")
except:
print("WARNING: 'music/albumcover.png' not found. Using placeholder.")
image = pr.gen_image_checked(512, 512, 32, 32, pr.DARKGRAY, pr.WHITE)
return None # --- THE FIX: FLIP THE IMAGE VERTICALLY ---
def add_to_queue(self, url): pr.image_flip_vertical(image)
self.song_queue.put_nowait(url)
def play(self): # 2. Create Texture
with self.lock: texture = pr.load_texture_from_image(image)
if not self.playing: pr.unload_image(image)
self.playing = True
def pause(self): # 3. Generate Mesh (CD Case)
with self.lock: mesh = pr.gen_mesh_cube(1.5, 1.5, 0.0)
self.playing = False
def _start_next(self): # 4. Load Model
# Kill old pipeline model = pr.load_model_from_mesh(mesh)
if self.proc:
self.proc.kill()
# Move next pipeline into active # 5. Apply Texture
self.position = 0.0 # We use index 0 for the Albedo/Diffuse map
self.proc = self.next_proc map_index = 0
self.current_file = self.next_file # Use MATERIAL_MAP_ALBEDO if the binding is modern enough
self.duration = self.next_duration if hasattr(pr.MaterialMapIndex, 'MATERIAL_MAP_ALBEDO'):
self.next_proc=None map_index = pr.MaterialMapIndex.MATERIAL_MAP_ALBEDO
self.next_preloaded = False
def preload_next(self): model.materials[0].maps[map_index].texture = texture
self.next_file = self.song_queue.get()
self.next_duration = self.get_duration(self.next_file)
self.next_proc = self._open_ffmpeg(self.next_file)
self.next_preloaded = True
def preload_next_threaded(self): return texture, model
if self.next_preloaded: return
self.next_preloaded = True
threading.Thread(target=self.preload_next).start()
def _callback(self, outdata, frames, t, status): # --- CORE 3D RENDERING ---
with self.lock:
needed = frames * self.channels * 2
data = b''
if self.proc is None:
self.preload_next()
self._start_next()
else:
data = self.proc.stdout.read(needed) or b''
self.position += len(data) / (self.samplerate * self.channels * 2)
if self.position >= self.duration-10:
self.preload_next_threaded()
if self.proc.poll() is not None and len(data)<needed:
self._start_next()
new_data = self.proc.stdout.read(needed-len(data)) or b''
self.position += len(new_data) / (self.samplerate * self.channels * 2)
data += new_data
outdata[:len(data)]=data
outdata[len(data):] = b"\x00" * (needed-len(data))
def setup_3d_environment(render_width, render_height):
camera = pr.Camera3D()
camera.position = pr.Vector3(0.0, 0.0, 4.0) # Moved back slightly to fit the new models
camera.target = pr.Vector3(0.0, 0.0, 0.0)
camera.up = pr.Vector3(0.0, 1.0, 0.0)
camera.fovy = 45.0
camera.projection = pr.CameraProjection.CAMERA_PERSPECTIVE
return camera
def build_jellyfin_audio_url( def draw_3d_cover_flow(camera, model):
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. Draws the textured model using the existing Matrix logic.
""" """
path = f"/Items/{item_id}/Download" pr.begin_mode_3d(camera)
params = { # We use pr.WHITE as the tint so the texture shows its original colors.
"UserId": user_id, # If you use pr.RED, the album cover will look red-tinted.
"Container": container,
"AudioCodec": audio_codec, # <-- IMPORTANT
"api_key": api_key,
}
if bitrate is not None: # --------------------------------------------------------
params["Bitrate"] = bitrate # 2. CURRENT ALBUM (Center)
# --------------------------------------------------------
# Draw model at (0,0,0) with 1.0 scale
pr.rl_push_matrix()
pr.rl_translatef(0.0, 0.0, 1.5) # Spaced out slightly more
pr.rl_rotatef(0.0, 0.0, 1.0, 0.0) # Sharper angle
pr.draw_model(model, pr.Vector3(0.0, 0.0, 0.0), 1.0, pr.WHITE)
pr.rl_pop_matrix()
if media_source_id is not None: # --------------------------------------------------------
params["MediaSourceId"] = media_source_id # 3. PREVIOUS ALBUM (Far Far Left)
# --------------------------------------------------------
pr.rl_push_matrix()
pr.rl_translatef(-3.5, 0.0, 0.0) # Spaced out slightly more
pr.rl_rotatef(90.0, 0.0, 1.0, 0.0) # Sharper angle
pr.draw_model(model, pr.Vector3(0.0, 0.0, 0.0), 1.0, pr.LIGHTGRAY) # Slightly darkened
pr.rl_pop_matrix()
query = urlencode(params) # --------------------------------------------------------
return urljoin(base_url, path) + "?" + query # 3. PREVIOUS ALBUM (Far Left)
# --------------------------------------------------------
pr.rl_push_matrix()
pr.rl_translatef(-2.5, 0.0, 0.0) # Spaced out slightly more
pr.rl_rotatef(90.0, 0.0, 1.0, 0.0) # Sharper angle
pr.draw_model(model, pr.Vector3(0.0, 0.0, 0.0), 1.0, pr.LIGHTGRAY) # Slightly darkened
pr.rl_pop_matrix()
# --------------------------------------------------------
# 3. PREVIOUS ALBUM (Near Left)
# --------------------------------------------------------
pr.rl_push_matrix()
pr.rl_translatef(-1.5, 0.0, 0.5) # Added slight Z offset for depth
pr.rl_rotatef(65.0, 0.0, 1.0, 0.0)
pr.draw_model(model, pr.Vector3(0.0, 0.0, 0.0), 1.0, pr.WHITE)
pr.rl_pop_matrix()
client = JellyfinClient() # --------------------------------------------------------
load_dotenv() # 4. NEXT ALBUM (Near Right)
# --------------------------------------------------------
pr.rl_push_matrix()
pr.rl_translatef(1.5, 0.0, 0.5)
pr.rl_rotatef(-65.0, 0.0, 1.0, 0.0)
pr.draw_model(model, pr.Vector3(0.0, 0.0, 0.0), 1.0, pr.WHITE)
pr.rl_pop_matrix()
client.config.app('FinPod', '0.0.1', 'FinPod prototype', 'FinPod_prototype_1') # --------------------------------------------------------
client.config.data["auth.ssl"] = True # 4. NEXT ALBUM (Far Right)
# --------------------------------------------------------
pr.rl_push_matrix()
pr.rl_translatef(2.5, 0.0, 0.0)
pr.rl_rotatef(-90.0, 0.0, 1.0, 0.0)
pr.draw_model(model, pr.Vector3(0.0, 0.0, 0.0), 1.0, pr.LIGHTGRAY)
pr.rl_pop_matrix()
client.auth.connect_to_address(os.getenv("host")) # --------------------------------------------------------
client.auth.login(os.getenv("URL"), os.getenv("username"), os.getenv("password")) # 4. NEXT ALBUM (Far Far Right)
# --------------------------------------------------------
pr.rl_push_matrix()
pr.rl_translatef(3.5, 0.0, 0.0)
pr.rl_rotatef(-90.0, 0.0, 1.0, 0.0)
pr.draw_model(model, pr.Vector3(0.0, 0.0, 0.0), 1.0, pr.LIGHTGRAY)
pr.rl_pop_matrix()
credentials = client.auth.credentials.get_credentials() pr.end_mode_3d()
server = credentials["Servers"][0]
print(json.dumps(server))
# --- Main Setup and Loop ---
# Initialization
pr.set_config_flags(pr.ConfigFlags.FLAG_WINDOW_RESIZABLE)
pr.set_config_flags(pr.FLAG_MSAA_4X_HINT)
pr.init_window(state["screen_width"], state["screen_height"], "UgPod")
pr.set_target_fps(TARGET_FPS)
player = FFQueuePlayer() player = FFQueuePlayer()
# Build Jellyfin URLs
# Add to queue
print("add queue") print("add queue")
player.add_to_queue(build_jellyfin_audio_url(server["address"], client.jellyfin.get_item("99067e877d91be1a66eb5a7ff2f4128f")["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("916eda422f48efd8705f29e0600a3e60")["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("5e1067d59ed98979ad12a58548b27b83")["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(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/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") print("add queue done")
player.play() player.play()
while True: # Initial setup
print("pos:", str(round((player.position*100)/player.duration))+"%", player.position, '/', player.duration) render_rect = get_3d_render_area(state["screen_width"], state["screen_height"])
time.sleep(1) state["render_texture"] = pr.load_render_texture(int(render_rect.width), int(render_rect.height))
state["camera"] = setup_3d_environment(int(render_rect.width), int(render_rect.height))
player.close() # LOAD THE ASSETS
state["album_texture"], state["album_model"] = load_album_assets()
# --- Main Game Loop ---
while not pr.window_should_close():
# 1. Update
current_width = pr.get_screen_width()
current_height = pr.get_screen_height()
if pr.is_window_resized():
state["screen_width"] = current_width
state["screen_height"] = current_height
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:
player.pause()
else:
player.play()
if pr.is_key_pressed(pr.KeyboardKey.KEY_LEFT):
player.seek(player.position-5)
if pr.is_key_pressed(pr.KeyboardKey.KEY_RIGHT):
player.seek(player.position+5)
# ----------------------------------------------------
# 2. DRAW 3D SCENE
# ----------------------------------------------------
render_rect = get_3d_render_area(current_width, current_height)
pr.begin_texture_mode(state["render_texture"])
pr.clear_background(pr.Color(20, 20, 20, 255))
# Pass the loaded model to the draw function
draw_3d_cover_flow(state["camera"], state["album_model"])
pr.end_texture_mode()
# ----------------------------------------------------
# 3. DRAW 2D GUI
# ----------------------------------------------------
pr.begin_drawing()
pr.clear_background(pr.Color(40, 40, 40, 255))
progress_rect = get_progress_bar_rect(current_width, current_height)
title_size = int(current_height * 0.05)
pr.draw_text("UgPod", int(current_width * 0.05), int(current_height * 0.05), title_size, pr.SKYBLUE)
source_rect = pr.Rectangle(0, 0, state["render_texture"].texture.width, -state["render_texture"].texture.height)
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)
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)
pr.end_drawing()
# --- De-initialization ---
pr.unload_texture(state["album_texture"]) # Unload the texture
pr.unload_model(state["album_model"]) # Unload the model/mesh
pr.unload_render_texture(state["render_texture"])
pr.close_window()

BIN
images/screenshot002.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 394 KiB

244
player.py Normal file
View File

@@ -0,0 +1,244 @@
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
import threading
import queue
import sys
import io
os.makedirs("logs", exist_ok=True)
class FFQueuePlayer:
def __init__(self, samplerate=44100, channels=2):
self.samplerate = samplerate
self.channels = channels
self.proc = None
self.next_proc = None
self.current_file = None
self.next_file = None
self.next_preload_state = 0
self.last_sample = 0.0
self.samples_since_last_sample = 0
self.closed=False
self.playing = False
self.position = 0.0
self.playback_info = None
self.next_playback_info=None
self.song = 0
self.song_queue = queue.Queue()
self.swap_pending = False
self.lock = threading.Lock()
self.stream = sd.RawOutputStream(
samplerate=self.samplerate,
channels=self.channels,
dtype="int16",
callback=self._callback
)
self.stream.start()
def _open_ffmpeg(self, url, seek=0):
self.song+=1
return subprocess.Popen(
[
"ffmpeg",
"-ss", str(seek),
"-i", url,
"-f", "s16le",
"-ac", str(self.channels),
"-ar", str(self.samplerate),
"-loglevel", "verbose",
"-"
],
stdout=subprocess.PIPE,
stderr=open('logs/'+str(self.song)+".txt", "wb")
)
def seek(self, pos):
with self.lock:
pos = min(max(0,pos), self.playback_info_to_duration(self.playback_info))
if self.proc:
self.proc.kill()
self.proc = self._open_ffmpeg(self.current_file, pos)
self.position = pos
def close(self):
self.closed=True
self.stream.close()
def get_stream_info(self, url):
"""Return duration in seconds for the track"""
try:
result = subprocess.run(
[
"ffprobe",
"-v", "quiet",
"-print_format", "json",
"-show_format",
"-show_streams",
url
],
stdout=subprocess.PIPE,
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, url):
self.song_queue.put_nowait(url)
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"]:
if "duration" in s:
return float(s["duration"])
if "format" in info and "duration" in info["format"]:
return float(info["format"]["duration"])
return 0.0
def _callback(self, outdata, frames, t, status):
with self.lock:
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:
data = self.proc.stdout.read(needed) or b''
self.position += len(data) / (self.samplerate * self.channels * 2)
if self.position >= self.playback_info_to_duration(self.playback_info)-10:
self.preload_next_threaded()
if self.proc.poll() is not None and len(data)<needed:
self._start_next()
if self.proc is not None and self.proc.poll() is None:
new_data = self.proc.stdout.read(needed-len(data)) or b''
self.position += len(new_data) / (self.samplerate * self.channels * 2)
data += new_data
else:
print("bruh")
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.
"""
path = f"/Items/{item_id}/Download"
params = {
"UserId": user_id,
"Container": container,
"AudioCodec": audio_codec, # <-- IMPORTANT
"api_key": api_key,
}
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
client = JellyfinClient()
load_dotenv()
client.config.app('FinPod', '0.0.1', 'FinPod prototype', 'FinPod_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)
# print("pos:", str(round((player.position*100)/(duration or 1.0)))+"%", player.position, '/', duration)
# time.sleep(1)

View File

@@ -1,9 +1,28 @@
aiohappyeyeballs==2.6.1
aiohttp==3.13.2
aiosignal==1.4.0
attrs==25.4.0
certifi==2025.11.12 certifi==2025.11.12
cffi==2.0.0
charset-normalizer==3.4.4 charset-normalizer==3.4.4
dotenv==0.9.9 dotenv==0.9.9
ffmpeg==1.4
ffmpeg-python==0.2.0
frozenlist==1.8.0
future==1.0.0
idna==3.11 idna==3.11
jellyfin-apiclient-python==1.11.0 jellyfin-apiclient-python==1.11.0
multidict==6.7.0
numpy==2.3.5
propcache==0.4.1
pycparser==2.23
pyee==13.0.0
python-dotenv==1.2.1 python-dotenv==1.2.1
python-vlc==3.0.21203
raylib==5.5.0.3
requests==2.32.5 requests==2.32.5
sounddevice==0.5.3
typing_extensions==4.15.0
urllib3==2.6.1 urllib3==2.6.1
websocket-client==1.9.0 websocket-client==1.9.0
yarl==1.22.0