add web app & add concurrency

This commit is contained in:
2026-02-20 20:22:06 +03:00
parent b42a556ec5
commit 1f65301a05
19 changed files with 2732 additions and 97 deletions

3
Justfile Normal file
View File

@@ -0,0 +1,3 @@
dev:
cd web && pnpm run build
go run .

285
main.go
View File

@@ -1,6 +1,7 @@
package main
import (
"embed"
"encoding/json"
"fmt"
"io"
@@ -11,6 +12,7 @@ import (
"path"
"strconv"
"sync"
"time"
"go.senan.xyz/taglib"
)
@@ -23,6 +25,40 @@ func sendJSON(w http.ResponseWriter, data any, status int) {
}
}
func sendError(w http.ResponseWriter, msg string, err error, status int) {
m := msg
if err != nil {
m = fmt.Sprintf("%s: %v", msg, err)
}
sendJSON(w, struct {
Error string `json:"error"`
}{
Error: m,
}, status)
}
//go:embed web/dist/*
var webDist embed.FS
type DownloadStatus = string
var (
DownloadStatusAdded = "ADDED"
DownloadStatusInProgress = "IN_PROGRESS"
DownloadStatusCompleted = "COMPLETED"
)
type DownloadItem struct {
sync.Mutex
Status DownloadStatus
Album monochrome.AlbumInfo
InstalledFiles int
TotalFiles int
}
var downloads = map[int]*DownloadItem{}
func main() {
mClient := monochrome.NewClient(monochrome.ClientConfig{
ApiURL: "https://api.monochrome.tf",
@@ -30,134 +66,189 @@ func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
if path == "/" {
path = "/index.html"
}
filePath := "web/dist" + path
http.ServeFileFS(w, r, webDist, filePath)
})
mux.HandleFunc("GET /search", func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query().Get("q")
results, err := mClient.SearchAlbum(q)
if err != nil {
http.Error(w, err.Error(), 404)
sendError(w, "failed to find albums", err, 404)
return
}
sendJSON(w, results, 200)
})
mux.HandleFunc("GET /cover/{id}", func(w http.ResponseWriter, r *http.Request) {
coverImg, err := mClient.AlbumCoverImage(r.PathValue("id"))
if err != nil {
sendError(w, "failed to download album cover", err, 500)
return
}
w.Header().Set("Content-Type", "image/jpeg")
w.WriteHeader(200)
if _, err := w.Write(coverImg); err != nil {
sendError(w, "failed to send response", err, 500)
return
}
})
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 {
http.Error(w, err.Error(), 500)
sendError(w, "failed to parse id from request url", err, 500)
return
}
album, err := mClient.AlbumInfo(id)
if err != nil {
http.Error(w, err.Error(), 404)
sendError(w, "failed to get album info", err, 500)
return
}
if album == nil {
http.Error(w, "album not found for some reason", 404)
sendError(w, "album not found", nil, 404)
return
}
albumCoverImg, err := mClient.AlbumCoverImage(album.CoverID)
if err != nil {
log.Printf("failed to download album cover: %v\n", err)
downloads[album.ID] = &DownloadItem{
Status: DownloadStatusAdded,
Album: *album,
InstalledFiles: 0,
TotalFiles: album.NumberOfTracks,
}
if err := os.Mkdir("./Music/"+album.Title, 0777); err != nil {
http.Error(w, fmt.Sprintf("failed to create album directory: %v", err), 500)
return
}
type Response struct {
sync.Mutex
count int
}
response := new(Response)
var wg sync.WaitGroup
for _, track := range album.Items {
wg.Go(func() {
info, err := mClient.TrackInfo(track.Item.ID, track.Item.AudioQuality)
if err != nil {
log.Printf("failed to get track info: %v\n", err)
return
}
manifest, err := mClient.DecodeManifest(info.Manifest)
if err != nil {
log.Printf("failed to decode track manifest: %v\n", err)
return
}
if manifest.EncryptionType != monochrome.TrackManifestEncryptionNone {
log.Println("file is encrypted; can't download")
return
}
if len(manifest.URLs) == 0 {
log.Println("track manifest doesn't have urls array")
return
}
resp, err := http.Get(manifest.URLs[0])
if err != nil {
log.Printf("failed to download track data: %v\n", err)
return
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
log.Printf("failed to read track data: %v\n", err)
return
}
fPath := path.Join("./Music/", album.Title, track.Item.Title+".flac")
if err := os.WriteFile(fPath, data, 0644); err != nil {
log.Printf("failed to save track file: %v\n", err)
return
}
artists := []string{}
for _, artist := range album.Artists {
artists = append(artists, artist.Name)
}
if err := taglib.WriteTags(fPath, map[string][]string{
taglib.Artist: {album.Artist.Name},
taglib.Artists: artists,
taglib.AlbumArtist: artists,
taglib.TrackNumber: {strconv.Itoa(track.Item.TrackNumber)},
taglib.Album: {album.Title},
taglib.Title: {track.Item.Title},
taglib.ReleaseDate: {album.ReleaseDate},
}, 0); err != nil {
log.Printf("failed to add metadata tags to track file: %v\n", err)
}
if err := taglib.WriteImage(fPath, albumCoverImg); err != nil {
log.Printf("failed to add album cover to track metadata: %v\n", err)
}
response.Lock()
response.count++
response.Unlock()
})
}
wg.Wait()
sendJSON(w, struct {
DownloadedFilesCount int `json:"downloadedFilesCount"`
TotalFilesCount int `json:"totalFilesCount"`
}{
DownloadedFilesCount: response.count,
TotalFilesCount: len(album.Items),
}, 200)
Ok bool `json:"ok"`
}{true}, 200)
})
go func() {
for {
time.Sleep(1 * time.Second)
if len(downloads) == 0 {
continue
}
for _, download := range downloads {
go func() {
download.Status = DownloadStatusInProgress
albumCoverImg, err := mClient.AlbumCoverImage(download.Album.CoverID)
if err != nil {
log.Printf("failed to download album cover: %v\n", err)
}
if err := os.Mkdir("./Music/"+download.Album.Title, 0777); err != nil {
return
}
var wg sync.WaitGroup
for _, track := range download.Album.Items {
wg.Go(func() {
info, err := mClient.TrackInfo(track.Item.ID, track.Item.AudioQuality)
if err != nil {
log.Printf("failed to get track info: %v\n", err)
return
}
manifest, err := mClient.DecodeManifest(info.Manifest)
if err != nil {
log.Printf("failed to decode track manifest: %v\n", err)
return
}
if manifest.EncryptionType != monochrome.TrackManifestEncryptionNone {
log.Println("file is encrypted; can't download")
return
}
if len(manifest.URLs) == 0 {
log.Println("track manifest doesn't have urls array")
return
}
resp, err := http.Get(manifest.URLs[0])
if err != nil {
log.Printf("failed to download track data: %v\n", err)
return
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
log.Printf("failed to read track data: %v\n", err)
return
}
fPath := path.Join("./Music/", download.Album.Title, track.Item.Title+".flac")
if err := os.WriteFile(fPath, data, 0644); err != nil {
log.Printf("failed to save track file: %v\n", err)
return
}
artists := []string{}
for _, artist := range download.Album.Artists {
artists = append(artists, artist.Name)
}
if err := taglib.WriteTags(fPath, map[string][]string{
taglib.Artist: {download.Album.Artist.Name},
taglib.Artists: artists,
taglib.AlbumArtist: artists,
taglib.TrackNumber: {strconv.Itoa(track.Item.TrackNumber)},
taglib.Album: {download.Album.Title},
taglib.Title: {track.Item.Title},
taglib.ReleaseDate: {download.Album.ReleaseDate},
}, 0); err != nil {
log.Printf("failed to add metadata tags to track file: %v\n", err)
}
if err := taglib.WriteImage(fPath, albumCoverImg); err != nil {
log.Printf("failed to add album cover to track metadata: %v\n", err)
}
download.Lock()
download.InstalledFiles++
download.Unlock()
})
}
download.Status = DownloadStatusCompleted
wg.Wait()
}()
}
}
}()
log.Println("starting http server")
if err := http.ListenAndServe(":5000", mux); err != nil {
log.Fatalf("failed to start http server: %v\n", err)

24
web/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

23
web/eslint.config.js Normal file
View File

@@ -0,0 +1,23 @@
import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
import { defineConfig, globalIgnores } from "eslint/config";
export default defineConfig([
globalIgnores(["dist"]),
{
files: ["**/*.{ts,tsx}"],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
]);

13
web/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>web</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

39
web/package.json Normal file
View File

@@ -0,0 +1,39 @@
{
"name": "web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@tanstack/react-query": "^5.90.21",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@tailwindcss/vite": "^4.2.0",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"babel-plugin-react-compiler": "^1.0.0",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"tailwindcss": "^4.2.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0",
"vite": "^8.0.0-beta.13"
},
"pnpm": {
"overrides": {
"vite": "^8.0.0-beta.13"
}
}
}

2135
web/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

104
web/src/App.tsx Normal file
View File

@@ -0,0 +1,104 @@
import { 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";
export default function App() {
const [search, setSearch] = useState("");
const debouncedSearch = useDebouncedValue(search);
const { data } = useSearchQuery({
query: debouncedSearch,
});
const { data: downloads } = useDownloadsQuery();
const downloadAlbumMutation = useDownloadAlbumMutation();
const handleDownloadAlbum = (album: Album) => {
downloadAlbumMutation.mutate(
{ albumId: album.id },
{
onError(error) {
console.log({ error });
},
},
);
};
return (
<div className="max-w-300 w-full mx-auto p-2">
<h1 className="text-3xl font-semibold">music-downloader</h1>
<div className="mt-4 flex flex-col gap-2">
{downloads?.map((item) => (
<div key={item.id} className="flex gap-2 p-2">
<img
src={`/cover/${item.album.cover}`}
alt=""
width={64}
height={64}
/>
<div>
<p>{item.album.title}</p>
<p>
Downloaded {item.installedFiles}/{item.totalFiles}
</p>
</div>
</div>
))}
</div>
<div className="mt-4">
<input
type="text"
placeholder="Search album..."
className="w-full p-1 border border-neutral-900"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<div className="mt-2 flex flex-col gap-2">
{data?.map((album) => {
const contrastColor = getContrastYIQ(album.vibrantColor);
const oppositeColor = contrastColor === "black" ? "white" : "black";
return (
<div
key={album.id}
className="flex gap-2 p-2"
style={{
backgroundColor: album.vibrantColor,
color: contrastColor,
}}
>
<img
src={`/cover/${album.cover}`}
alt=""
width={128}
height={128}
/>
<div className="flex flex-col">
<p>{album.title}</p>
<p>{album.releaseDate}</p>
<p>{album.numberOfTracks} tracks</p>
<p>{album.audioQuality} quality</p>
{album.explicit && <p>Explicit</p>}
</div>
<button
type="button"
className="cursor-pointer ml-auto mt-auto py-1 px-2"
style={{ backgroundColor: oppositeColor, color: contrastColor }}
onClick={() => handleDownloadAlbum(album)}
>
Download
</button>
</div>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,14 @@
import { useMutation } from "@tanstack/react-query";
export const useDownloadAlbumMutation = () => {
return useMutation({
mutationFn: async ({ albumId }: { albumId: number }) => {
const resp = await fetch(`/download-album/${albumId}`);
if (!resp.ok) {
const data = (await resp.json()) as { error: string };
throw new Error(data.error);
}
return await resp.json();
},
});
};

View File

@@ -0,0 +1,19 @@
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

@@ -0,0 +1,33 @@
import { useQuery } from "@tanstack/react-query";
export type Album = {
id: number;
title: string;
duration: number;
numberOfTracks: number;
releaseDate: string;
type: "ALBUM";
url: string;
cover: string;
vibrantColor: string;
explicit: boolean;
audioQuality: "LOSSLESS" | "LOW";
};
export type SearchQueryParams = {
query: string;
};
export const useSearchQuery = (params: SearchQueryParams) => {
return useQuery({
queryKey: ["search", params],
enabled: !!params.query,
queryFn: async () => {
const q = new URLSearchParams();
q.set("q", params.query);
const resp = await fetch(`/search?${q.toString()}`);
return (await resp.json()) as Album[];
},
});
};

25
web/src/getContrastYIQ.ts Normal file
View File

@@ -0,0 +1,25 @@
export function getContrastYIQ(hexcolor: string) {
// Remove leading # if present
if (hexcolor.startsWith("#")) {
hexcolor = hexcolor.slice(1);
}
// Handle 3-digit hex codes (e.g., #abc → #aabbcc)
if (hexcolor.length === 3) {
hexcolor = hexcolor
.split("")
.map((c) => c + c)
.join("");
}
// 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);
// Calculate YIQ brightness
const yiq = (r * 299 + g * 587 + b * 114) / 1000;
// Return black or white based on contrast
return yiq >= 128 ? "black" : "white";
}

View File

@@ -0,0 +1,14 @@
import { useState, useEffect } from "react";
export function useDebouncedValue<T>(value: T, wait: number = 300): T {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value);
}, wait);
return () => {
clearTimeout(timer);
};
}, [value, wait]);
return debouncedValue;
}

1
web/src/index.css Normal file
View File

@@ -0,0 +1 @@
@import "tailwindcss";

21
web/src/main.tsx Normal file
View File

@@ -0,0 +1,21 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App.tsx";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
createRoot(document.getElementById("root")!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</StrictMode>,
);

28
web/tsconfig.app.json Normal file
View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
web/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

26
web/tsconfig.node.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

15
web/vite.config.ts Normal file
View File

@@ -0,0 +1,15 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
// https://vite.dev/config/
export default defineConfig({
plugins: [
tailwindcss(),
react({
babel: {
plugins: [["babel-plugin-react-compiler"]],
},
}),
],
});