feat: add remove from queue action and refactor models

This commit is contained in:
Radu C. Martin 2025-04-15 18:17:41 +02:00
parent 2286d7a8d5
commit 4bd6ad85e3
5 changed files with 135 additions and 125 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

@ -42,6 +42,7 @@
<th style="text-align: left; padding: 8px;">Artist</th> <th style="text-align: left; padding: 8px;">Artist</th>
<th style="text-align: left; padding: 8px;">Title</th> <th style="text-align: left; padding: 8px;">Title</th>
<th style="text-align: right; padding: 8px;">Duration</th> <th style="text-align: right; padding: 8px;">Duration</th>
<th style="text-align: center; padding: 8px;">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody id="queueBody"> <tbody id="queueBody">
@ -56,6 +57,12 @@ const s = Math.floor(seconds % 60).toString().padStart(2, '0');
return `${m}:${s}`; return `${m}:${s}`;
} }
const removeFromQueue = async (trackId) => {
await api(`/queue/${trackId}`, { method: "DELETE" });
updateQueue(); // Refresh queue after deletion
};
function updateProgress(position, duration) { function updateProgress(position, duration) {
const progressBar = document.getElementById("progressBar"); const progressBar = document.getElementById("progressBar");
const elapsedTime = document.getElementById("elapsedTime"); const elapsedTime = document.getElementById("elapsedTime");
@ -117,11 +124,11 @@ if (track) {
}; };
queueSocket.onmessage = (event) => { queueSocket.onmessage = (event) => {
const queue = JSON.parse(event.data); const queue = JSON.parse(event.data);
const queueBody = document.getElementById("queueBody"); const queueBody = document.getElementById("queueBody");
queueBody.innerHTML = ""; queueBody.innerHTML = "";
(queue.items || queue).forEach((track, index) => { (queue.items || queue).forEach((track, index) => {
const row = document.createElement("tr"); const row = document.createElement("tr");
row.innerHTML = ` row.innerHTML = `
@ -129,10 +136,13 @@ queueBody.innerHTML = "";
<td style="padding: 8px;">${track.artist}</td> <td style="padding: 8px;">${track.artist}</td>
<td style="padding: 8px;">${track.title}</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: right;">${formatTime(track.duration)}</td>
<td style="padding: 8px; text-align: center;">
<button onclick="removeFromQueue('${track.id}')">🗑️</button>
</td>
`; `;
queueBody.appendChild(row); queueBody.appendChild(row);
}); });
}; };
playerSocket.onerror = queueSocket.onerror = console.error; playerSocket.onerror = queueSocket.onerror = console.error;

61
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.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 # Setup
tags_metadata = [ tags_metadata = [
@ -132,6 +80,11 @@ async def post_to_queue(url: str):
await player.add_to_queue(track) 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"]) @app.post("/player/play", tags=["player"])
async def player_play(): async def player_play():
await player.play() await player.play()

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,6 +126,7 @@ 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
if self._state.track:
os.remove(self._state.track.filepath) 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:

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