get true gapless playback working
This commit is contained in:
229
app.py
229
app.py
@@ -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()
|
||||
Reference in New Issue
Block a user