Compare commits

...
Sign in to create a new pull request.

3 commits

Author SHA1 Message Date
Radu C. Martin
5666b87124 feat: add systemd scripts 2025-05-05 15:48:53 +02:00
Radu C. Martin
d265b92f1d feat: split frontend into separate folder 2025-04-15 18:36:26 +02:00
Radu C. Martin
4bd6ad85e3 feat: add remove from queue action and refactor models 2025-04-15 18:17:41 +02:00
9 changed files with 312 additions and 276 deletions

View file

@ -1,57 +0,0 @@
import asyncio
import sys
import yt_dlp
from music_player import Track
ydl_opts = {
"format": "mp3/bestaudio/best",
# See help(yt_dlp.postprocessor) for a list of available Postprocessors and their arguments
"postprocessors": [
{
"key": "FFmpegExtractAudio",
"preferredcodec": "mp3",
}
],
"paths": {"home": "queue"},
"outtmpl": {"default": "%(artist)s - %(track)s [%(id)s].%(ext)s"},
}
class DownloadService:
def __init__(self) -> None:
self.ydl = yt_dlp.YoutubeDL(ydl_opts)
async def download(self, url: str) -> Track:
def extract():
return self.ydl.extract_info(url, download=True)
info = await asyncio.to_thread(extract)
try:
filepath = info["requested_downloads"][-1]["filepath"] # type: ignore
except KeyError:
raise ValueError("Could not ")
track = Track(
artist=info.get("artist", None), # type: ignore
title=info.get("title", None), # type: ignore
duration=info.get("duration", None), # type: ignore
filepath=filepath, # type: ignore
)
print(f"Finished processing {track}")
return track
def main():
if len(sys.argv) < 2:
sys.exit(1)
qser = DownloadService()
qser.download(sys.argv[1])
if __name__ == "__main__":
main()

View file

@ -1,148 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Music Player</title>
<style>
body { font-family: sans-serif; padding: 1rem; }
button { margin: 0.5rem; }
#queueList { margin-top: 1rem; }
</style>
</head>
<body>
<div style="font-family: sans-serif; max-width: 600px; margin: auto;">
<h2>🎵 Dashdio</h2>
<div id="nowPlaying">
<strong>Artist:</strong> <span id="trackArtist">-</span><br>
<strong>Title:</strong> <span id="trackTitle">-</span><br>
<strong>Status:</strong> <span id="playbackState">-</span><br>
<div style="margin-top: 10px;">
<progress id="progressBar" value="0" max="100" style="width: 100%; height: 20px;"></progress>
<div style="text-align: right;">
<span id="elapsedTime">0:00</span> / <span id="totalTime">0:00</span>
</div>
</div>
</div>
<div style="margin-top: 20px;">
<label for="volumeSlider"><strong>🔊 Volume:</strong> <span id="volumeValue">1</span></label><br>
<input type="range" min="0" max="1" step="0.01" id="volumeSlider" oninput="setVolume(this.value)">
<button onclick="play()">Play/Pause</button>
<button onclick="stop()">Stop</button>
<button onclick="skip()">Skip</button>
</div>
<h3 style="margin-top: 30px;">📃 Queue</h3>
<input type="text" id="trackUrl" placeholder="Track URL">
<button onclick="addToQueue()">Add to Queue</button>
<table id="queueTable" style="width: 100%; border-collapse: collapse; font-size: 0.95em;">
<thead>
<tr style="background-color: #f0f0f0;">
<th style="text-align: left; padding: 8px;">#</th>
<th style="text-align: left; padding: 8px;">Artist</th>
<th style="text-align: left; padding: 8px;">Title</th>
<th style="text-align: right; padding: 8px;">Duration</th>
</tr>
</thead>
<tbody id="queueBody">
<!-- Filled by JS -->
</tbody>
</table>
</div>
<script>
function formatTime(seconds) {
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60).toString().padStart(2, '0');
return `${m}:${s}`;
}
function updateProgress(position, duration) {
const progressBar = document.getElementById("progressBar");
const elapsedTime = document.getElementById("elapsedTime");
const totalTime = document.getElementById("totalTime");
progressBar.max = duration;
progressBar.value = position;
elapsedTime.textContent = formatTime(position);
totalTime.textContent = formatTime(duration);
}
const api = (endpoint, options = {}) =>
fetch(endpoint, options).then(res => res.json()).catch(console.error);
const setVolume = async (val) => {
await api(`/player/volume?volume=${val}`, { method: "PUT" });
document.getElementById("volumeValue").textContent = val;
};
const addToQueue = async () => {
const url = document.getElementById("trackUrl").value;
await api(`/queue?url=${encodeURIComponent(url)}`, { method: "POST" });
updateQueue();
};
const play = async () => { await api("/player/play", { method: "POST" });};
const stop = async () => { await api("/player/stop", { method: "POST" });};
const skip = async () => { await api("/player/skip", { method: "POST" });};
// WebSocket connections
let playerSocket, queueSocket;
function connectWebSockets() {
const proto = location.protocol === "https:" ? "wss" : "ws";
const base = `${proto}://${location.host}`;
playerSocket = new WebSocket(`${base}/player`);
queueSocket = new WebSocket(`${base}/queue`);
playerSocket.onopen = () => playerSocket.send("ping");
queueSocket.onopen = () => queueSocket.send("ping");
playerSocket.onmessage = (event) => {
const state = JSON.parse(event.data);
const { playback_state, track, position, volume } = state;
document.getElementById("trackArtist").textContent = track?.artist || "-";
document.getElementById("trackTitle").textContent = track?.title || "-";
document.getElementById("playbackState").textContent = playback_state;
document.getElementById("volumeSlider").value = volume;
document.getElementById("volumeValue").textContent = volume;
if (track) {
updateProgress(position, track.duration);
}
};
queueSocket.onmessage = (event) => {
const queue = JSON.parse(event.data);
const queueBody = document.getElementById("queueBody");
queueBody.innerHTML = "";
(queue.items || queue).forEach((track, index) => {
const row = document.createElement("tr");
row.innerHTML = `
<td style="padding: 8px;">${index + 1}</td>
<td style="padding: 8px;">${track.artist}</td>
<td style="padding: 8px;">${track.title}</td>
<td style="padding: 8px; text-align: right;">${formatTime(track.duration)}</td>
`;
queueBody.appendChild(row);
});
};
playerSocket.onerror = queueSocket.onerror = console.error;
playerSocket.onclose = () => setTimeout(connectWebSockets, 1000);
queueSocket.onclose = () => setTimeout(connectWebSockets, 1000);
}
connectWebSockets();
</script>
</body>
</html>

