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 index 0e4e05b..fe5ade4 100644 --- a/index.html +++ b/index.html @@ -42,6 +42,7 @@ Artist Title Duration + Actions @@ -56,6 +57,12 @@ const s = Math.floor(seconds % 60).toString().padStart(2, '0'); return `${m}:${s}`; } +const removeFromQueue = async (trackId) => { + await api(`/queue/${trackId}`, { method: "DELETE" }); + updateQueue(); // Refresh queue after deletion +}; + + function updateProgress(position, duration) { const progressBar = document.getElementById("progressBar"); const elapsedTime = document.getElementById("elapsedTime"); @@ -117,22 +124,25 @@ if (track) { }; queueSocket.onmessage = (event) => { -const queue = JSON.parse(event.data); -const queueBody = document.getElementById("queueBody"); -queueBody.innerHTML = ""; + const queue = JSON.parse(event.data); + const queueBody = document.getElementById("queueBody"); + queueBody.innerHTML = ""; -(queue.items || queue).forEach((track, index) => { - const row = document.createElement("tr"); + (queue.items || queue).forEach((track, index) => { + const row = document.createElement("tr"); - row.innerHTML = ` - ${index + 1} - ${track.artist} - ${track.title} - ${formatTime(track.duration)} - `; + row.innerHTML = ` + ${index + 1} + ${track.artist} + ${track.title} + ${formatTime(track.duration)} + + + + `; - queueBody.appendChild(row); -}); + queueBody.appendChild(row); + }); }; playerSocket.onerror = queueSocket.onerror = console.error; diff --git a/main.py b/main.py index bcd6acf..c54ef6d 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 models import MusicPlayer, PlayerState, WSConnectionType +from services import ConnectionManager, DownloadService # Setup tags_metadata = [ @@ -132,6 +80,11 @@ async def post_to_queue(url: str): await player.add_to_queue(track) +@app.delete("/queue/{track_id}", tags=["queue"]) +def delete_from_queue(track_id: str): + player.remove_from_queue_by_id(track_id) + + @app.post("/player/play", tags=["player"]) async def player_play(): await player.play() 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/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