Compare commits
5 Commits
51a1c5a77d
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 470b38c7a3 | |||
| ac093d6a51 | |||
| e1e5a8451a | |||
| de495c0f78 | |||
| e87d02b1cb |
3
go.mod
3
go.mod
@@ -1,9 +1,10 @@
|
|||||||
module music-downloader
|
module music-dl
|
||||||
|
|
||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/tetratelabs/wazero v1.11.0 // indirect
|
github.com/tetratelabs/wazero v1.11.0 // indirect
|
||||||
go.senan.xyz/taglib v0.11.1 // 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
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
2
go.sum
2
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=
|
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 h1:S3mO5e3HRRG0Ehw1jLUodYbAJK8TtqdOoNgqkC0D3uU=
|
||||||
go.senan.xyz/taglib v0.11.1/go.mod h1:qyTl978MnGeZ/ny4d/t0ErLXxysA+39X4+SNSCk56Zs=
|
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 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
|||||||
60
main.go
60
main.go
@@ -6,7 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"music-downloader/monochrome"
|
"music-dl/monochrome"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.senan.xyz/taglib"
|
"go.senan.xyz/taglib"
|
||||||
|
"golang.org/x/net/websocket"
|
||||||
)
|
)
|
||||||
|
|
||||||
func sendJSON(w http.ResponseWriter, data any, status int) {
|
func sendJSON(w http.ResponseWriter, data any, status int) {
|
||||||
@@ -71,6 +72,42 @@ func main() {
|
|||||||
|
|
||||||
mux := http.NewServeMux()
|
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) {
|
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
|
||||||
path := r.URL.Path
|
path := r.URL.Path
|
||||||
if 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) {
|
mux.HandleFunc("GET /download-album/{id}", func(w http.ResponseWriter, r *http.Request) {
|
||||||
id, err := strconv.Atoi(r.PathValue("id"))
|
id, err := strconv.Atoi(r.PathValue("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -252,8 +270,8 @@ func main() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
download.Status = DownloadStatusCompleted
|
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
|
download.Status = DownloadStatusCompleted
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0"
|
"react-dom": "^19.2.0",
|
||||||
|
"react-hot-toast": "^2.6.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
|
|||||||
26
web/pnpm-lock.yaml
generated
26
web/pnpm-lock.yaml
generated
@@ -20,6 +20,9 @@ importers:
|
|||||||
react-dom:
|
react-dom:
|
||||||
specifier: ^19.2.0
|
specifier: ^19.2.0
|
||||||
version: 19.2.4(react@19.2.4)
|
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:
|
devDependencies:
|
||||||
'@eslint/js':
|
'@eslint/js':
|
||||||
specifier: ^9.39.1
|
specifier: ^9.39.1
|
||||||
@@ -742,6 +745,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==}
|
resolution: {integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
goober@2.1.18:
|
||||||
|
resolution: {integrity: sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw==}
|
||||||
|
peerDependencies:
|
||||||
|
csstype: ^3.0.10
|
||||||
|
|
||||||
graceful-fs@4.2.11:
|
graceful-fs@4.2.11:
|
||||||
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
||||||
|
|
||||||
@@ -975,6 +983,13 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^19.2.4
|
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:
|
react-refresh@0.18.0:
|
||||||
resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==}
|
resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -1836,6 +1851,10 @@ snapshots:
|
|||||||
|
|
||||||
globals@16.5.0: {}
|
globals@16.5.0: {}
|
||||||
|
|
||||||
|
goober@2.1.18(csstype@3.2.3):
|
||||||
|
dependencies:
|
||||||
|
csstype: 3.2.3
|
||||||
|
|
||||||
graceful-fs@4.2.11: {}
|
graceful-fs@4.2.11: {}
|
||||||
|
|
||||||
has-flag@4.0.0: {}
|
has-flag@4.0.0: {}
|
||||||
@@ -2015,6 +2034,13 @@ snapshots:
|
|||||||
react: 19.2.4
|
react: 19.2.4
|
||||||
scheduler: 0.27.0
|
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-refresh@0.18.0: {}
|
||||||
|
|
||||||
react@19.2.4: {}
|
react@19.2.4: {}
|
||||||
|
|||||||
@@ -1,27 +1,62 @@
|
|||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useSearchQuery, type Album } from "./api/useSearchQuery";
|
import { useSearchQuery, type Album } from "./api/useSearchQuery";
|
||||||
import { useDebouncedValue } from "./hooks/useDebouncedValue";
|
import { useDebouncedValue } from "./hooks/useDebouncedValue";
|
||||||
import { getContrastYIQ } from "./getContrastYIQ";
|
import { getContrastYIQ } from "./getContrastYIQ";
|
||||||
import { useDownloadAlbumMutation } from "./api/useDownloadAlbumMutation";
|
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() {
|
export default function App() {
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const debouncedSearch = useDebouncedValue(search);
|
const debouncedSearch = useDebouncedValue(search);
|
||||||
|
|
||||||
|
const [downloads, setDownloads] = useState<DownloadItem[]>([]);
|
||||||
|
|
||||||
const { data } = useSearchQuery({
|
const { data } = useSearchQuery({
|
||||||
query: debouncedSearch,
|
query: debouncedSearch,
|
||||||
});
|
});
|
||||||
const { data: downloads } = useDownloadsQuery();
|
|
||||||
|
|
||||||
const downloadAlbumMutation = useDownloadAlbumMutation();
|
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) => {
|
const handleDownloadAlbum = (album: Album) => {
|
||||||
downloadAlbumMutation.mutate(
|
downloadAlbumMutation.mutate(
|
||||||
{ albumId: album.id },
|
{ albumId: album.id },
|
||||||
{
|
{
|
||||||
onError(error) {
|
onError(error) {
|
||||||
console.log({ error });
|
toast.error(error.message);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -42,6 +77,7 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<p>{item.album.title}</p>
|
<p>{item.album.title}</p>
|
||||||
|
<p>Status: {formatDownloadStatus(item.status)}</p>
|
||||||
<p>
|
<p>
|
||||||
Downloaded {item.installedFiles}/{item.totalFiles}
|
Downloaded {item.installedFiles}/{item.totalFiles}
|
||||||
</p>
|
</p>
|
||||||
@@ -69,10 +105,17 @@ export default function App() {
|
|||||||
<div
|
<div
|
||||||
key={album.id}
|
key={album.id}
|
||||||
className="flex gap-2 p-2"
|
className="flex gap-2 p-2"
|
||||||
style={{
|
style={
|
||||||
backgroundColor: album.vibrantColor,
|
album.vibrantColor
|
||||||
color: contrastColor,
|
? {
|
||||||
}}
|
backgroundColor: album.vibrantColor,
|
||||||
|
color: contrastColor,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
backgroundColor: "white",
|
||||||
|
color: "black",
|
||||||
|
}
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={`/cover/${album.cover}`}
|
src={`/cover/${album.cover}`}
|
||||||
@@ -90,7 +133,11 @@ export default function App() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="cursor-pointer ml-auto mt-auto py-1 px-2"
|
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)}
|
onClick={() => handleDownloadAlbum(album)}
|
||||||
>
|
>
|
||||||
Download
|
Download
|
||||||
@@ -102,3 +149,14 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formatDownloadStatus = (status: DownloadItem["status"]): string => {
|
||||||
|
switch (status) {
|
||||||
|
case "ADDED":
|
||||||
|
return "added";
|
||||||
|
case "IN_PROGRESS":
|
||||||
|
return "in progress";
|
||||||
|
case "COMPLETED":
|
||||||
|
return "completed";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -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[];
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -13,9 +13,9 @@ export function getContrastYIQ(hexcolor: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Parse RGB values
|
// Parse RGB values
|
||||||
const r = parseInt(hexcolor.substr(0, 2), 16);
|
const r = parseInt(hexcolor.substring(0, 2), 16);
|
||||||
const g = parseInt(hexcolor.substr(2, 2), 16);
|
const g = parseInt(hexcolor.substring(2, 2), 16);
|
||||||
const b = parseInt(hexcolor.substr(4, 2), 16);
|
const b = parseInt(hexcolor.substring(4, 2), 16);
|
||||||
|
|
||||||
// Calculate YIQ brightness
|
// Calculate YIQ brightness
|
||||||
const yiq = (r * 299 + g * 587 + b * 114) / 1000;
|
const yiq = (r * 299 + g * 587 + b * 114) / 1000;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client";
|
|||||||
import "./index.css";
|
import "./index.css";
|
||||||
import App from "./App.tsx";
|
import App from "./App.tsx";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { Toaster } from "react-hot-toast";
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
@@ -15,6 +16,7 @@ const queryClient = new QueryClient({
|
|||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<Toaster />
|
||||||
<App />
|
<App />
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
|
|||||||
Reference in New Issue
Block a user