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: -
-
- -
- 0:00 / 0:00 -
-
-
- -
-
- - - - -
- -

📃 Queue

- - - - - - - - - - - - - - -
#ArtistTitleDuration
-
- - - - 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: -
+
+ +
+ 0:00 / 0:00 +
+
+
+ +
+
+ + + + +
+ +

📃 Queue

+ + + + + + + + + + + + + + + +
#ArtistTitleDurationActions
+
+ + + + 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(); +