add websockets to get downloads list

This commit is contained in:
2026-02-20 22:54:26 +03:00
parent e87d02b1cb
commit de495c0f78
8 changed files with 121 additions and 43 deletions

1
go.mod
View File

@@ -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
)

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=

56
main.go
View File

@@ -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 {

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>
@@ -102,3 +138,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

@@ -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>,