This commit is contained in:
Simone Tesini 2025-08-02 04:53:27 +02:00
commit e4be88db2d
12 changed files with 251 additions and 110 deletions

View file

@ -1,17 +1,17 @@
import uuid import uuid
from dataclasses import asdict from dataclasses import asdict
import dotenv import dotenv
from flask import Flask, Response, jsonify, request from flask import Flask, Response, jsonify, request
from flask_cors import CORS from flask_cors import CORS
from flask_socketio import SocketIO, join_room, leave_room from flask_socketio import SocketIO, join_room, leave_room
from .connect import get_connection
from .qrcode_gen import generate_qr
from .room import Room
from .song import Song, add_song_in_db, get_song_by_title_artist, init_db
from .song_fetch import download_song_mp3, lastfm_query_search
from .state import State from .state import State
from .connect import get_connection
from .room import Room
from .song import Song, init_db, get_song_by_title_artist, add_song_in_db, get_song_by_uuid
from .song_fetch import lastfm_query_search, yt_get_audio_url, yt_search_song
from .qrcode_gen import generate_qr
dotenv.load_dotenv() dotenv.load_dotenv()
@ -33,9 +33,9 @@ state.rooms[1000] = Room(
pin=None, pin=None,
tags=set(), tags=set(),
range_size=100, range_size=100,
songs={"b": (Song(uuid="b", title="title", artist="art", tags=["a", "B"], image_id="img", youtube_id="yt"), 1)}, songs={},
history=[], history=[],
playing=[Song(uuid="<uuid>", title="<title>", artist="<artist>", tags=[], image_id="<img>", youtube_id="<yt>")], playing=[],
playing_idx=0, playing_idx=0,
) )
@ -110,15 +110,16 @@ def queue_next():
if room.playing_idx >= len(room.playing): if room.playing_idx >= len(room.playing):
## queue ended ## queue ended
room.renew_queue()
ended = True
else:
ended = False
# room.renew_queue() data = {"success": True, "ended": ended, "index": room.playing_idx, "queue": [asdict(s) for s in room.playing]}
data = {"success": True, "ended": True, "index": room.playing_idx, "queue": room.playing} state.socketio.emit("queue_update", data, to=str(room.id))
state.socketio.emit("new_queue", data, to=str(room.id))
return data return data
return {"success": True, "ended": False, "index": room.playing_idx}
@app.post("/api/room/new") @app.post("/api/room/new")
def room_new(): def room_new():
@ -185,8 +186,7 @@ def add_song():
if (song := get_song_by_title_artist(info.title, info.artist)) is None: if (song := get_song_by_title_artist(info.title, info.artist)) is None:
## song not found, downolad from YT ## song not found, downolad from YT
if (res := download_song_mp3(info.title, info.artist)) is None: yt_video_id = yt_search_song(info.title, info.artist)
return error("Cannot get info from YT")
## add in DB ## add in DB
song = Song( song = Song(
@ -195,7 +195,7 @@ def add_song():
artist=info.artist, artist=info.artist,
tags=info.tags, tags=info.tags,
image_id=info.img_id, image_id=info.img_id,
youtube_id=res[0], youtube_id=yt_video_id,
) )
add_song_in_db(song) add_song_in_db(song)
@ -203,7 +203,7 @@ def add_song():
## add the song in the room if does not exists ## add the song in the room if does not exists
if song.uuid not in room.songs: if song.uuid not in room.songs:
room.songs[song.uuid] = (song, 1) # start with one vote room.songs[song.uuid] = (song, 1) # start with one vote
socketio.emit("new_song", {"song": song, "user_score": 1}, to=str(room.id)) socketio.emit("new_song", {"song": asdict(song) | {"upvote": 1}}, to=str(room.id))
return {"success": True, "song": song} return {"success": True, "song": song}
@ -235,7 +235,7 @@ def post_song_vote():
## update the song ## update the song
room.songs[song_id] = (song_info[0], song_info[1] + int(request.args["increment"])) room.songs[song_id] = (song_info[0], song_info[1] + int(request.args["increment"]))
socketio.emit("new_vote", {"song": song_info[0], "user_score": song_info[1]}) socketio.emit("new_vote", {"song": asdict(song_info[0]) | {"upvote": song_info[1]}})
return {"success": True} return {"success": True}
@ -257,5 +257,19 @@ def room_qrcode():
return Response(stream, content_type="image/jpeg") return Response(stream, content_type="image/jpeg")
@app.get("/api/song/audio")
def get_audio_url():
if (song_uuid := request.args.get("song")) is None:
return error("Missing song id")
if (song := get_song_by_uuid(song_uuid)) is None:
return error("Song not found")
if (url := yt_get_audio_url(song.youtube_id)) is None:
return error("Cannot get audio url")
return {"success": True, "url": url}
if __name__ == "__main__": if __name__ == "__main__":
socketio.run(app, debug=True) socketio.run(app, debug=True)

