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
.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.
FinPod
UgPod
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.
@@ -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:
FinPod Copyright (C) 2025 Ugric
UgPod Copyright (C) 2025 Ugric
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.

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)

471
app.py
View File

@@ -1,233 +1,308 @@
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
import pyray as pr
import math
from ctypes import c_float
from player import FFQueuePlayer, build_jellyfin_audio_url, server, client
class FFQueuePlayer:
def __init__(self, samplerate=44100, channels=2):
self.samplerate = samplerate
self.channels = channels
# --- Configuration Constants ---
INITIAL_SCREEN_WIDTH = 800
INITIAL_SCREEN_HEIGHT = 600
TARGET_FPS = 60
self.proc = None
self.next_proc = None
# --- 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
}
self.current_file = None
self.next_file = None
# --- Utility Functions ---
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
self.position = 0.0
self.duration = 1.0
self.next_duration=1.0
def get_3d_render_area(screen_width, screen_height):
ASPECT_WIDTH = 2.0
ASPECT_HEIGHT = 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()
self.swap_pending = False
if (max_available_width / max_available_height) > ASPECT_RATIO:
height = max_available_height
width = height * ASPECT_RATIO
else:
width = max_available_width
height = width / ASPECT_RATIO
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(str(self.song)+".txt", "wb")
)
x = (screen_width - width) / 2
y = screen_height * 0.15
def seek(self, pos):
with self.lock:
self.proc = self._open_ffmpeg(self.current_file, pos)
self.position = pos
return pr.Rectangle(x, y, width, height)
def get_progress_bar_rect(screen_width, screen_height):
width = screen_width * 0.7
height = screen_height * 0.03
x = (screen_width - width) / 2
y = screen_height * 0.75
return pr.Rectangle(x, y, width, height)
def draw_progress_bar(rect, current_time, total_time):
if total_time > 0:
progress_ratio = current_time / total_time
else:
progress_ratio = 0.0
pr.draw_rectangle_rec(rect, pr.Color(100, 100, 100, 255))
progress_width = rect.width * progress_ratio
pr.draw_rectangle(int(rect.x), int(rect.y), int(progress_width), int(rect.height), pr.Color(200, 50, 50, 255))
pr.draw_rectangle_lines_ex(rect, 2, pr.Color(50, 50, 50, 255))
def close(self):
self.closed=True
self.stream.close()
def get_duration(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
)
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)
info = json.loads(result.stdout)
# --- ASSET MANAGEMENT ---
# Prefer stream duration → fallback to format duration
if "streams" in info:
for s in info["streams"]:
if "duration" in s:
return float(s["duration"])
def load_album_assets():
"""Loads the texture, creates the 3D model, and applies the flipped texture."""
# 1. Load Image
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)
if "format" in info and "duration" in info["format"]:
return float(info["format"]["duration"])
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:
self.playing = True
def pause(self):
with self.lock:
self.playing = False
def _start_next(self):
# Kill old pipeline
if self.proc:
self.proc.kill()
# --- THE FIX: FLIP THE IMAGE VERTICALLY ---
pr.image_flip_vertical(image)
# 2. Create Texture
texture = pr.load_texture_from_image(image)
pr.unload_image(image)
# 3. Generate Mesh (CD Case)
mesh = pr.gen_mesh_cube(1.5, 1.5, 0.0)
# 4. Load Model
model = pr.load_model_from_mesh(mesh)
# 5. Apply Texture
# We use index 0 for the Albedo/Diffuse map
map_index = 0
# Use MATERIAL_MAP_ALBEDO if the binding is modern enough
if hasattr(pr.MaterialMapIndex, 'MATERIAL_MAP_ALBEDO'):
map_index = pr.MaterialMapIndex.MATERIAL_MAP_ALBEDO
# Move next pipeline into active
self.position = 0.0
self.proc = self.next_proc
self.current_file = self.next_file
self.duration = self.next_duration
self.next_proc=None
self.next_preloaded = False
model.materials[0].maps[map_index].texture = texture
def preload_next(self):
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
return texture, model
def preload_next_threaded(self):
if self.next_preloaded: return
self.next_preloaded = True
threading.Thread(target=self.preload_next).start()
# --- CORE 3D RENDERING ---
def _callback(self, outdata, frames, t, status):
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(
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:
def draw_3d_cover_flow(camera, model):
"""
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)
# We use pr.WHITE as the tint so the texture shows its original colors.
# If you use pr.RED, the album cover will look red-tinted.
# --------------------------------------------------------
# 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()
params = {
"UserId": user_id,
"Container": container,
"AudioCodec": audio_codec, # <-- IMPORTANT
"api_key": api_key,
}
# --------------------------------------------------------
# 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()
if bitrate is not None:
params["Bitrate"] = bitrate
# --------------------------------------------------------
# 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()
if media_source_id is not None:
params["MediaSourceId"] = media_source_id
# --------------------------------------------------------
# 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()
# --------------------------------------------------------
# 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()
query = urlencode(params)
return urljoin(base_url, path) + "?" + query
# --------------------------------------------------------
# 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()
# --------------------------------------------------------
# 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()
pr.end_mode_3d()
# --- Main Setup and Loop ---
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))
# 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()
# Build Jellyfin URLs
# Add to 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("916eda422f48efd8705f29e0600a3e60")["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(build_jellyfin_audio_url(server["address"], client.jellyfin.get_item("8bcf8240d12aa5c3b14dc3b57f32fef7")["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('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")
player.play()
while True:
print("pos:", str(round((player.position*100)/player.duration))+"%", player.position, '/', player.duration)
time.sleep(1)
# Initial setup
render_rect = get_3d_render_area(state["screen_width"], state["screen_height"])
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
cffi==2.0.0
charset-normalizer==3.4.4
dotenv==0.9.9
ffmpeg==1.4
ffmpeg-python==0.2.0
frozenlist==1.8.0
future==1.0.0
idna==3.11
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-vlc==3.0.21203
raylib==5.5.0.3
requests==2.32.5
sounddevice==0.5.3
typing_extensions==4.15.0
urllib3==2.6.1
websocket-client==1.9.0
yarl==1.22.0