From 2fe284a8068397764216f180489370b7ab184ae7 Mon Sep 17 00:00:00 2001 From: William Bell <62452284+Ugric@users.noreply.github.com> Date: Wed, 10 Dec 2025 03:29:12 +0000 Subject: [PATCH] get true gapless playback working --- app.py | 229 +++++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 213 insertions(+), 16 deletions(-) diff --git a/app.py b/app.py index 10d2953..f534b96 100644 --- a/app.py +++ b/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) 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) \ No newline at end of file +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() \ No newline at end of file