change to an album based queuing system with history and display album cover, song and album name

This commit is contained in:
William Bell
2025-12-16 07:58:43 +00:00
parent 8496e6871b
commit 27e4c13b7e
3 changed files with 228 additions and 138 deletions

View File

@@ -18,16 +18,20 @@ 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
duration: float
name: str
album_name:str
album_cover:str
album_cover_path:str
artist_name:str
@@ -36,12 +40,6 @@ class GaplessPlayer:
self.samplerate = samplerate
self.channels = channels
self.proc = None
self.next_proc = None
self.current_song: Song = None
self.next_file: Song = None
self.next_preload_state = 0
self.closed=False
@@ -62,15 +60,18 @@ class GaplessPlayer:
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, url, seek=0):
self.song+=1
def _open_ffmpeg(self, song, seek=0):
proc = subprocess.Popen(
[
"ffmpeg",
# "-re",
"-ss", str(seek),
"-i", url,
"-i", song.url,
"-f", "s16le",
"-ac", str(self.channels),
"-ar", str(self.samplerate),
@@ -90,15 +91,63 @@ class GaplessPlayer:
def seek(self, pos):
with self.lock:
pos = min(max(0,pos), self.playback_info_to_duration(self.playback_info))
if self.proc:
self.proc.kill()
self.proc = self._open_ffmpeg(self.current_file, pos)
self.position = pos
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:
@@ -115,64 +164,21 @@ class GaplessPlayer:
stderr=subprocess.PIPE,
text=True
)
return json.loads(result.stdout)
except Exception as e:
print("ffprobe error:", e)
return None
def add_to_queue(self, song:Song):
self.current_song_in_list.append(song)
def play(self):
with self.lock:
if not self.playing:
if not self.proc and self.current_file:
self.proc = self._open_ffmpeg(self.current_file, self.position)
self.playing = True
def pause(self):
with self.lock:
if self.proc:
self.proc.kill()
self.proc = None
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.playback_info = self.next_playback_info
self.next_proc=None
self.next_playback_info = None
self.next_preload_state = 0
def preload_next(self):
self.next_file = self.song_queue.get()
self.next_playback_info = self.get_stream_info(self.next_file)
self.next_proc = self._open_ffmpeg(self.next_file)
self.next_preload_state = 2
def preload_next_threaded(self):
if self.next_preload_state: return
self.next_preload_state = 1
threading.Thread(target=self.preload_next).start()
def playback_info_to_duration(self, info):
if info is None: return 0.0
if "streams" in info:
for s in info["streams"]:
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 info and "duration" in info["format"]:
return float(info["format"]["duration"])
if "format" in song.playback_info and "duration" in song.playback_info["format"]:
return float(song.playback_info["format"]["duration"])
return 0.0
@@ -181,67 +187,78 @@ class GaplessPlayer:
needed = frames * self.channels * 2
data = b''
if self.playing:
if self.proc is None:
if self.next_preload_state==2:
self._start_next()
elif self.next_preload_state == 0:
self.preload_next_threaded()
else:
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 = self.proc.stdout.read(needed) or b''
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.playback_info_to_duration(self.playback_info)-10:
if self.position >= self.song_to_duration(current_song)-10:
self.preload_next_threaded()
if self.proc.poll() is not None and len(data)<needed:
if round(self.position, 2) >= self.playback_info_to_duration(self.playback_info)-0.1:
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()
if self.proc is not None and self.proc.poll() is None:
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 = self.proc.stdout.read(needed-len(data)) or b''
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:
self.proc = self._open_ffmpeg(self.current_file, self.position)
current_song.ffmpeg = self._open_ffmpeg(current_song, self.position)
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.
"""
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": user_id,
"Container": container,
"AudioCodec": audio_codec, # <-- IMPORTANT
"api_key": api_key,
"UserId": client_data["UserId"],
"Container": "flac",
"AudioCodec": "flac", # <-- IMPORTANT
"api_key": client_data["AccessToken"],
}
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
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()