diff --git a/go.mod b/go.mod index 4c1382a..8c3a8ed 100644 --- a/go.mod +++ b/go.mod @@ -5,5 +5,6 @@ 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 ) diff --git a/go.sum b/go.sum index 904fd24..7da0bda 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/main.go b/main.go index c52cb5d..68a38c9 100644 --- a/main.go +++ b/main.go @@ -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 { diff --git a/web/package.json b/web/package.json index dd596ac..14a010f 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 4af1133..5f589c8 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -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: {} diff --git a/web/src/App.tsx b/web/src/App.tsx index 65c35ac..46109ac 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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([]); + 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() { />

{item.album.title}

+

Status: {formatDownloadStatus(item.status)}

Downloaded {item.installedFiles}/{item.totalFiles}

@@ -102,3 +138,14 @@ export default function App() {
); } + +const formatDownloadStatus = (status: DownloadItem["status"]): string => { + switch (status) { + case "ADDED": + return "added"; + case "IN_PROGRESS": + return "in progress"; + case "COMPLETED": + return "completed"; + } +}; diff --git a/web/src/api/useDownloadsQuery.ts b/web/src/api/useDownloadsQuery.ts deleted file mode 100644 index e966dd1..0000000 --- a/web/src/api/useDownloadsQuery.ts +++ /dev/null @@ -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[]; - }, - }); -}; diff --git a/web/src/main.tsx b/web/src/main.tsx index 5f917f2..d99d678 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -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( + ,