92
main.py
View file

@ -1,62 +1,10 @@
import asyncio import asyncio
from enum import Enum
from fastapi import FastAPI, WebSocket, WebSocketDisconnect from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.responses import HTMLResponse from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from download_service import DownloadService
from music_player import MusicPlayer, PlayerState
class ChangePlayerState(Enum):
play = "play"
pause = "pause"
resume = "resume"
stop = "stop"
class WSConnectionType(Enum):
state = "state"
queue = "queue"
class ConnectionManager:
def __init__(self) -> None:
self.active_connections: dict[str, set[WebSocket]] = {
WSConnectionType.state.value: set(),
WSConnectionType.queue.value: set(),
}
async def connect(self, websocket: WebSocket, type: WSConnectionType):
await websocket.accept()
self.active_connections[type.value].add(websocket)
async def send(self, ws: WebSocket, message: BaseModel):
try:
await ws.send_json(message.model_dump())
except Exception:
self.disconnect(ws)
async def broadcast(self, ws_type: WSConnectionType, message: BaseModel):
broken = set()
conn_list = list(self.active_connections[ws_type.value])
for idx in range(len(conn_list)):
ws = conn_list[idx]
try:
await ws.send_json(message.model_dump())
except Exception:
broken.add(ws)
for ws in broken:
self.disconnect(ws)
def disconnect(self, websocket: WebSocket):
if websocket in self.active_connections[WSConnectionType.state.value]:
self.active_connections[WSConnectionType.state.value].remove(websocket)
if websocket in self.active_connections[WSConnectionType.queue.value]:
self.active_connections[WSConnectionType.queue.value].remove(websocket)
from models import MusicPlayer, PlayerState, WSConnectionType
from services import ConnectionManager, DownloadService
# Setup # Setup
tags_metadata = [ tags_metadata = [
@ -64,17 +12,16 @@ tags_metadata = [
{"name": "experimental"}, {"name": "experimental"},
{"name": "queue"}, {"name": "queue"},
] ]
app = FastAPI(openapi_tags=tags_metadata) api_app = FastAPI(openapi_tags=tags_metadata, title="Dashdio API")
player = MusicPlayer() player = MusicPlayer()
dl_service = DownloadService() dl_service = DownloadService()
ws_manager = ConnectionManager() ws_manager = ConnectionManager()
app = FastAPI(title="Dashdio App")
# Interface # Interface
@app.get("/", response_class=HTMLResponse) app.mount("/api", api_app)
async def root(): app.mount("/", StaticFiles(directory="ui", html=True), name="ui")
with open("index.html") as f:
return f.read()
@app.on_event("startup") @app.on_event("startup")
@ -98,7 +45,7 @@ async def queue_broadcast_loop():
# Status updates # Status updates
@app.websocket("/player") @api_app.websocket("/player")
async def websocket_player(websocket: WebSocket): async def websocket_player(websocket: WebSocket):
await ws_manager.connect(websocket, WSConnectionType.state) await ws_manager.connect(websocket, WSConnectionType.state)
try: try:
@ -110,7 +57,7 @@ async def websocket_player(websocket: WebSocket):
# Queue updates # Queue updates
@app.websocket("/queue") @api_app.websocket("/queue")
async def websocket_queue(websocket: WebSocket): async def websocket_queue(websocket: WebSocket):
await ws_manager.connect(websocket, WSConnectionType.queue) await ws_manager.connect(websocket, WSConnectionType.queue)
try: try:
@ -121,43 +68,48 @@ async def websocket_queue(websocket: WebSocket):
ws_manager.disconnect(websocket) ws_manager.disconnect(websocket)
@app.get("/queue", tags=["queue"]) @api_app.get("/queue", tags=["queue"])
def get_queue(): def get_queue():
return player.get_queue() return player.get_queue()
@app.post("/queue", tags=["queue"]) @api_app.post("/queue", tags=["queue"])
async def post_to_queue(url: str): async def post_to_queue(url: str):
track = await dl_service.download(url) track = await dl_service.download(url)
await player.add_to_queue(track) await player.add_to_queue(track)
@app.post("/player/play", tags=["player"]) @api_app.delete("/queue/{track_id}", tags=["queue"])
def delete_from_queue(track_id: str):
player.remove_from_queue_by_id(track_id)
@api_app.post("/player/play", tags=["player"])
async def player_play(): async def player_play():
await player.play() await player.play()
@app.post("/player/stop", tags=["player"]) @api_app.post("/player/stop", tags=["player"])
async def player_stop(): async def player_stop():
await player.stop() await player.stop()
@app.post("/player/skip", tags=["player"]) @api_app.post("/player/skip", tags=["player"])
async def player_skip(): async def player_skip():
await player.next() await player.next()
# Player # Player
@app.put("/player/volume", tags=["player"]) @api_app.put("/player/volume", tags=["player"])
async def set_volume(volume: float): async def set_volume(volume: float):
await player.set_volume(volume) await player.set_volume(volume)
@app.get("/player/volume", tags=["player"]) @api_app.get("/player/volume", tags=["player"])
def get_volume(): def get_volume():
return {"volume": player.get_volume()} return {"volume": player.get_volume()}
@app.get("/player", tags=["player"]) @api_app.get("/player", tags=["player"])
def get_player_state() -> PlayerState: def get_player_state() -> PlayerState:
return player.get_state() return player.get_state()

View file

@ -3,18 +3,25 @@ import os
import threading import threading
import time import time
from enum import Enum from enum import Enum
from uuid import uuid4
import pygame import pygame
from pydantic import BaseModel from pydantic import BaseModel
class Track(BaseModel): class Track(BaseModel):
id: str = str(uuid4())
artist: str | None artist: str | None
title: str | None title: str | None
duration: int | None duration: int | None
filepath: str filepath: str
class WSConnectionType(Enum):
state = "state"
queue = "queue"
class PlaybackState(str, Enum): class PlaybackState(str, Enum):
Playing = "Playing" Playing = "Playing"
Paused = "Paused" Paused = "Paused"
@ -40,6 +47,14 @@ class Queue(BaseModel):
def add(self, track: Track) -> None: def add(self, track: Track) -> None:
self.items.append(track) self.items.append(track)
def remove_by_id(self, track_id: str) -> None:
track = self.get_by_id(track_id)
if track:
self.items.remove(track)
def get_by_id(self, track_id: str) -> Track | None:
return next((x for x in self.items if x.id == track_id), None)
def len(self) -> int: def len(self) -> int:
return len(self.items) return len(self.items)
@ -111,7 +126,8 @@ class MusicPlayer:
async def _unload_track(self) -> None: async def _unload_track(self) -> None:
pygame.mixer.music.unload() pygame.mixer.music.unload()
# Delete file from disc # Delete file from disc
os.remove(self._state.track.filepath) if self._state.track:
os.remove(self._state.track.filepath)
# Update state # Update state
await self._set_track(None) await self._set_track(None)
@ -162,6 +178,11 @@ class MusicPlayer:
if que_len == 0 and not self._state.track: if que_len == 0 and not self._state.track:
await self._play_next_track() await self._play_next_track()
def remove_from_queue_by_id(self, track_id: str):
with self.lock:
self._queue.remove_by_id(track_id)
self._queue_event.set()
async def play(self): async def play(self):
with self.lock: with self.lock:
match self._state.playback_state: match self._state.playback_state:

13
py-dj.service Normal file
View file

@ -0,0 +1,13 @@
[Unit]
Description=Start py-dj FastAPI service
After=network.target
[Service]
User=pi
WorkingDirectory=/home/pi/py-dj
ExecStart=/home/pi/py-dj/start-api.sh
Restart=on-failure
Environment=PATH=/home/pi/.local/bin:/usr/bin:/bin
[Install]
WantedBy=multi-user.target

83
services.py Normal file
View file

@ -0,0 +1,83 @@
import asyncio
import yt_dlp
from fastapi import WebSocket
from pydantic import BaseModel
from models import Track, WSConnectionType
class ConnectionManager:
def __init__(self) -> None:
self.active_connections: dict[str, set[WebSocket]] = {
WSConnectionType.state.value: set(),
WSConnectionType.queue.value: set(),
}
async def connect(self, websocket: WebSocket, type: WSConnectionType):
await websocket.accept()
self.active_connections[type.value].add(websocket)
async def send(self, ws: WebSocket, message: BaseModel):
try:
await ws.send_json(message.model_dump())
except Exception:
self.disconnect(ws)
async def broadcast(self, ws_type: WSConnectionType, message: BaseModel):
broken = set()
conn_list = list(self.active_connections[ws_type.value])
for idx in range(len(conn_list)):
ws = conn_list[idx]
try:
await ws.send_json(message.model_dump())
except Exception:
broken.add(ws)
for ws in broken:
self.disconnect(ws)
def disconnect(self, websocket: WebSocket):
if websocket in self.active_connections[WSConnectionType.state.value]:
self.active_connections[WSConnectionType.state.value].remove(websocket)
if websocket in self.active_connections[WSConnectionType.queue.value]:
self.active_connections[WSConnectionType.queue.value].remove(websocket)
class DownloadService:
ydl_opts = {
"format": "mp3/bestaudio/best",
# See help(yt_dlp.postprocessor) for a list of available Postprocessors and their arguments
"postprocessors": [
{
"key": "FFmpegExtractAudio",
"preferredcodec": "mp3",
}
],
"paths": {"home": "queue"},
"outtmpl": {"default": "%(artist)s - %(track)s [%(id)s].%(ext)s"},
}
def __init__(self) -> None:
self.ydl = yt_dlp.YoutubeDL(self.ydl_opts)
async def download(self, url: str) -> Track:
def extract():
return self.ydl.extract_info(url, download=True)
info = await asyncio.to_thread(extract)
try:
filepath = info["requested_downloads"][-1]["filepath"] # type: ignore
except KeyError:
raise ValueError("Could not ")
track = Track(
artist=info.get("artist", None), # type: ignore
title=info.get("title", None), # type: ignore
duration=info.get("duration", None), # type: ignore
filepath=filepath, # type: ignore
)
print(f"Finished processing {track}")
return track

14
start-api.sh Executable file
View file

@ -0,0 +1,14 @@
#!/bin/bash
set -e
cd /home/pi/py-dj
# Pull latest changes
git pull
# Clear the queue directory
rm -rf queue/*
mkdir -p queue # Ensure it exists
# Start the API
uv run fastapi run

56
ui/index.html Normal file
View file

@ -0,0 +1,56 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Music Player</title>
<style>
body { font-family: sans-serif; padding: 1rem; }
button { margin: 0.5rem; }
#queueList { margin-top: 1rem; }
</style>
</head>
<body>
<div style="font-family: sans-serif; max-width: 600px; margin: auto;">
<h2>🎵 Dashdio</h2>
<div id="nowPlaying">
<strong>Artist:</strong> <span id="trackArtist">-</span><br>
<strong>Title:</strong> <span id="trackTitle">-</span><br>
<strong>Status:</strong> <span id="playbackState">-</span><br>
<div style="margin-top: 10px;">
<progress id="progressBar" value="0" max="100" style="width: 100%; height: 20px;"></progress>
<div style="text-align: right;">
<span id="elapsedTime">0:00</span> / <span id="totalTime">0:00</span>
</div>
</div>
</div>
<div style="margin-top: 20px;">
<label for="volumeSlider"><strong>🔊 Volume:</strong> <span id="volumeValue">1</span></label><br>
<input type="range" min="0" max="1" step="0.01" id="volumeSlider" oninput="setVolume(this.value)">
<button onclick="play()">Play/Pause</button>
<button onclick="stop()">Stop</button>
<button onclick="skip()">Skip</button>
</div>
<h3 style="margin-top: 30px;">📃 Queue</h3>
<input type="text" id="trackUrl" placeholder="Track URL">
<button onclick="addToQueue()">Add to Queue</button>
<table id="queueTable" style="width: 100%; border-collapse: collapse; font-size: 0.95em;">
<thead>
<tr style="background-color: #f0f0f0;">
<th style="text-align: left; padding: 8px;">#</th>
<th style="text-align: left; padding: 8px;">Artist</th>
<th style="text-align: left; padding: 8px;">Title</th>
<th style="text-align: right; padding: 8px;">Duration</th>
<th style="text-align: center; padding: 8px;">Actions</th>
</tr>
</thead>
<tbody id="queueBody">
<!-- Filled by JS -->
</tbody>
</table>
</div>
<script src="script.js"></script>
</body>
</html>

102
ui/script.js Normal file
View file

@ -0,0 +1,102 @@
function formatTime(seconds) {
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60).toString().padStart(2, '0');
return `${m}:${s}`;
}
const removeFromQueue = async (trackId) => {
await api(`/api/queue/${trackId}`, { method: "DELETE" });
updateQueue(); // Refresh queue after deletion
};
function updateProgress(position, duration) {
const progressBar = document.getElementById("progressBar");
const elapsedTime = document.getElementById("elapsedTime");
const totalTime = document.getElementById("totalTime");
progressBar.max = duration;
progressBar.value = position;
elapsedTime.textContent = formatTime(position);
totalTime.textContent = formatTime(duration);
}
const api = (endpoint, options = {}) =>
fetch(endpoint, options).then(res => res.json()).catch(console.error);
const setVolume = async (val) => {
await api(`/api/player/volume?volume=${val}`, { method: "PUT" });
document.getElementById("volumeValue").textContent = val;
};
const addToQueue = async () => {
const url = document.getElementById("trackUrl").value;
await api(`/api/queue?url=${encodeURIComponent(url)}`, { method: "POST" });
updateQueue();
};
const play = async () => { await api("/api/player/play", { method: "POST" });};
const stop = async () => { await api("/api/player/stop", { method: "POST" });};
const skip = async () => { await api("/api/player/skip", { method: "POST" });};
// WebSocket connections
let playerSocket, queueSocket;
function connectWebSockets() {
const proto = location.protocol === "https:" ? "wss" : "ws";
const base = `${proto}://${location.host}/api`;
playerSocket = new WebSocket(`${base}/player`);
queueSocket = new WebSocket(`${base}/queue`);
playerSocket.onopen = () => playerSocket.send("ping");
queueSocket.onopen = () => queueSocket.send("ping");
playerSocket.onmessage = (event) => {
const state = JSON.parse(event.data);
const { playback_state, track, position, volume } = state;
document.getElementById("trackArtist").textContent = track?.artist || "-";
document.getElementById("trackTitle").textContent = track?.title || "-";
document.getElementById("playbackState").textContent = playback_state;
document.getElementById("volumeSlider").value = volume;
document.getElementById("volumeValue").textContent = volume;
if (track) {
updateProgress(position, track.duration);
}
};
queueSocket.onmessage = (event) => {
const queue = JSON.parse(event.data);
const queueBody = document.getElementById("queueBody");
queueBody.innerHTML = "";
(queue.items || queue).forEach((track, index) => {
const row = document.createElement("tr");
row.innerHTML = `
<td style="padding: 8px;">${index + 1}</td>
<td style="padding: 8px;">${track.artist}</td>
<td style="padding: 8px;">${track.title}</td>
<td style="padding: 8px; text-align: right;">${formatTime(track.duration)}</td>
<td style="padding: 8px; text-align: center;">
<button onclick="removeFromQueue('${track.id}')">🗑</button>
</td>
`;
queueBody.appendChild(row);
});
};
playerSocket.onerror = queueSocket.onerror = console.error;
playerSocket.onclose = () => setTimeout(connectWebSockets, 1000);
queueSocket.onclose = () => setTimeout(connectWebSockets, 1000);
}
connectWebSockets();