diff --git a/backend/src/app.py b/backend/src/app.py index 304ab3c..247ba5f 100644 --- a/backend/src/app.py +++ b/backend/src/app.py @@ -1,17 +1,17 @@ import uuid from dataclasses import asdict - import dotenv from flask import Flask, Response, jsonify, request from flask_cors import CORS 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 .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() @@ -33,9 +33,9 @@ state.rooms[1000] = Room( pin=None, tags=set(), range_size=100, - songs={"b": (Song(uuid="b", title="title", artist="art", tags=["a", "B"], image_id="img", youtube_id="yt"), 1)}, + songs={}, history=[], - playing=[Song(uuid="", title="", artist="<artist>", tags=[], image_id="<img>", youtube_id="<yt>")], + playing=[], playing_idx=0, ) @@ -110,14 +110,15 @@ def queue_next(): if room.playing_idx >= len(room.playing): ## queue ended + room.renew_queue() + ended = True + else: + ended = False - # room.renew_queue() - data = {"success": True, "ended": True, "index": room.playing_idx, "queue": room.playing} - state.socketio.emit("new_queue", data, to=str(room.id)) + data = {"success": True, "ended": ended, "index": room.playing_idx, "queue": [asdict(s) for s in room.playing]} + state.socketio.emit("queue_update", data, to=str(room.id)) - return data - - return {"success": True, "ended": False, "index": room.playing_idx} + return data @app.post("/api/room/new") @@ -185,8 +186,7 @@ def add_song(): if (song := get_song_by_title_artist(info.title, info.artist)) is None: ## song not found, downolad from YT - if (res := download_song_mp3(info.title, info.artist)) is None: - return error("Cannot get info from YT") + yt_video_id = yt_search_song(info.title, info.artist) ## add in DB song = Song( @@ -195,7 +195,7 @@ def add_song(): artist=info.artist, tags=info.tags, image_id=info.img_id, - youtube_id=res[0], + youtube_id=yt_video_id, ) add_song_in_db(song) @@ -203,7 +203,7 @@ def add_song(): ## add the song in the room if does not exists if song.uuid not in room.songs: 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} @@ -235,7 +235,7 @@ def post_song_vote(): ## update the song 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} @@ -257,5 +257,19 @@ def room_qrcode(): 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__": socketio.run(app, debug=True) diff --git a/backend/src/song_fetch.py b/backend/src/song_fetch.py index 561e18d..ec15485 100644 --- a/backend/src/song_fetch.py +++ b/backend/src/song_fetch.py @@ -59,7 +59,7 @@ def lastfm_query_search(query: str) -> SongInfo: 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 = { "format": "bestaudio", "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: 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"]: if "acodec" in fmt and fmt["acodec"] != "none": - return video_id, fmt["url"] + return fmt["url"] return None diff --git a/frontend/src/lib/components/Error.svelte b/frontend/src/lib/components/Error.svelte index 47e6a19..414657e 100644 --- a/frontend/src/lib/components/Error.svelte +++ b/frontend/src/lib/components/Error.svelte @@ -1,8 +1,10 @@ <script lang="ts"> - let { code, message } = $props() + import type { FetchError } from "$lib/types" + + let { returnError }: { returnError: FetchError } = $props() </script> <div class="flex h-screen w-full flex-col items-center justify-center"> - <h1 class="p-2 text-xl">Error {code}</h1> - <p>{message}</p> + <h1 class="p-2 text-xl">Error {returnError.code}</h1> + <p>{returnError.message}</p> </div> diff --git a/frontend/src/lib/components/QueueSlider.svelte b/frontend/src/lib/components/QueueSlider.svelte index 430a9bc..ec5a647 100644 --- a/frontend/src/lib/components/QueueSlider.svelte +++ b/frontend/src/lib/components/QueueSlider.svelte @@ -1,12 +1,12 @@ <script lang="ts"> import { type Song, createEmptySong } from "$lib/types" - let { songs, playing } = $props() + let { queueSongs, playingIndex } = $props() let displaySongs = $derived<Song[]>([ - playing > 0 && playing < songs.length ? songs[playing - 1] : createEmptySong(), - songs[playing], - playing == songs.length - 1 ? createEmptySong() : songs[playing + 1], + playingIndex > 0 ? queueSongs[playingIndex - 1] : createEmptySong(), + queueSongs[playingIndex], + playingIndex == queueSongs.length - 1 ? createEmptySong() : queueSongs[playingIndex + 1], ]) </script> diff --git a/frontend/src/lib/components/SuggestionInput.svelte b/frontend/src/lib/components/SuggestionInput.svelte index de8e437..09982fd 100644 --- a/frontend/src/lib/components/SuggestionInput.svelte +++ b/frontend/src/lib/components/SuggestionInput.svelte @@ -9,7 +9,11 @@ } </script> -<div class="flex h-full w-full flex-row items-center gap-2 rounded border-2 border-black p-4"> - <input type="text" placeholder="Song & Artist" class="h-full w-3/4 rounded" bind:value={input} /> - <button class="w-1/4 rounded" onclick={sendSong}>Send</button> +<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="font-bold outline-none text-white h-[50px] px-4 w-3/4 rounded" bind:value={input} /> + <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> diff --git a/frontend/src/lib/components/SuggestionList.svelte b/frontend/src/lib/components/SuggestionList.svelte index 16cc08c..9c3d650 100644 --- a/frontend/src/lib/components/SuggestionList.svelte +++ b/frontend/src/lib/components/SuggestionList.svelte @@ -3,29 +3,39 @@ 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" }) } </script> <div class="flex h-full w-full flex-col items-center gap-2 overflow-y-auto"> - {#each suggestions as sug} - <div class="flex h-[80px] w-full flex-row gap-2 rounded border-2 border-black p-2"> + {#if suggestions.length == 0} + <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"> <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 class="flex w-1/4 flex-row items-center justify-center gap-2"> <button + class="grayscale" onclick={() => { - vote(1, sug.uuid) - }}>+1</button + vote(idx, 1, sug.uuid) + }}>👍</button > - <p>{sug.upvote}</p> + <p class="text-white font-semibold">{sug.upvote}</p> <button + class="hover:scale-150 duration-100" onclick={() => { - vote(-1, sug.uuid) - }}>-1</button + vote(idx, -1, sug.uuid) + }}><div class="rotate-180">👍</div></button > </div> </div> diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 3f10d61..7de4960 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -35,3 +35,8 @@ export const parseSuggestion = async function (sugg: any): Promise<Suggestion> { let resp = await SuggestionSchema.parseAsync(sugg) return resp } + +export type FetchError = { + code: number + message: string +} diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts new file mode 100644 index 0000000..1babeb9 --- /dev/null +++ b/frontend/src/lib/utils.ts @@ -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"]] +} diff --git a/frontend/src/routes/admin/[id]/+page.svelte b/frontend/src/routes/admin/[id]/+page.svelte index 7573aa2..55d93a5 100644 --- a/frontend/src/routes/admin/[id]/+page.svelte +++ b/frontend/src/routes/admin/[id]/+page.svelte @@ -1,37 +1,43 @@ <script lang="ts"> 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([ - { - 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 { data } = $props() - 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> +{returnError} + <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> diff --git a/frontend/src/routes/admin/[id]/+page.ts b/frontend/src/routes/admin/[id]/+page.ts new file mode 100644 index 0000000..216a08b --- /dev/null +++ b/frontend/src/routes/admin/[id]/+page.ts @@ -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") +} diff --git a/frontend/src/routes/room/[id]/+page.svelte b/frontend/src/routes/room/[id]/+page.svelte index eb225cd..9d9702b 100644 --- a/frontend/src/routes/room/[id]/+page.svelte +++ b/frontend/src/routes/room/[id]/+page.svelte @@ -2,73 +2,56 @@ import QueueSlider from "$lib/components/QueueSlider.svelte" import SuggestionInput from "$lib/components/SuggestionInput.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 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" let { data } = $props() - let songs = $state<Song[]>([]) - let playing = $state(0) + let queueSongs = $state<Song[]>([]) + let playingIndex = $state(0) let suggestions = $state<Suggestion[]>([]) - let error = $state({ code: 0, message: "" }) + let returnError = $state<FetchError | null>() + + let wsUrl = "" onMount(async () => { // Join the room - let resp = await fetch("/api/join?room=" + data.roomId) - if (resp.status != 200) { - error = { code: 400, message: "Failed to join the room. Maybe wrong code or location?" } + let sugg, queue, index + ;[returnError, wsUrl] = await joinRoom(data.roomId) + if (returnError) { return } + ;[returnError, sugg] = await getSuggestions(data.roomId) + if (returnError) return + // Setup websocket connection let socket = io("/", { path: "/ws" }) await socket.emitWithAck("join_room", { id: data.roomId }) - - // Get room suggestions - resp = await fetch("/api/room/suggestions?room=" + data.roomId) - - if (resp.status != 200) { - error = { code: 500, message: "Failed to retrive suggestions" } + ;[returnError, queue, index] = await getQueueSongs(data.roomId) + if (returnError) { return } - let json = await resp.json() - - json["songs"].forEach(async (i: any) => { - 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) + queueSongs = queue + suggestions = sugg + playingIndex = index }) </script> <!-- Check if the room exists --> -{#if error.code != 0} - <Error code={error.code} message={error.message} /> +{#if returnError} + <Error {returnError} /> {:else} <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]"> <SuggestionInput roomId={data.roomId} /> </div> diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts new file mode 100644 index 0000000..16951d0 --- /dev/null +++ b/frontend/tailwind.config.ts @@ -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