View file

@ -59,7 +59,7 @@ def lastfm_query_search(query: str) -> SongInfo:
return SongInfo(artist=artist, title=name, img_id=img_id, tags=tags) return SongInfo(artist=artist, title=name, img_id=img_id, tags=tags)
def download_song_mp3(name: str, artist: str) -> tuple[str, str] | None: # ( id, audio ) def yt_search_song(name: str, artist: str) -> str: # video id
ydl_opts = { ydl_opts = {
"format": "bestaudio", "format": "bestaudio",
"default_search": "ytsearch1", "default_search": "ytsearch1",
@ -70,12 +70,27 @@ def download_song_mp3(name: str, artist: str) -> tuple[str, str] | None: # ( id
with yt_dlp.YoutubeDL(ydl_opts) as ydl: with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(f"{name!r} - {artist!r}", download=False) info = ydl.extract_info(f"{name!r} - {artist!r}", download=False)
first_entry = info["entries"][0] return info["entries"][0]["id"]
video_id = first_entry["id"]
def yt_get_audio_url(video_id) -> str | None: # audio url
ydl_opts = {
"format": "bestaudio",
"default_search": "ytsearch1",
"outtmpl": "%(title)s.%(ext)s",
"skip_download": True,
}
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(video_id, download=False)
if "entries" not in info:
return info["url"]
first_entry = info["entries"][0]
for fmt in first_entry["formats"]: for fmt in first_entry["formats"]:
if "acodec" in fmt and fmt["acodec"] != "none": if "acodec" in fmt and fmt["acodec"] != "none":
return video_id, fmt["url"] return fmt["url"]
return None return None

View file

@ -1,8 +1,10 @@
<script lang="ts"> <script lang="ts">
let { code, message } = $props() import type { FetchError } from "$lib/types"
let { returnError }: { returnError: FetchError } = $props()
</script> </script>
<div class="flex h-screen w-full flex-col items-center justify-center"> <div class="flex h-screen w-full flex-col items-center justify-center">
<h1 class="p-2 text-xl">Error {code}</h1> <h1 class="p-2 text-xl">Error {returnError.code}</h1>
<p>{message}</p> <p>{returnError.message}</p>
</div> </div>

View file

@ -1,12 +1,12 @@
<script lang="ts"> <script lang="ts">
import { type Song, createEmptySong } from "$lib/types" import { type Song, createEmptySong } from "$lib/types"
let { songs, playing } = $props() let { queueSongs, playingIndex } = $props()
let displaySongs = $derived<Song[]>([ let displaySongs = $derived<Song[]>([
playing > 0 && playing < songs.length ? songs[playing - 1] : createEmptySong(), playingIndex > 0 ? queueSongs[playingIndex - 1] : createEmptySong(),
songs[playing], queueSongs[playingIndex],
playing == songs.length - 1 ? createEmptySong() : songs[playing + 1], playingIndex == queueSongs.length - 1 ? createEmptySong() : queueSongs[playingIndex + 1],
]) ])
</script> </script>

View file

@ -9,7 +9,11 @@
} }
</script> </script>
<div class="flex h-full w-full flex-row items-center gap-2 rounded border-2 border-black p-4"> <div class="bg-lime-500 flex h-full w-full flex-row items-center gap-2 rounded border-2 border-lime-600">
<input type="text" placeholder="Song & Artist" class="h-full w-3/4 rounded" bind:value={input} /> <input type="text" placeholder="Song & Artist" class="font-bold outline-none text-white h-[50px] px-4 w-3/4 rounded" bind:value={input} />
<button class="w-1/4 rounded" onclick={sendSong}>Send</button> <button
class="shadow-xl hover:scale-105 w-1/4 h-[40px] cursor-pointer bg-lime-600 border-lime-700 font-semibold text-white border-2 i-lucide-check rounded active:scale-90 duration-100"
onclick={sendSong}>Add</button
>
<span class="i-lucide-chevrons-left"></span>
</div> </div>

View file

