Compare commits

...

5 Commits

9 changed files with 143 additions and 54 deletions

3
go.mod
View File

@@ -1,9 +1,10 @@
module music-downloader
module music-dl
go 1.25.0
require (
github.com/tetratelabs/wazero v1.11.0 // indirect
go.senan.xyz/taglib v0.11.1 // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/sys v0.41.0 // indirect
)

2
go.sum
View File

@@ -2,5 +2,7 @@ github.com/tetratelabs/wazero v1.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbw
github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU=
go.senan.xyz/taglib v0.11.1 h1:S3mO5e3HRRG0Ehw1jLUodYbAJK8TtqdOoNgqkC0D3uU=
go.senan.xyz/taglib v0.11.1/go.mod h1:qyTl978MnGeZ/ny4d/t0ErLXxysA+39X4+SNSCk56Zs=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=

60
main.go
View File

@@ -6,7 +6,7 @@ import (
"fmt"
"io"
"log"
"music-downloader/monochrome"
"music-dl/monochrome"
"net/http"
"os"
"path"
@@ -15,6 +15,7 @@ import (
"time"
"go.senan.xyz/taglib"
"golang.org/x/net/websocket"
)
func sendJSON(w http.ResponseWriter, data any, status int) {
@@ -71,6 +72,42 @@ func main() {
mux := http.NewServeMux()
wsServer := websocket.Server{
Handler: func(c *websocket.Conn) {
for {
time.Sleep(1 * time.Second)
type item struct {
ID int `json:"id"`
Album monochrome.AlbumInfo `json:"album"`
Status DownloadStatus `json:"status"`
InstalledFiles int `json:"installedFiles"`
TotalFiles int `json:"totalFiles"`
}
items := []item{}
for id, download := range downloads {
items = append(items, item{
ID: id,
Album: download.Album,
Status: download.Status,
InstalledFiles: download.InstalledFiles,
TotalFiles: download.TotalFiles,
})
}
if err := json.NewEncoder(c).Encode(struct {
Downloads []item `json:"downloads"`
}{
Downloads: items,
}); err != nil {
continue
}
}
},
}
mux.HandleFunc("GET /ws/downloads", wsServer.ServeHTTP)
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
if path == "/" {
@@ -107,25 +144,6 @@ func main() {
}
})
mux.HandleFunc("GET /downloads", func(w http.ResponseWriter, r *http.Request) {
type item struct {
ID int `json:"id"`
Album monochrome.AlbumInfo `json:"album"`
InstalledFiles int `json:"installedFiles"`
TotalFiles int `json:"totalFiles"`
}
items := []item{}
for id, download := range downloads {
items = append(items, item{
ID: id,
Album: download.Album,
InstalledFiles: download.InstalledFiles,
TotalFiles: download.TotalFiles,
})
}
sendJSON(w, items, 200)
})
mux.HandleFunc("GET /download-album/{id}", func(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
@@ -252,8 +270,8 @@ func main() {
})
}
download.Status = DownloadStatusCompleted
wg.Wait()
download.Status = DownloadStatusCompleted
}()
}
}

View File

@@ -12,7 +12,8 @@
"dependencies": {
"@tanstack/react-query": "^5.90.21",
"react": "^19.2.0",
"react-dom": "^19.2.0"
"react-dom": "^19.2.0",
"react-hot-toast": "^2.6.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",

26
web/pnpm-lock.yaml generated
View File

@@ -20,6 +20,9 @@ importers:
react-dom:
specifier: ^19.2.0
version: 19.2.4(react@19.2.4)
react-hot-toast:
specifier: ^2.6.0
version: 2.6.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
devDependencies:
'@eslint/js':
specifier: ^9.39.1
@@ -742,6 +745,11 @@ packages:
resolution: {integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==}
engines: {node: '>=18'}
goober@2.1.18:
resolution: {integrity: sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw==}
peerDependencies:
csstype: ^3.0.10
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
@@ -975,6 +983,13 @@ packages:
peerDependencies:
react: ^19.2.4
react-hot-toast@2.6.0:
resolution: {integrity: sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==}
engines: {node: '>=10'}
peerDependencies:
react: '>=16'
react-dom: '>=16'
react-refresh@0.18.0:
resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==}
engines: {node: '>=0.10.0'}
@@ -1836,6 +1851,10 @@ snapshots:
globals@16.5.0: {}
goober@2.1.18(csstype@3.2.3):
dependencies:
csstype: 3.2.3
graceful-fs@4.2.11: {}
has-flag@4.0.0: {}
@@ -2015,6 +2034,13 @@ snapshots:
react: 19.2.4
scheduler: 0.27.0
react-hot-toast@2.6.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
csstype: 3.2.3
goober: 2.1.18(csstype@3.2.3)
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
react-refresh@0.18.0: {}
react@19.2.4: {}

