commit 9d7ef2a4d1bf71ea765d41cfb1051fc375aeeb7a Author: Daniil Tsivinsky Date: Fri Feb 20 17:38:15 2026 +0300 init diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..45d6f01 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module music-downloader + +go 1.25.0 diff --git a/main.go b/main.go new file mode 100644 index 0000000..bbea59b --- /dev/null +++ b/main.go @@ -0,0 +1,136 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "log" + "music-downloader/monochrome" + "net/http" + "os" + "path" + "strconv" + "sync" +) + +func sendJSON(w http.ResponseWriter, data any, status int) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if err := json.NewEncoder(w).Encode(data); err != nil { + http.Error(w, err.Error(), 500) + } +} + +func main() { + mClient := monochrome.NewClient(monochrome.ClientConfig{ + ApiURL: "https://api.monochrome.tf", + }) + + mux := http.NewServeMux() + + 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) + return + } + + sendJSON(w, results, 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) + return + } + + album, err := mClient.AlbumInfo(id) + if err != nil { + http.Error(w, err.Error(), 404) + return + } + + if album == nil { + http.Error(w, "album not found for some reason", 404) + return + } + + 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 + } + + if err := os.WriteFile(path.Join("./music/", album.Title, track.Item.Title+".flac"), data, 0644); err != nil { + log.Printf("failed to save track file: %v\n", err) + return + } + + 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) + }) + + log.Println("starting http server") + if err := http.ListenAndServe(":5000", mux); err != nil { + log.Fatalf("failed to start http server: %v\n", err) + } +} diff --git a/monochrome/album.go b/monochrome/album.go new file mode 100644 index 0000000..59dca65 --- /dev/null +++ b/monochrome/album.go @@ -0,0 +1,62 @@ +package monochrome + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "strconv" +) + +type AlbumTrack struct { + Type string `json:"type"` + Item struct { + ID int `json:"id"` + Title string `json:"title"` + Duration int `json:"duration"` + TrackNumber int `json:"trackNumber"` + Explicit bool `json:"explicit"` + AudioQuality AudioQuality `json:"audioQuality"` + } `json:"item"` +} + +type AlbumInfo struct { + ID int `json:"id"` + Title string `json:"title"` + Duration int `json:"duration"` + NumberOfTracks int `json:"numberOfTracks"` + ReleaseDate string `json:"releaseDate"` + Type SearchItemType `json:"type"` + CoverID string `json:"cover"` + VibrantColor string `json:"vibrantColor"` + Explicit bool `json:"explicit"` + Items []AlbumTrack `json:"items"` +} + +type AlbumInfoResponse struct { + Data AlbumInfo `json:"data"` +} + +func (c *Client) AlbumInfo(id int) (*AlbumInfo, error) { + req, err := http.NewRequest("GET", c.config.ApiURL+"/album", nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %v", err) + } + + params := url.Values{} + params.Set("id", strconv.Itoa(id)) + req.URL.RawQuery = params.Encode() + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to send request: %v", err) + } + defer resp.Body.Close() + + response := AlbumInfoResponse{} + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + return nil, fmt.Errorf("failed to decode json response: %v", err) + } + + return &response.Data, nil +} diff --git a/monochrome/client.go b/monochrome/client.go new file mode 100644 index 0000000..44e4d14 --- /dev/null +++ b/monochrome/client.go @@ -0,0 +1,15 @@ +package monochrome + +type ClientConfig struct { + ApiURL string +} + +type Client struct { + config ClientConfig +} + +func NewClient(config ClientConfig) *Client { + return &Client{ + config: config, + } +} diff --git a/monochrome/search.go b/monochrome/search.go new file mode 100644 index 0000000..eda284a --- /dev/null +++ b/monochrome/search.go @@ -0,0 +1,68 @@ +package monochrome + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" +) + +type SearchItemType = string + +var ( + SearchItemAlbum SearchItemType = "ALBUM" + SearchItemSingle SearchItemType = "SINGLE" +) + +type AudioQuality = string + +var ( + AudioQualityLossless AudioQuality = "LOSSLESS" + AudioQualityLow AudioQuality = "LOW" +) + +type SearchAlbum struct { + ID int `json:"id"` + Title string `json:"title"` + Duration int `json:"duration"` + NumberOfTracks int `json:"numberOfTracks"` + ReleaseDate string `json:"releaseDate"` + Type SearchItemType `json:"type"` + URL string `json:"url"` + CoverID string `json:"cover"` + VibrantColor string `json:"vibrantColor"` + Explicit bool `json:"explicit"` + AudioQuality AudioQuality `json:"audioQuality"` +} + +type SearchResponse struct { + Data struct { + Albums struct { + Items []SearchAlbum `json:"items"` + } `json:"albums"` + } `json:"data"` +} + +func (c *Client) SearchAlbum(q string) ([]SearchAlbum, error) { + req, err := http.NewRequest("GET", c.config.ApiURL+"/search", nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %v", err) + } + + params := url.Values{} + params.Set("al", q) + req.URL.RawQuery = params.Encode() + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to send request: %v", err) + } + defer resp.Body.Close() + + response := SearchResponse{} + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + return nil, fmt.Errorf("failed to decode json response: %v", err) + } + + return response.Data.Albums.Items, nil +} diff --git a/monochrome/tracks.go b/monochrome/tracks.go new file mode 100644 index 0000000..7abae79 --- /dev/null +++ b/monochrome/tracks.go @@ -0,0 +1,72 @@ +package monochrome + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strconv" +) + +type TrackInfo struct { + TrackId int `json:"trackId"` + Manifest string `json:"manifest"` +} + +type TrackInfoResponse struct { + Data TrackInfo `json:"data"` +} + +func (c *Client) TrackInfo(id int, quality AudioQuality) (*TrackInfo, error) { + req, err := http.NewRequest("GET", c.config.ApiURL+"/track", nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %v", err) + } + + params := url.Values{} + params.Set("id", strconv.Itoa(id)) + params.Set("quality", quality) + req.URL.RawQuery = params.Encode() + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to send request: %v", err) + } + defer resp.Body.Close() + + response := TrackInfoResponse{} + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + return nil, fmt.Errorf("failed to decode json response: %v", err) + } + + return &response.Data, nil +} + +type TrackManifestEncryption = string + +var ( + TrackManifestEncryptionNone TrackManifestEncryption = "NONE" +) + +type TrackManifest struct { + MimeType string `json:"mimeType"` + Codecs string `json:"codecs"` + EncryptionType TrackManifestEncryption `json:"encryptionType"` + URLs []string `json:"urls"` +} + +func (c *Client) DecodeManifest(manifest string) (*TrackManifest, error) { + m, err := base64.StdEncoding.DecodeString(manifest) + if err != nil { + return nil, fmt.Errorf("failed to decode track manifest: %v", err) + } + + mData := &TrackManifest{} + if err := json.NewDecoder(bytes.NewReader(m)).Decode(mData); err != nil { + return nil, fmt.Errorf("failed to decode track manifest: %v", err) + } + + return mData, nil +}