diff --git a/download_service.py b/download_service.py
deleted file mode 100644
index 9f1793d..0000000
--- a/download_service.py
+++ /dev/null
@@ -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()
diff --git a/index.html b/index.html
deleted file mode 100644
index 0e4e05b..0000000
--- a/index.html
+++ /dev/null
@@ -1,148 +0,0 @@
-
-
-
-
- Music Player
-
-
-
-
-
đĩ Dashdio
-
-
Artist: -
-
Title: -
-
Status: -
-
-
-
-
-
-
-
-
-
-
-
-
đ Queue
-
-
-
-
-
- # |
- Artist |
- Title |
- Duration |
-
-
-
-
-
-
-
-
-
-
-
diff --git a/main.py b/main.py
index bcd6acf..1909f55 100644
--- a/main.py
+++ b/main.py
@@ -1,62 +1,10 @@
import asyncio
-from enum import Enum
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
-from fastapi.responses import HTMLResponse
-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 fastapi.staticfiles import StaticFiles
+from models import MusicPlayer, PlayerState, WSConnectionType
+from services import ConnectionManager, DownloadService
# Setup
tags_metadata = [
@@ -64,17 +12,16 @@ tags_metadata = [
{"name": "experimental"},
{"name": "queue"},
]
-app = FastAPI(openapi_tags=tags_metadata)
+api_app = FastAPI(openapi_tags=tags_metadata, title="Dashdio API")
player = MusicPlayer()
dl_service = DownloadService()
ws_manager = ConnectionManager()
+app = FastAPI(title="Dashdio App")
# Interface
-@app.get("/", response_class=HTMLResponse)
-async def root():
- with open("index.html") as f:
- return f.read()
+app.mount("/api", api_app)
+app.mount("/", StaticFiles(directory="ui", html=True), name="ui")
@app.on_event("startup")
@@ -98,7 +45,7 @@ async def queue_broadcast_loop():
# Status updates
-@app.websocket("/player")
+@api_app.websocket("/player")
async def websocket_player(websocket: WebSocket):
await ws_manager.connect(websocket, WSConnectionType.state)
try:
@@ -110,7 +57,7 @@ async def websocket_player(websocket: WebSocket):
# Queue updates
-@app.websocket("/queue")
+@api_app.websocket("/queue")
async def websocket_queue(websocket: WebSocket):
await ws_manager.connect(websocket, WSConnectionType.queue)
try:
@@ -121,43 +68,48 @@ async def websocket_queue(websocket: WebSocket):
ws_manager.disconnect(websocket)
-@app.get("/queue", tags=["queue"])
+@api_app.get("/queue", tags=["queue"])
def get_queue():
return player.get_queue()
-@app.post("/queue", tags=["queue"])
+@api_app.post("/queue", tags=["queue"])
async def post_to_queue(url: str):
track = await dl_service.download(url)
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():
await player.play()
-@app.post("/player/stop", tags=["player"])
+@api_app.post("/player/stop", tags=["player"])
async def player_stop():
await player.stop()
-@app.post("/player/skip", tags=["player"])
+@api_app.post("/player/skip", tags=["player"])
async def player_skip():
await player.next()
# Player
-@app.put("/player/volume", tags=["player"])
+@api_app.put("/player/volume", tags=["player"])
async def set_volume(volume: float):
await player.set_volume(volume)
-@app.get("/player/volume", tags=["player"])
+@api_app.get("/player/volume", tags=["player"])
def get_volume():
return {"volume": player.get_volume()}
-@app.get("/player", tags=["player"])
+@api_app.get("/player", tags=["player"])
def get_player_state() -> PlayerState:
return player.get_state()
diff --git a/music_player.py b/models.py
similarity index 90%
rename from music_player.py
rename to models.py
index 2376b90..bf7a68e 100644
--- a/music_player.py
+++ b/models.py
@@ -3,18 +3,25 @@ import os
import threading
import time
from enum import Enum
+from uuid import uuid4
import pygame
from pydantic import BaseModel
class Track(BaseModel):
+ id: str = str(uuid4())
artist: str | None
title: str | None
duration: int | None
filepath: str
+class WSConnectionType(Enum):
+ state = "state"
+ queue = "queue"
+
+
class PlaybackState(str, Enum):
Playing = "Playing"
Paused = "Paused"
@@ -40,6 +47,14 @@ class Queue(BaseModel):
def add(self, track: Track) -> None:
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:
return len(self.items)
@@ -111,7 +126,8 @@ class MusicPlayer:
async def _unload_track(self) -> None:
pygame.mixer.music.unload()
# Delete file from disc
- os.remove(self._state.track.filepath)
+ if self._state.track:
+ os.remove(self._state.track.filepath)
# Update state
await self._set_track(None)
@@ -162,6 +178,11 @@ class MusicPlayer:
if que_len == 0 and not self._state.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):
with self.lock:
match self._state.playback_state:
diff --git a/py-dj.service b/py-dj.service
new file mode 100644
index 0000000..ca10bf5
--- /dev/null
+++ b/py-dj.service
@@ -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
diff --git a/services.py b/services.py
new file mode 100644
index 0000000..84dce68
--- /dev/null
+++ b/services.py
@@ -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
diff --git a/start-api.sh b/start-api.sh
new file mode 100755
index 0000000..1710a0c
--- /dev/null
+++ b/start-api.sh
@@ -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
diff --git a/ui/index.html b/ui/index.html
new file mode 100644
index 0000000..140d02e
--- /dev/null
+++ b/ui/index.html
@@ -0,0 +1,56 @@
+
+
+
+
+ Music Player
+
+
+
+
+
đĩ Dashdio
+
+
Artist: -
+
Title: -
+
Status: -
+
+
+
+
+
+
+
+
+
+
+
+
đ Queue
+
+
+
+
+
+ # |
+ Artist |
+ Title |
+ Duration |
+ Actions |
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ui/script.js b/ui/script.js
new file mode 100644
index 0000000..a5521fc
--- /dev/null
+++ b/ui/script.js
@@ -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 = `
+ ${index + 1} |
+ ${track.artist} |
+ ${track.title} |
+ ${formatTime(track.duration)} |
+
+
+ |
+ `;
+
+ queueBody.appendChild(row);
+ });
+};
+
+playerSocket.onerror = queueSocket.onerror = console.error;
+playerSocket.onclose = () => setTimeout(connectWebSockets, 1000);
+queueSocket.onclose = () => setTimeout(connectWebSockets, 1000);
+}
+
+connectWebSockets();
+