View File

@@ -1,27 +1,62 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { useSearchQuery, type Album } from "./api/useSearchQuery";
import { useDebouncedValue } from "./hooks/useDebouncedValue";
import { getContrastYIQ } from "./getContrastYIQ";
import { useDownloadAlbumMutation } from "./api/useDownloadAlbumMutation";
import { useDownloadsQuery } from "./api/useDownloadsQuery";
import toast from "react-hot-toast";
type DownloadItem = {
id: number;
status: "ADDED" | "IN_PROGRESS" | "COMPLETED";
album: Album;
installedFiles: number;
totalFiles: number;
};
export default function App() {
const [search, setSearch] = useState("");
const debouncedSearch = useDebouncedValue(search);
const [downloads, setDownloads] = useState<DownloadItem[]>([]);
const { data } = useSearchQuery({
query: debouncedSearch,
});
const { data: downloads } = useDownloadsQuery();
const downloadAlbumMutation = useDownloadAlbumMutation();
useEffect(() => {
const ws = new WebSocket("/ws/downloads");
ws.onopen = () => {
console.log("ws connection opened");
};
ws.onclose = () => {
console.log("ws connection closed");
};
ws.onmessage = (e) => {
const data = JSON.parse(e.data) as { downloads: DownloadItem[] };
const items = [...data.downloads].sort((a, b) => {
if (a.status === "COMPLETED" && b.status !== "COMPLETED") {
return -1;
}
if (a.status !== "COMPLETED" && b.status === "COMPLETED") {
return 1;
}
return a.album.title.localeCompare(b.album.title);
});
setDownloads(items);
};
}, []);
const handleDownloadAlbum = (album: Album) => {
downloadAlbumMutation.mutate(
{ albumId: album.id },
{
onError(error) {
console.log({ error });
toast.error(error.message);
},
},
);
@@ -42,6 +77,7 @@ export default function App() {
/>
<div>
<p>{item.album.title}</p>
<p>Status: {formatDownloadStatus(item.status)}</p>
<p>
Downloaded {item.installedFiles}/{item.totalFiles}
</p>
@@ -69,10 +105,17 @@ export default function App() {
<div
key={album.id}
className="flex gap-2 p-2"
style={{
style={
album.vibrantColor
? {
backgroundColor: album.vibrantColor,
color: contrastColor,
}}
}
: {
backgroundColor: "white",
color: "black",
}
}
>
<img
src={`/cover/${album.cover}`}
@@ -90,7 +133,11 @@ export default function App() {
<button
type="button"
className="cursor-pointer ml-auto mt-auto py-1 px-2"
style={{ backgroundColor: oppositeColor, color: contrastColor }}
style={
album.vibrantColor
? { backgroundColor: oppositeColor, color: contrastColor }
: { backgroundColor: "black", color: "white" }
}
onClick={() => handleDownloadAlbum(album)}
>
Download
@@ -102,3 +149,14 @@ export default function App() {
</div>
);
}
const formatDownloadStatus = (status: DownloadItem["status"]): string => {
switch (status) {
case "ADDED":
return "added";
case "IN_PROGRESS":
return "in progress";
case "COMPLETED":
return "completed";
}
};

View File

@@ -1,19 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import type { Album } from "./useSearchQuery";
export type DownloadItem = {
id: number;
album: Album;
installedFiles: number;
totalFiles: number;
};
export const useDownloadsQuery = () => {
return useQuery({
queryKey: ["downloads"],
queryFn: async () => {
const resp = await fetch("/downloads");
return (await resp.json()) as DownloadItem[];
},
});
};

View File

@@ -13,9 +13,9 @@ export function getContrastYIQ(hexcolor: string) {
}
// Parse RGB values
const r = parseInt(hexcolor.substr(0, 2), 16);
const g = parseInt(hexcolor.substr(2, 2), 16);
const b = parseInt(hexcolor.substr(4, 2), 16);
const r = parseInt(hexcolor.substring(0, 2), 16);
const g = parseInt(hexcolor.substring(2, 2), 16);
const b = parseInt(hexcolor.substring(4, 2), 16);
// Calculate YIQ brightness
const yiq = (r * 299 + g * 587 + b * 114) / 1000;

View File

@@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App.tsx";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Toaster } from "react-hot-toast";
const queryClient = new QueryClient({
defaultOptions: {
@@ -15,6 +16,7 @@ const queryClient = new QueryClient({
createRoot(document.getElementById("root")!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<Toaster />
<App />
</QueryClientProvider>
</StrictMode>,