@ -3,29 +3,39 @@
let { suggestions, roomId }: { suggestions: Suggestion[]; roomId: string } = $props() let { suggestions, roomId }: { suggestions: Suggestion[]; roomId: string } = $props()
async function vote(amount: number, songId: string) { async function vote(idx: number, amount: number, songId: string) {
suggestions[idx].upvote += amount
await fetch(`/api/song/voting?room=${roomId}&song=${songId}&increment=${amount}`, { method: "POST" }) await fetch(`/api/song/voting?room=${roomId}&song=${songId}&increment=${amount}`, { method: "POST" })
} }
</script> </script>
<div class="flex h-full w-full flex-col items-center gap-2 overflow-y-auto"> <div class="flex h-full w-full flex-col items-center gap-2 overflow-y-auto">
{#each suggestions as sug} {#if suggestions.length == 0}
<div class="flex h-[80px] w-full flex-row gap-2 rounded border-2 border-black p-2"> <p>No suggestions yet! Try to add a new one using the <b>Add</b> button</p>
{/if}
{#each suggestions as sug, idx}
<div class="shadow-md hover:bg-indigo-400 duration-100 bg-indigo-500 flex h-[80px] w-full flex-row gap-2 rounded border-2 border-indigo-600 p-2">
<div class="flex w-3/4 flex-row gap-2"> <div class="flex w-3/4 flex-row gap-2">
<img class="w-[60px] min-w-[60px] rounded" src={`https://lastfm.freetls.fastly.net/i/u/174s/${sug.image_id}.png`} alt="Song cover" /> <img class="w-[60px] min-w-[60px] rounded" src={`https://lastfm.freetls.fastly.net/i/u/174s/${sug.image_id}.png`} alt="Song cover" />
<h1>{sug.title} - {sug.artist}</h1> <div class="text-white">
<p>{sug.title}</p>
<p>{sug.artist}</p>
</div>
</div> </div>
<div class="flex w-1/4 flex-row items-center justify-center gap-2"> <div class="flex w-1/4 flex-row items-center justify-center gap-2">
<button <button
class="grayscale"
onclick={() => { onclick={() => {
vote(1, sug.uuid) vote(idx, 1, sug.uuid)
}}>+1</button }}>👍</button
> >
<p>{sug.upvote}</p> <p class="text-white font-semibold">{sug.upvote}</p>
<button <button
class="hover:scale-150 duration-100"
onclick={() => { onclick={() => {
vote(-1, sug.uuid) vote(idx, -1, sug.uuid)
}}>-1</button }}><div class="rotate-180">👍</div></button
> >
</div> </div>
</div> </div>

View file

@ -35,3 +35,8 @@ export const parseSuggestion = async function (sugg: any): Promise<Suggestion> {
let resp = await SuggestionSchema.parseAsync(sugg) let resp = await SuggestionSchema.parseAsync(sugg)
return resp return resp
} }
export type FetchError = {
code: number
message: string
}

69
frontend/src/lib/utils.ts Normal file
View file

@ -0,0 +1,69 @@
import { parseSong, parseSuggestion, type FetchError, type Song, type Suggestion } from "./types"
export const joinRoom = async function (roomId: string): Promise<[FetchError | null, string]> {
let resp = await fetch("/api/join?room=" + roomId)
if (resp.status != 200) {
return [{ code: 400, message: "Cannot join the room" }, ""]
}
return [null, "test"]
}
export const getSuggestions = async function (roomId: string): Promise<[FetchError | null, Suggestion[]]> {
let resp = await fetch("/api/room/suggestions?room=" + roomId)
if (resp.status != 200) {
return [{ code: 400, message: "Failed to retrieve suggestions" }, []]
}
let json = await resp.json()
let suggestions: Suggestion[] = []
json["songs"].forEach(async (i: any) => {
suggestions.push(await parseSuggestion(i))
})
suggestions = suggestions.sort((a, b) => {
return a.upvote - b.upvote
})
return [null, suggestions]
}
export const getQueueSongs = async function (roomId: string): Promise<[FetchError | null, Song[], number]> {
let resp = await fetch("/api/queue?room=" + roomId)
if (resp.status != 200) {
return [{ code: 400, message: "Failed to load queue songs" }, [], 0]
}
let json = await resp.json()
let songs: Song[] = []
json["queue"].forEach(async (i: any) => {
songs.push(await parseSong(i))
})
let playingId = json["index"]
return [null, songs, playingId]
}
export const triggerPlayNext = async function (roomId: string): Promise<[FetchError | null, Song[], number]> {
let resp = await fetch("/api/queue/next?room=" + roomId, { method: "POST" })
if (resp.status != 200) {
return [{ code: 400, message: "Failed to trigger next song playback" }, [], 0]
}
let json = await resp.json()
let songs: Song[] = []
if (json["ended"]) {
json["queue"].forEach(async (i: any) => {
songs.push(await parseSong(i))
})
}
return [null, songs, json["index"]]
}

View file

@ -1,37 +1,43 @@
<script lang="ts"> <script lang="ts">
import QueueSlider from "$lib/components/QueueSlider.svelte" import QueueSlider from "$lib/components/QueueSlider.svelte"
import { type Song } from "$lib/types"
import { onMount } from "svelte"
import type { FetchError } from "$lib/types"
import { getQueueSongs, triggerPlayNext } from "$lib/utils.js"
let songs = $state([ let { data } = $props()
{
name: "Cisco PT - Cantarex",
image: "https://s2.qwant.com/thumbr/474x474/5/9/bcbd0c0aeb1838f6916bf452c557251d7be985a13449e49fccb567a3374d4e/OIP.pmqEiKWv47zViDGgPgbbQAHaHa.jpg?u=https%3A%2F%2Ftse.mm.bing.net%2Fth%2Fid%2FOIP.pmqEiKWv47zViDGgPgbbQAHaHa%3Fr%3D0%26pid%3DApi&q=0&b=1&p=0&a=0",
points: 0,
},
{
name: "Io e i miei banchi - Paul Ham",
image: "https://i.prcdn.co/img?regionKey=RbtvKb5E1Cv4j1VWm2uGrw%3D%3D",
points: 0,
},
{
name: "Ragatthi - Bersatetthi",
image: "https://s2.qwant.com/thumbr/474x474/5/9/bcbd0c0aeb1838f6916bf452c557251d7be985a13449e49fccb567a3374d4e/OIP.pmqEiKWv47zViDGgPgbbQAHaHa.jpg?u=https%3A%2F%2Ftse.mm.bing.net%2Fth%2Fid%2FOIP.pmqEiKWv47zViDGgPgbbQAHaHa%3Fr%3D0%26pid%3DApi&q=0&b=1&p=0&a=0",
points: 0,
},
{
name: "Quarta",
image: "https://s2.qwant.com/thumbr/474x474/5/9/bcbd0c0aeb1838f6916bf452c557251d7be985a13449e49fccb567a3374d4e/OIP.pmqEiKWv47zViDGgPgbbQAHaHa.jpg?u=https%3A%2F%2Ftse.mm.bing.net%2Fth%2Fid%2FOIP.pmqEiKWv47zViDGgPgbbQAHaHa%3Fr%3D0%26pid%3DApi&q=0&b=1&p=0&a=0",
points: 0,
},
{
name: "Quinta",
image: "https://s2.qwant.com/thumbr/474x474/5/9/bcbd0c0aeb1838f6916bf452c557251d7be985a13449e49fccb567a3374d4e/OIP.pmqEiKWv47zViDGgPgbbQAHaHa.jpg?u=https%3A%2F%2Ftse.mm.bing.net%2Fth%2Fid%2FOIP.pmqEiKWv47zViDGgPgbbQAHaHa%3Fr%3D0%26pid%3DApi&q=0&b=1&p=0&a=0",
points: 0,
},
])
let playing = $state(1) let queueSongs = $state<Song[]>([])
let playingIndex = $state<number>()
let returnError = $state<FetchError | null>()
onMount(async () => {
let songs, index
;[returnError, songs, index] = await getQueueSongs(data.roomId)
queueSongs = songs
playingIndex = index
})
$effect(() => {
$inspect(queueSongs)
})
async function playNext() {
let songs, index
;[returnError, songs, index] = await triggerPlayNext(data.roomId)
if (returnError) return
if (songs.length != 0) queueSongs = songs
playingIndex = index
}
</script> </script>
{returnError}
<div class="flex w-full flex-col items-center justify-center p-4 lg:p-10"> <div class="flex w-full flex-col items-center justify-center p-4 lg:p-10">
<QueueSlider {songs} {playing} /> <QueueSlider {queueSongs} {playingIndex} />
<button onclick={playNext}>Next</button>
</div> </div>

View file

@ -0,0 +1,11 @@
import { error } from "@sveltejs/kit"
import type { PageLoad } from "../../room/[id]/$types"
export const load: PageLoad = function ({ params }) {
if (params.id) {
return {
roomId: params.id,
}
}
error(400, "Please provide a room id")
}

View file

@ -2,73 +2,56 @@
import QueueSlider from "$lib/components/QueueSlider.svelte" import QueueSlider from "$lib/components/QueueSlider.svelte"
import SuggestionInput from "$lib/components/SuggestionInput.svelte" import SuggestionInput from "$lib/components/SuggestionInput.svelte"
import Error from "$lib/components/Error.svelte" import Error from "$lib/components/Error.svelte"
import { parseSong, parseSuggestion, type Suggestion, type Song } from "$lib/types.js" import { type Suggestion, type Song } from "$lib/types.js"
import { onMount } from "svelte" import { onMount } from "svelte"
import SuggestionList from "$lib/components/SuggestionList.svelte" import SuggestionList from "$lib/components/SuggestionList.svelte"
import { getQueueSongs, getSuggestions, joinRoom } from "$lib/utils.js"
import type { FetchError } from "$lib/types.js"
import { io } from "socket.io-client" import { io } from "socket.io-client"
let { data } = $props() let { data } = $props()
let songs = $state<Song[]>([]) let queueSongs = $state<Song[]>([])
let playing = $state(0) let playingIndex = $state(0)
let suggestions = $state<Suggestion[]>([]) let suggestions = $state<Suggestion[]>([])
let error = $state({ code: 0, message: "" }) let returnError = $state<FetchError | null>()
let wsUrl = ""
onMount(async () => { onMount(async () => {
// Join the room // Join the room
let resp = await fetch("/api/join?room=" + data.roomId)
if (resp.status != 200) { let sugg, queue, index
error = { code: 400, message: "Failed to join the room. Maybe wrong code or location?" } ;[returnError, wsUrl] = await joinRoom(data.roomId)
if (returnError) {
return return
} }
;[returnError, sugg] = await getSuggestions(data.roomId)
if (returnError) return
// Setup websocket connection // Setup websocket connection
let socket = io("/", { path: "/ws" }) let socket = io("/", { path: "/ws" })
await socket.emitWithAck("join_room", { id: data.roomId }) await socket.emitWithAck("join_room", { id: data.roomId })
;[returnError, queue, index] = await getQueueSongs(data.roomId)
// Get room suggestions if (returnError) {
resp = await fetch("/api/room/suggestions?room=" + data.roomId)
if (resp.status != 200) {
error = { code: 500, message: "Failed to retrive suggestions" }
return return
} }
let json = await resp.json() queueSongs = queue
suggestions = sugg
json["songs"].forEach(async (i: any) => { playingIndex = index
suggestions.push(await parseSuggestion(i))
})
suggestions = suggestions.sort((a, b) => {
return a.upvote - b.upvote
})
// Get the room queue
resp = await fetch("/api/queue?room=" + data.roomId)
if (resp.status != 200) {
error = { code: 404, message: "Room not found" }
return
}
json = await resp.json()
json["queue"].forEach(async (i: any) => {
songs.push(await parseSong(i))
})
console.log(songs)
}) })
</script> </script>
<!-- Check if the room exists --> <!-- Check if the room exists -->
{#if error.code != 0} {#if returnError}
<Error code={error.code} message={error.message} /> <Error {returnError} />
{:else} {:else}
<div class="flex w-full flex-col items-center justify-center p-4 lg:p-10"> <div class="flex w-full flex-col items-center justify-center p-4 lg:p-10">
<QueueSlider {songs} {playing} /> <QueueSlider {queueSongs} {playingIndex} />
<div class="w-full py-6 lg:w-[30vw]"> <div class="w-full py-6 lg:w-[30vw]">
<SuggestionInput roomId={data.roomId} /> <SuggestionInput roomId={data.roomId} />
</div> </div>

View file

@ -0,0 +1,22 @@
import type { Config } from "tailwindcss"
const { iconsPlugin, getIconCollections } = require("@egoist/tailwindcss-icons")
export default {
content: ["./src/**/*.{html,js,svelte,ts}"],
theme: {
extend: {},
},
plugins: [
iconsPlugin({
// Select the icon collections you want to use
// You can also ignore this option to automatically discover all individual icon packages you have installed
// If you install @iconify/json, you should explicitly specify the collections you want to use, like this:
collections: getIconCollections(["lucide"]),
// If you want to use all icons from @iconify/json, you can do this:
// collections: getIconCollections("all"),
// and the more recommended way is to use `dynamicIconsPlugin`, see below.
}),
],
} satisfies Config