Files
UgPod/gapless_player.py

282 lines
9.0 KiB
Python

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<len(self.song_list):
return self.song_list[self.current_song_in_list]
def _open_ffmpeg(self, song, seek=0):
proc = subprocess.Popen(
[
"ffmpeg",
# "-re",
"-ss", str(seek),
"-i", song.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:
song = self.get_current_song()
if song:
pos = min(max(0,pos), self.song_to_duration(song))
if song.ffmpeg:
song.ffmpeg.kill()
song.ffmpeg = self._open_ffmpeg(song, pos)
self.position = pos
def close(self):
self.closed=True
self.stream.close()
def add_to_queue(self, song:Song):
song.ffmpeg = None
song.playback_info = None
song.preload_state = 0
self.song_list.append(song)
def play(self):
with self.lock:
if not self.playing:
current_song = self.get_current_song()
if current_song and not current_song.ffmpeg:
current_song.ffmpeg = self._open_ffmpeg(current_song, self.position)
self.playing = True
def pause(self):
with self.lock:
# current_song = self.get_current_song()
# if current_song and current_song.ffmpeg:
# current_song.ffmpeg.kill()
# current_song.ffmpeg = None
self.playing = False
def _start_next(self):
# Kill old pipeline
current_song = self.get_current_song()
if current_song and current_song.ffmpeg:
current_song.ffmpeg.kill()
# Move next pipeline into active
self.position = 0.0
self.current_song_in_list+=1
def get_next_song(self):
if self.current_song_in_list+1 >= 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)<needed:
if round(self.position, 2) >= 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)