Files
UgPod/gapless_player.py
2025-12-26 21:46:11 +00:00

333 lines
11 KiB
Python

import json
import uuid
import subprocess
import os
import time
import ffmpeg
import requests
import threading
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 numpy as np
from collections import deque
from jelly import server, client
from urllib.parse import urlencode, urljoin
import requests
from pathlib import Path
import mimetypes
@dataclass
class Song:
id: str
url: str
name: str
duration: float
album_name: str
album_cover_path: str
artist_name: str
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"
ext = None
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(
item_id,
url,
data["Name"],
data["RunTimeTicks"] / 10_000_000,
data["Album"],
saved_path,
data["AlbumArtist"],
)
#os.makedirs("logs", exist_ok=True)
os.makedirs("data", exist_ok=True)
os.makedirs("data/images", exist_ok=True)
class GaplessPlayer:
def __init__(self, samplerate: int = 96000, channels: int = 2):
self.samplerate = samplerate
self.channels = channels
self.closed = False
self.playing = False
self.position = 0.0
self.song_list: list[Song] = []
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()
self.oscilloscope_data_points = deque(maxlen=samplerate//60)
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,
)
print("yo")
# --- 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), song.duration)
if song.ffmpeg:
song.ffmpeg.kill()
song.ffmpeg = None
if self.playing:
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.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()
current_song.ffmpeg = None
# 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 forward_song(self):
current_song = self.get_current_song()
if current_song and current_song.ffmpeg:
current_song.ffmpeg.kill()
current_song.ffmpeg = None
if self.current_song_in_list < len(
self.song_list
):
self.current_song_in_list += 1
def load_song(self, song: Song):
if song:
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()
return None
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 >= current_song.duration - 10:
self.preload_next_threaded()
else:
next_song = self.get_next_song()
if next_song and next_song.ffmpeg:
if next_song.ffmpeg.poll() is None:
next_song.ffmpeg.kill()
next_song.ffmpeg = None
next_song.preload_state = 0
if current_song.ffmpeg.poll() is not None and len(data) < needed:
if round(self.position, 2) >= current_song.duration - 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:
# if current_song.ffmpeg and current_song.ffmpeg.poll() is not None:
# current_song.ffmpeg.kill()
# current_song.ffmpeg = None
current_song.ffmpeg = self._open_ffmpeg(
current_song, self.position
)
samples = np.frombuffer(data, dtype=np.int16)
left = samples[0::2]
right = samples[1::2]
norm = 32769.0
x = left / norm
y = right / norm
points = list(zip(x, y))
# step = max(1, len(points) // 1000)
# points = points[::step]
self.oscilloscope_data_points.extend(points)
outdata[: len(data)] = data
outdata[len(data) :] = b"\x00" * (needed - len(data))
def save_state(self, path):
with open(path,"w") as f:
data = {
"queue": [song.id for song in self.song_list],
"current_song": self.current_song_in_list,
"position": self.position
}
json.dump(data, f)
def load_state(self, path):
try:
with open(path,"r") as f:
data = json.load(f)
self.song_list = []
for song in data["queue"]:
songOBJ = song_data_to_Song(client.jellyfin.get_item(song), server)
songOBJ.ffmpeg = None
songOBJ.preload_state = 0
self.song_list.append(songOBJ)
self.current_song_in_list = data['current_song']
self.seek(data['position'])
except:
return
# 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)