get true gapless playback working

This commit is contained in:
William Bell
2025-12-10 03:29:12 +00:00
parent a859cdb2f5
commit 2fe284a806

229
app.py
View File

@@ -4,33 +4,230 @@ 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
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_preloaded = False
self.closed=False
self.playing = False
self.position = 0.0
self.duration = 1.0
self.next_duration=1.0
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(str(self.song)+".txt", "wb")
)
def seek(self, pos):
with self.lock:
self.proc = self._open_ffmpeg(self.current_file, pos)
self.position = pos
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
)
info = json.loads(result.stdout)
# Prefer stream duration → fallback to format duration
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"])
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()
# 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
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
def preload_next_threaded(self):
if self.next_preloaded: return
self.next_preloaded = True
threading.Thread(target=self.preload_next).start()
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 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
play_id = str(uuid.uuid4())
container = "flac"
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]
server["username"] = 'username'
print(json.dumps(server))
ffmpeg = subprocess.Popen(
["ffmpeg", "-i", "pipe:0", "-f", "wav", "pipe:1"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE
)
aplay = subprocess.Popen(
["aplay"],
stdin=ffmpeg.stdout
)
freebird = client.jellyfin.search_media_items(
term="Free Bird", media="Music")['Items'][0]
client.jellyfin.get_audio_stream(ffmpeg.stdin, freebird['Id'], play_id, container)
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"]))
print("add queue done")
player.play()
while True:
print("pos:", str(round((player.position*100)/player.duration))+"%", player.position, '/', player.duration)
time.sleep(1)
player.close()