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 fcntl from dataclasses import dataclass os.makedirs("logs", exist_ok=True) @dataclass class Song: url: str duration: float name: str album_name:str album_cover:str artist_name:str class GaplessPlayer: def __init__(self, samplerate:int=44100, channels:int=2): 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 self.playing = False self.position = 0.0 self.song_list = [] self.current_song_in_list = -1 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 proc = subprocess.Popen( [ "ffmpeg", # "-re", "-ss", str(seek), "-i", url, "-f", "s16le", "-ac", str(self.channels), "-ar", str(self.samplerate), "-loglevel", "verbose", "-" ], stdout=subprocess.PIPE, stderr=subprocess.PIPE ) # --- make stdout non-blocking --- fd = proc.stdout.fileno() flags = fcntl.fcntl(fd, fcntl.F_GETFL) fcntl.fcntl(fd, fcntl.F_SETFL, flags | os.O_NONBLOCK) return proc 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, 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"]: 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: try: data = self.proc.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: self.preload_next_threaded() if self.proc.poll() is not None and len(data)= self.playback_info_to_duration(self.playback_info)-0.1: self._start_next() if self.proc is not None and self.proc.poll() is None: try: new_data = self.proc.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) 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"/Audio/{item_id}/universal" 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('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) # print("pos:", str(round((player.position*100)/(duration or 1.0)))+"%", player.position, '/', duration) # time.sleep(1)