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 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 name: str album_name:str album_cover_path:str artist_name:str class GaplessPlayer: def __init__(self, samplerate:int=44100, 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.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 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: 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 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 song.playback_info and "duration" in song.playback_info["format"]: return float(song.playback_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: 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 = current_song.ffmpeg.stdout.read(needed) or b'' except BlockingIOError: pass self.position += len(data) / (self.samplerate * self.channels * 2) if self.position >= self.song_to_duration(current_song)-10: self.preload_next_threaded() if current_song.ffmpeg.poll() is not None and len(data)= self.song_to_duration(current_song)-0.1: self._start_next() 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 = 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: current_song.ffmpeg = self._open_ffmpeg(current_song, self.position) outdata[:len(data)] = data outdata[len(data):] = b'\x00'*(needed-len(data)) 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["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) # print("pos:", str(round((player.position*100)/(duration or 1.0)))+"%", player.position, '/', duration) # time.sleep(1)