diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
index e6b7917..bbbe999 100644
--- a/.github/FUNDING.yml
+++ b/.github/FUNDING.yml
@@ -1,2 +1,3 @@
github: afkarxyz
ko_fi: afkarxyz
+buy_me_a_coffee: afkarxyz
diff --git a/README.md b/README.md
index ba2ec22..d17d0dc 100644
--- a/README.md
+++ b/README.md
@@ -2,8 +2,6 @@
[](https://t.me/spotiflac)
[](https://t.me/spotiflac_chat)
-
-

@@ -78,7 +76,8 @@ _If this software is useful and brings you value,
consider supporting the project by buying me a coffee.
Your support helps keep development going._
-[](https://ko-fi.com/afkarxyz)
+[](https://ko-fi.com/afkarxyz)
+[](https://www.buymeacoffee.com/afkarxyz)
## Disclaimer
diff --git a/app.go b/app.go
index bc4f415..5d5d319 100644
--- a/app.go
+++ b/app.go
@@ -128,6 +128,27 @@ func (a *App) GetSpotifyMetadata(req SpotifyMetadataRequest) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(req.Timeout*float64(time.Second)))
defer cancel()
+ settings, err := a.LoadSettings()
+
+ if err == nil && settings != nil {
+ if useAPI, ok := settings["useSpotFetchAPI"].(bool); ok && useAPI {
+ if apiURL, ok := settings["spotFetchAPIUrl"].(string); ok && apiURL != "" {
+
+ data, err := backend.GetSpotifyDataWithAPI(ctx, req.URL, true, apiURL, req.Batch, time.Duration(req.Delay*float64(time.Second)))
+ if err != nil {
+ return "", fmt.Errorf("failed to fetch metadata from API: %v", err)
+ }
+
+ jsonData, err := json.MarshalIndent(data, "", " ")
+ if err != nil {
+ return "", fmt.Errorf("failed to encode response: %v", err)
+ }
+
+ return string(jsonData), nil
+ }
+ }
+ }
+
data, err := backend.GetFilteredSpotifyData(ctx, req.URL, req.Batch, time.Duration(req.Delay*float64(time.Second)))
if err != nil {
return "", fmt.Errorf("failed to fetch metadata: %v", err)
@@ -592,6 +613,76 @@ func (a *App) CancelAllQueuedItems() {
backend.CancelAllQueuedItems()
}
+func (a *App) ExportFailedDownloads() (string, error) {
+ queueInfo := backend.GetDownloadQueue()
+ var failedItems []string
+
+ hasFailed := false
+ for _, item := range queueInfo.Queue {
+ if item.Status == backend.StatusFailed {
+ hasFailed = true
+ break
+ }
+ }
+
+ if !hasFailed {
+ return "No failed downloads to export.", nil
+ }
+
+ failedItems = append(failedItems, fmt.Sprintf("Failed Downloads Report - %s", time.Now().Format("2006-01-02 15:04:05")))
+ failedItems = append(failedItems, strings.Repeat("-", 50))
+ failedItems = append(failedItems, "")
+
+ count := 0
+ for _, item := range queueInfo.Queue {
+ if item.Status == backend.StatusFailed {
+ count++
+ line := fmt.Sprintf("%d. %s - %s", count, item.TrackName, item.ArtistName)
+ if item.AlbumName != "" {
+ line += fmt.Sprintf(" (%s)", item.AlbumName)
+ }
+ failedItems = append(failedItems, line)
+ failedItems = append(failedItems, fmt.Sprintf(" Error: %s", item.ErrorMessage))
+
+ if item.ISRC != "" {
+ failedItems = append(failedItems, fmt.Sprintf(" ID: %s", item.ISRC))
+ if !strings.HasPrefix(item.ISRC, "http") {
+ failedItems = append(failedItems, fmt.Sprintf(" URL: https://open.spotify.com/track/%s", item.ISRC))
+ }
+ }
+ failedItems = append(failedItems, "")
+ }
+ }
+
+ content := strings.Join(failedItems, "\n")
+ defaultFilename := fmt.Sprintf("SpotiFLAC_%s_Failed.txt", time.Now().Format("20060102_150405"))
+
+ path, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{
+ DefaultFilename: defaultFilename,
+ Title: "Export Failed Downloads",
+ Filters: []runtime.FileFilter{
+ {
+ DisplayName: "Text Files (*.txt)",
+ Pattern: "*.txt",
+ },
+ },
+ })
+
+ if err != nil {
+ return "", fmt.Errorf("failed to open save dialog: %v", err)
+ }
+
+ if path == "" {
+ return "Export cancelled", nil
+ }
+
+ if err := os.WriteFile(path, []byte(content), 0644); err != nil {
+ return "", fmt.Errorf("failed to write file: %v", err)
+ }
+
+ return fmt.Sprintf("Successfully exported %d failed downloads to %s", count, path), nil
+}
+
func (a *App) Quit() {
panic("quit")
@@ -1091,12 +1182,15 @@ type CheckFileExistenceResult struct {
ArtistName string `json:"artist_name,omitempty"`
}
-func (a *App) CheckFilesExistence(outputDir string, tracks []CheckFileExistenceRequest) []CheckFileExistenceResult {
+func (a *App) CheckFilesExistence(outputDir string, rootDir string, tracks []CheckFileExistenceRequest) []CheckFileExistenceResult {
if len(tracks) == 0 {
return []CheckFileExistenceResult{}
}
outputDir = backend.NormalizePath(outputDir)
+ if rootDir != "" {
+ rootDir = backend.NormalizePath(rootDir)
+ }
defaultFilenameFormat := "title-artist"
@@ -1107,6 +1201,30 @@ func (a *App) CheckFilesExistence(outputDir string, tracks []CheckFileExistenceR
resultsChan := make(chan result, len(tracks))
+ var rootDirFiles map[string]string
+ rootDirFilesOnce := false
+ getRootDirFiles := func() map[string]string {
+ if rootDirFilesOnce {
+ return rootDirFiles
+ }
+ rootDirFiles = make(map[string]string)
+ if rootDir != "" && rootDir != outputDir {
+ filepath.Walk(rootDir, func(path string, info os.FileInfo, err error) error {
+ if err != nil {
+ return nil
+ }
+ if !info.IsDir() {
+ if strings.EqualFold(filepath.Ext(path), ".flac") || strings.EqualFold(filepath.Ext(path), ".mp3") {
+ rootDirFiles[info.Name()] = path
+ }
+ }
+ return nil
+ })
+ }
+ rootDirFilesOnce = true
+ return rootDirFiles
+ }
+
for i, track := range tracks {
go func(idx int, t CheckFileExistenceRequest) {
res := CheckFileExistenceResult{
@@ -1163,6 +1281,9 @@ func (a *App) CheckFilesExistence(outputDir string, tracks []CheckFileExistenceR
if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 100*1024 {
res.Exists = true
res.FilePath = expectedPath
+ } else {
+
+ res.FilePath = expectedFilename
}
resultsChan <- result{index: idx, result: res}
@@ -1170,9 +1291,39 @@ func (a *App) CheckFilesExistence(outputDir string, tracks []CheckFileExistenceR
}
results := make([]CheckFileExistenceResult, len(tracks))
+ missingIndices := []int{}
+
for i := 0; i < len(tracks); i++ {
r := <-resultsChan
results[r.index] = r.result
+ if !results[r.index].Exists {
+ missingIndices = append(missingIndices, r.index)
+ }
+ }
+
+ if len(missingIndices) > 0 && rootDir != "" {
+ filesMap := getRootDirFiles()
+ if len(filesMap) > 0 {
+ for _, idx := range missingIndices {
+
+ expectedFilename := results[idx].FilePath
+ baseName := filepath.Base(expectedFilename)
+ if path, ok := filesMap[baseName]; ok {
+ results[idx].Exists = true
+ results[idx].FilePath = path
+ } else {
+ results[idx].FilePath = ""
+ }
+ }
+ } else {
+ for _, idx := range missingIndices {
+ results[idx].FilePath = ""
+ }
+ }
+ } else {
+ for _, idx := range missingIndices {
+ results[idx].FilePath = ""
+ }
}
return results
@@ -1245,3 +1396,52 @@ func (a *App) CheckFFmpegInstalled() (bool, error) {
func (a *App) GetOSInfo() (string, error) {
return backend.GetOSInfo()
}
+
+func (a *App) CreateM3U8File(m3u8Name string, outputDir string, filePaths []string) error {
+ if len(filePaths) == 0 {
+ return nil
+ }
+
+ if err := os.MkdirAll(outputDir, 0755); err != nil {
+ return err
+ }
+
+ fnName := m3u8Name
+
+ safeName := backend.SanitizeFilename(fnName)
+ if safeName == "" {
+ safeName = "playlist"
+ }
+
+ m3u8Path := filepath.Join(outputDir, safeName+".m3u8")
+
+ f, err := os.Create(m3u8Path)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+
+ if _, err := f.WriteString("#EXTM3U\n"); err != nil {
+ return err
+ }
+
+ for _, path := range filePaths {
+ if path == "" {
+ continue
+ }
+
+ relPath, err := filepath.Rel(outputDir, path)
+ if err != nil {
+
+ relPath = path
+ }
+
+ relPath = filepath.ToSlash(relPath)
+
+ if _, err := f.WriteString(relPath + "\n"); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
diff --git a/backend/amazon.go b/backend/amazon.go
index e109cb9..d1ed2de 100644
--- a/backend/amazon.go
+++ b/backend/amazon.go
@@ -8,6 +8,7 @@ import (
"net/http"
"net/url"
"os"
+ "os/exec"
"path/filepath"
"regexp"
"strings"
@@ -25,13 +26,9 @@ type SongLinkResponse struct {
} `json:"linksByPlatform"`
}
-type AfkarXYZResponse struct {
- Success bool `json:"success"`
- Data struct {
- DirectLink string `json:"direct_link"`
- FileName string `json:"file_name"`
- FileSize int64 `json:"file_size"`
- } `json:"data"`
+type AmazonStreamResponse struct {
+ StreamURL string `json:"streamUrl"`
+ DecryptionKey string `json:"decryptionKey"`
}
func NewAmazonDownloader() *AmazonDownloader {
@@ -55,6 +52,7 @@ func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (strin
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
+ req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36")
fmt.Println("Getting Amazon URL...")
@@ -108,13 +106,21 @@ func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (strin
}
func (a *AmazonDownloader) DownloadFromAfkarXYZ(amazonURL, outputDir, quality string) (string, error) {
- apiURL := "https://amazon.afkarxyz.fun/convert?url=" + url.QueryEscape(amazonURL)
+
+ asinRegex := regexp.MustCompile(`(B[0-9A-Z]{9})`)
+ asin := asinRegex.FindString(amazonURL)
+ if asin == "" {
+ return "", fmt.Errorf("failed to extract ASIN from URL: %s", amazonURL)
+ }
+
+ apiURL := fmt.Sprintf("https://amazon.afkarxyz.fun/api/track/%s", asin)
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
return "", err
}
+ req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36")
- fmt.Printf("Fetching from AfkarXYZ...\n")
+ fmt.Printf("Fetching from Amazon API (ASIN: %s)...\n", asin)
resp, err := a.client.Do(req)
if err != nil {
return "", err
@@ -122,27 +128,25 @@ func (a *AmazonDownloader) DownloadFromAfkarXYZ(amazonURL, outputDir, quality st
defer resp.Body.Close()
if resp.StatusCode != 200 {
- return "", fmt.Errorf("AfkarXYZ API returned status %d", resp.StatusCode)
+ return "", fmt.Errorf("Amazon API returned status %d", resp.StatusCode)
}
- bodyBytes, _ := io.ReadAll(resp.Body)
- var apiResp AfkarXYZResponse
+ bodyBytes, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return "", err
+ }
+
+ var apiResp AmazonStreamResponse
if err := json.Unmarshal(bodyBytes, &apiResp); err != nil {
return "", fmt.Errorf("failed to decode response: %w", err)
}
- if !apiResp.Success || apiResp.Data.DirectLink == "" {
- return "", fmt.Errorf("AfkarXYZ failed or no link found")
+ if apiResp.StreamURL == "" {
+ return "", fmt.Errorf("no stream URL found in response")
}
- downloadURL := apiResp.Data.DirectLink
- fileName := apiResp.Data.FileName
- if fileName == "" {
- fileName = "track.flac"
- }
-
- reg := regexp.MustCompile(`[<>:"/\\|?*]`)
- fileName = reg.ReplaceAllString(fileName, "")
+ downloadURL := apiResp.StreamURL
+ fileName := fmt.Sprintf("%s.m4a", asin)
filePath := filepath.Join(outputDir, fileName)
out, err := os.Create(filePath)
@@ -152,6 +156,7 @@ func (a *AmazonDownloader) DownloadFromAfkarXYZ(amazonURL, outputDir, quality st
defer out.Close()
dlReq, _ := http.NewRequest("GET", downloadURL, nil)
+ dlReq.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36")
dlResp, err := a.client.Do(dlReq)
if err != nil {
@@ -159,7 +164,7 @@ func (a *AmazonDownloader) DownloadFromAfkarXYZ(amazonURL, outputDir, quality st
}
defer dlResp.Body.Close()
- fmt.Printf("Downloading from AfkarXYZ: %s\n", fileName)
+ fmt.Printf("Downloading track: %s\n", fileName)
pw := NewProgressWriter(out)
_, err = io.Copy(pw, dlResp.Body)
if err != nil {
@@ -169,6 +174,86 @@ func (a *AmazonDownloader) DownloadFromAfkarXYZ(amazonURL, outputDir, quality st
}
fmt.Printf("\rDownloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024))
+
+ if apiResp.DecryptionKey != "" {
+ fmt.Printf("Decrypting file...\n")
+
+ ffprobePath, err := GetFFprobePath()
+ var codec string
+ if err == nil {
+ cmdProbe := exec.Command(ffprobePath,
+ "-v", "quiet",
+ "-select_streams", "a:0",
+ "-show_entries", "stream=codec_name",
+ "-of", "default=noprint_wrappers=1:nokey=1",
+ filePath,
+ )
+ setHideWindow(cmdProbe)
+ codecOutput, _ := cmdProbe.Output()
+ codec = strings.TrimSpace(string(codecOutput))
+ fmt.Printf("Detected codec: %s\n", codec)
+ }
+
+ targetExt := ".m4a"
+ if codec == "flac" {
+ targetExt = ".flac"
+ }
+
+ decryptedFilename := "dec_" + fileName + targetExt
+
+ if targetExt == ".flac" && strings.HasSuffix(fileName, ".m4a") {
+ decryptedFilename = "dec_" + strings.TrimSuffix(fileName, ".m4a") + ".flac"
+ }
+
+ decryptedPath := filepath.Join(outputDir, decryptedFilename)
+
+ ffmpegPath, err := GetFFmpegPath()
+ if err != nil {
+ return "", fmt.Errorf("ffmpeg not found for decryption: %w", err)
+ }
+
+ if err := ValidateExecutable(ffmpegPath); err != nil {
+ return "", fmt.Errorf("invalid ffmpeg executable: %w", err)
+ }
+
+ key := strings.TrimSpace(apiResp.DecryptionKey)
+
+ cmd := exec.Command(ffmpegPath,
+ "-decryption_key", key,
+ "-i", filePath,
+ "-c", "copy",
+ "-y",
+ decryptedPath,
+ )
+
+ setHideWindow(cmd)
+ output, err := cmd.CombinedOutput()
+ if err != nil {
+
+ outStr := string(output)
+ if len(outStr) > 500 {
+ outStr = outStr[len(outStr)-500:]
+ }
+ return "", fmt.Errorf("ffmpeg decryption failed: %v\nTail Output: %s", err, outStr)
+ }
+
+ if info, err := os.Stat(decryptedPath); err != nil || info.Size() == 0 {
+ return "", fmt.Errorf("decrypted file missing or empty")
+ }
+
+ if err := os.Remove(filePath); err != nil {
+ fmt.Printf("Warning: Failed to remove encrypted file: %v\n", err)
+ }
+
+ finalPath := filepath.Join(outputDir, strings.TrimPrefix(decryptedFilename, "dec_"))
+ if err := os.Rename(decryptedPath, finalPath); err != nil {
+ return "", fmt.Errorf("failed to rename decrypted file: %w", err)
+ }
+ filePath = finalPath
+
+ fmt.Println("Decryption successful")
+ }
+
return filePath, nil
}
@@ -201,6 +286,9 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename
return "", err
}
+ originalFileDir := filepath.Dir(filePath)
+ originalFileBase := strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
+
if spotifyTrackName != "" && spotifyArtistName != "" {
safeArtist := sanitizeFilename(spotifyArtistName)
safeTitle := sanitizeFilename(spotifyTrackName)
@@ -252,7 +340,11 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename
}
}
- newFilename = newFilename + ".flac"
+ ext := filepath.Ext(filePath)
+ if ext == "" {
+ ext = ".flac"
+ }
+ newFilename = newFilename + ext
newFilePath := filepath.Join(outputDir, newFilename)
if err := os.Rename(filePath, newFilePath); err != nil {
@@ -300,12 +392,24 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename
Description: "https://github.com/afkarxyz/SpotiFLAC",
}
- if err := EmbedMetadata(filePath, metadata, coverPath); err != nil {
+ if err := EmbedMetadataToConvertedFile(filePath, metadata, coverPath); err != nil {
fmt.Printf("Warning: Failed to embed metadata: %v\n", err)
} else {
fmt.Println("Metadata embedded successfully")
}
+ if strings.HasSuffix(strings.ToLower(filePath), ".flac") {
+
+ originalM4aPath := filepath.Join(originalFileDir, originalFileBase+".m4a")
+ if _, err := os.Stat(originalM4aPath); err == nil {
+ if err := os.Remove(originalM4aPath); err != nil {
+ fmt.Printf("Warning: Failed to remove M4A file: %v\n", err)
+ } else {
+ fmt.Printf("Cleaned up original M4A file: %s\n", filepath.Base(originalM4aPath))
+ }
+ }
+ }
+
fmt.Println("Done")
fmt.Println("✓ Downloaded successfully from Amazon Music")
return filePath, nil
diff --git a/backend/qobuz.go b/backend/qobuz.go
index 0f023a7..c157b80 100644
--- a/backend/qobuz.go
+++ b/backend/qobuz.go
@@ -145,10 +145,19 @@ func (q *QobuzDownloader) mapJumoQuality(quality string) int {
func (q *QobuzDownloader) DownloadFromJumo(trackID int64, quality string) (string, error) {
formatID := q.mapJumoQuality(quality)
region := "US"
- url := fmt.Sprintf("https://jumo-dl.pages.dev/file?track_id=%d&format_id=%d®ion=%s", trackID, formatID, region)
+ url := fmt.Sprintf("https://jumo-dl.pages.dev/get?track_id=%d&format_id=%d®ion=%s", trackID, formatID, region)
client := &http.Client{Timeout: 30 * time.Second}
- resp, err := client.Get(url)
+
+ req, err := http.NewRequest("GET", url, nil)
+ if err != nil {
+ return "", err
+ }
+
+ req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36")
+ req.Header.Set("Referer", "https://jumo-dl.pages.dev/")
+
+ resp, err := client.Do(req)
if err != nil {
return "", err
}
@@ -163,7 +172,9 @@ func (q *QobuzDownloader) DownloadFromJumo(trackID int64, quality string) (strin
return "", err
}
- var result map[string]interface{}
+ var result struct {
+ URL string `json:"url"`
+ }
if err := json.Unmarshal(body, &result); err != nil {
@@ -173,18 +184,8 @@ func (q *QobuzDownloader) DownloadFromJumo(trackID int64, quality string) (strin
}
}
- if urlVal, ok := result["url"].(string); ok && urlVal != "" {
- return urlVal, nil
- }
-
- if data, ok := result["data"].(map[string]interface{}); ok {
- if urlVal, ok := data["url"].(string); ok && urlVal != "" {
- return urlVal, nil
- }
- }
-
- if linkVal, ok := result["link"].(string); ok && linkVal != "" {
- return linkVal, nil
+ if result.URL != "" {
+ return result.URL, nil
}
return "", fmt.Errorf("URL not found in Jumo response")
@@ -216,6 +217,15 @@ func (q *QobuzDownloader) DownloadFromStandard(apiBase string, trackID int64, qu
return streamResp.URL, nil
}
+ var nestedResp struct {
+ Data struct {
+ URL string `json:"url"`
+ } `json:"data"`
+ }
+ if err := json.Unmarshal(body, &nestedResp); err == nil && nestedResp.Data.URL != "" {
+ return nestedResp.Data.URL, nil
+ }
+
return "", fmt.Errorf("invalid response")
}
diff --git a/backend/spotfetch.go b/backend/spotfetch.go
index 992191a..475237f 100644
--- a/backend/spotfetch.go
+++ b/backend/spotfetch.go
@@ -767,8 +767,57 @@ func FilterTrack(data map[string]interface{}, albumFetchData ...map[string]inter
if discNumber == 0 {
discNumber = 1
}
+
+ maxDiscFromAlbum := 0
+ totalDiscsFromAlbum := 0
+
+ if len(albumFetchData) > 0 && albumFetchData[0] != nil {
+ albumUnion := getMap(getMap(albumFetchData[0], "data"), "albumUnion")
+ if len(albumUnion) > 0 {
+ discsData := getMap(albumUnion, "discs")
+ if len(discsData) > 0 {
+ totalDiscsFromAlbum = int(getFloat64(discsData, "totalCount"))
+ }
+
+ albumTracks := getMap(albumUnion, "tracks")
+ if len(albumTracks) > 0 {
+ albumTrackItems := getSlice(albumTracks, "items")
+ currentTrackID := getString(trackData, "id")
+ for idx, item := range albumTrackItems {
+ itemMap, ok := item.(map[string]interface{})
+ if !ok {
+ continue
+ }
+ trackItem := getMap(itemMap, "track")
+ if len(trackItem) > 0 {
+ dNum := int(getFloat64(trackItem, "discNumber"))
+ if dNum > maxDiscFromAlbum {
+ maxDiscFromAlbum = dNum
+ }
+
+ trackURI := getString(trackItem, "uri")
+ if strings.Contains(trackURI, currentTrackID) || getString(trackItem, "id") == currentTrackID {
+ if dNum > 0 {
+ discNumber = dNum
+ }
+ }
+
+ trackNum := int(getFloat64(trackData, "trackNumber"))
+ itemTrackNum := idx + 1
+ if trackNum == itemTrackNum && dNum > 0 {
+ }
+ }
+ }
+ }
+ }
+ }
+
totalDiscs := 1
- if discInfo["totalDiscs"] != nil {
+ if totalDiscsFromAlbum > 0 {
+ totalDiscs = totalDiscsFromAlbum
+ } else if maxDiscFromAlbum > 0 {
+ totalDiscs = maxDiscFromAlbum
+ } else if discInfo["totalDiscs"] != nil {
totalDiscs = discInfo["totalDiscs"].(int)
}
@@ -878,6 +927,11 @@ func FilterAlbum(data map[string]interface{}) map[string]interface{} {
contentRating := getMap(track, "contentRating")
isExplicit := getString(contentRating, "label") == "EXPLICIT"
+ discNumber := int(getFloat64(track, "discNumber"))
+ if discNumber == 0 {
+ discNumber = 1
+ }
+
trackInfo := map[string]interface{}{
"id": trackID,
"name": getString(track, "name"),
@@ -886,6 +940,7 @@ func FilterAlbum(data map[string]interface{}) map[string]interface{} {
"duration": durationString,
"plays": getString(track, "playcount"),
"is_explicit": isExplicit,
+ "disc_number": discNumber,
}
tracks = append(tracks, trackInfo)
}
@@ -905,6 +960,12 @@ func FilterAlbum(data map[string]interface{}) map[string]interface{} {
albumID = parts[len(parts)-1]
}
+ totalDiscs := 1
+ discsData := getMap(albumData, "discs")
+ if len(discsData) > 0 {
+ totalDiscs = int(getFloat64(discsData, "totalCount"))
+ }
+
filtered := map[string]interface{}{
"id": albumID,
"name": getString(albumData, "name"),
@@ -913,6 +974,9 @@ func FilterAlbum(data map[string]interface{}) map[string]interface{} {
"releaseDate": releaseDate,
"count": len(tracks),
"tracks": tracks,
+ "discs": map[string]interface{}{
+ "totalCount": totalDiscs,
+ },
}
return filtered
@@ -1103,10 +1167,15 @@ func FilterPlaylist(data map[string]interface{}) map[string]interface{} {
contentRating := getMap(trackData, "contentRating")
isExplicit := getString(contentRating, "label") == "EXPLICIT"
+ trackName := getString(trackData, "name")
+ if trackName == "" {
+ continue
+ }
+
trackInfo := map[string]interface{}{
"id": trackID,
"cover": trackCover,
- "title": getString(trackData, "name"),
+ "title": trackName,
"artist": artistsString,
"artistIds": artistIDs,
"plays": rank,
@@ -1116,6 +1185,7 @@ func FilterPlaylist(data map[string]interface{}) map[string]interface{} {
"albumId": albumID,
"duration": durationString,
"is_explicit": isExplicit,
+ "disc_number": int(getFloat64(trackData, "discNumber")),
}
tracks = append(tracks, trackInfo)
}
diff --git a/backend/spotfetch_api.go b/backend/spotfetch_api.go
new file mode 100644
index 0000000..cce1a93
--- /dev/null
+++ b/backend/spotfetch_api.go
@@ -0,0 +1,101 @@
+package backend
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "regexp"
+ "strings"
+ "time"
+)
+
+func GetSpotifyDataWithAPI(ctx context.Context, spotifyURL string, useAPI bool, apiBaseURL string, batch bool, delay time.Duration) (interface{}, error) {
+ if !useAPI || apiBaseURL == "" {
+
+ return GetFilteredSpotifyData(ctx, spotifyURL, batch, delay)
+ }
+
+ spotifyType, id := parseSpotifyURLToTypeAndID(spotifyURL)
+ if spotifyType == "" || id == "" {
+ return nil, fmt.Errorf("invalid Spotify URL: %s", spotifyURL)
+ }
+
+ apiURL := fmt.Sprintf("%s/%s/%s", strings.TrimSuffix(apiBaseURL, "/"), spotifyType, id)
+
+ req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create API request: %w", err)
+ }
+
+ client := &http.Client{
+ Timeout: 30 * time.Second,
+ }
+
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("SpotFetch API request failed: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("SpotFetch API error: HTTP %d", resp.StatusCode)
+ }
+
+ bodyBytes, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read API response: %w", err)
+ }
+
+ var data interface{}
+
+ switch spotifyType {
+ case "track":
+ var trackResp TrackResponse
+ if err := json.Unmarshal(bodyBytes, &trackResp); err != nil {
+ return nil, fmt.Errorf("failed to decode track response: %w", err)
+ }
+ data = trackResp
+ case "album":
+ var albumResp AlbumResponsePayload
+ if err := json.Unmarshal(bodyBytes, &albumResp); err != nil {
+ return nil, fmt.Errorf("failed to decode album response: %w", err)
+ }
+ data = &albumResp
+ case "playlist":
+ var playlistResp PlaylistResponsePayload
+ if err := json.Unmarshal(bodyBytes, &playlistResp); err != nil {
+ return nil, fmt.Errorf("failed to decode playlist response: %w", err)
+ }
+ data = playlistResp
+ case "artist":
+ var artistResp ArtistDiscographyPayload
+ if err := json.Unmarshal(bodyBytes, &artistResp); err != nil {
+ return nil, fmt.Errorf("failed to decode artist response: %w", err)
+ }
+ data = &artistResp
+ default:
+ return nil, fmt.Errorf("unsupported Spotify type: %s", spotifyType)
+ }
+
+ return data, nil
+}
+
+func parseSpotifyURLToTypeAndID(url string) (string, string) {
+
+ if strings.HasPrefix(url, "spotify:") {
+ parts := strings.Split(url, ":")
+ if len(parts) >= 3 {
+ return parts[1], parts[2]
+ }
+ }
+
+ re := regexp.MustCompile(`spotify\.com/(track|album|playlist|artist)/([a-zA-Z0-9]+)`)
+ matches := re.FindStringSubmatch(url)
+ if len(matches) == 3 {
+ return matches[1], matches[2]
+ }
+
+ return "", ""
+}
diff --git a/backend/spotify_metadata.go b/backend/spotify_metadata.go
index c102fe1..fef00a8 100644
--- a/backend/spotify_metadata.go
+++ b/backend/spotify_metadata.go
@@ -210,7 +210,10 @@ type apiAlbumResponse struct {
Cover string `json:"cover"`
ReleaseDate string `json:"releaseDate"`
Count int `json:"count"`
- Tracks []struct {
+ Discs struct {
+ TotalCount int `json:"totalCount"`
+ } `json:"discs"`
+ Tracks []struct {
ID string `json:"id"`
Name string `json:"name"`
Artists string `json:"artists"`
@@ -218,6 +221,7 @@ type apiAlbumResponse struct {
Duration string `json:"duration"`
Plays string `json:"plays"`
IsExplicit bool `json:"is_explicit"`
+ DiscNumber int `json:"disc_number"`
} `json:"tracks"`
}
@@ -245,6 +249,7 @@ type apiPlaylistResponse struct {
AlbumID string `json:"albumId"`
Duration string `json:"duration"`
IsExplicit bool `json:"is_explicit"`
+ DiscNumber int `json:"disc_number"`
} `json:"tracks"`
}
@@ -432,22 +437,45 @@ func (c *SpotifyMetadataClient) fetchTrack(ctx context.Context, trackID string)
}
if albumID != "" {
- albumPayload := map[string]interface{}{
- "variables": map[string]interface{}{
- "uri": fmt.Sprintf("spotify:album:%s", albumID),
- "locale": "",
- "offset": 0,
- "limit": 1,
- },
- "operationName": "getAlbum",
- "extensions": map[string]interface{}{
- "persistedQuery": map[string]interface{}{
- "version": 1,
- "sha256Hash": "b9bfabef66ed756e5e13f68a942deb60bd4125ec1f1be8cc42769dc0259b4b10",
+
+ albumResponse, err := c.fetchAlbumWithClient(ctx, client, albumID)
+ if err == nil && albumResponse != nil {
+
+ albumJSON, _ := json.Marshal(albumResponse)
+ var albumMap map[string]interface{}
+ json.Unmarshal(albumJSON, &albumMap)
+
+ tracksItems := []interface{}{}
+ if albumMap["tracks"] != nil {
+ if trackList, ok := albumMap["tracks"].([]interface{}); ok {
+ for _, t := range trackList {
+ if trackMap, ok := t.(map[string]interface{}); ok {
+ tracksItems = append(tracksItems, map[string]interface{}{
+ "track": map[string]interface{}{
+ "discNumber": trackMap["disc_number"],
+ "id": trackMap["id"],
+ "uri": fmt.Sprintf("spotify:track:%s", trackMap["id"]),
+ },
+ })
+ }
+ }
+ }
+ }
+
+ albumFetchData = map[string]interface{}{
+ "data": map[string]interface{}{
+ "albumUnion": map[string]interface{}{
+ "discs": map[string]interface{}{
+ "totalCount": albumResponse.Discs.TotalCount,
+ },
+ "tracks": map[string]interface{}{
+ "items": tracksItems,
+ "totalCount": albumResponse.Count,
+ },
+ },
},
- },
+ }
}
- albumFetchData, _ = client.Query(albumPayload)
}
}
}
@@ -914,8 +942,8 @@ func (c *SpotifyMetadataClient) formatAlbumData(raw *apiAlbumResponse) (*AlbumRe
ReleaseDate: raw.ReleaseDate,
TrackNumber: trackNumber,
TotalTracks: raw.Count,
- DiscNumber: 1,
- TotalDiscs: 0,
+ DiscNumber: item.DiscNumber,
+ TotalDiscs: raw.Discs.TotalCount,
ExternalURL: fmt.Sprintf("https://open.spotify.com/track/%s", item.ID),
ISRC: item.ID,
AlbumID: raw.ID,
@@ -974,7 +1002,7 @@ func (c *SpotifyMetadataClient) formatPlaylistData(raw *apiPlaylistResponse) Pla
ReleaseDate: "",
TrackNumber: 0,
TotalTracks: 0,
- DiscNumber: 1,
+ DiscNumber: item.DiscNumber,
TotalDiscs: 0,
ExternalURL: fmt.Sprintf("https://open.spotify.com/track/%s", item.ID),
ISRC: item.ID,
@@ -1094,7 +1122,7 @@ func (c *SpotifyMetadataClient) formatArtistDiscographyData(ctx context.Context,
ReleaseDate: albumData.ReleaseDate,
TrackNumber: trackNumber,
TotalTracks: albumData.Count,
- DiscNumber: 1,
+ DiscNumber: tr.DiscNumber,
ExternalURL: fmt.Sprintf("https://open.spotify.com/track/%s", tr.ID),
ISRC: tr.ID,
AlbumID: albumID,
diff --git a/backend/tidal.go b/backend/tidal.go
index af78d05..b16d894 100644
--- a/backend/tidal.go
+++ b/backend/tidal.go
@@ -101,6 +101,8 @@ func (t *TidalDownloader) GetTidalURLFromSpotify(spotifyTrackID string) (string,
return "", fmt.Errorf("failed to create request: %w", err)
}
+ req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36")
+
fmt.Println("Getting Tidal URL...")
resp, err := t.client.Do(req)
@@ -157,7 +159,15 @@ func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (string,
url := fmt.Sprintf("%s/track/?id=%d&quality=%s", t.apiURL, trackID, quality)
fmt.Printf("Tidal API URL: %s\n", url)
- resp, err := t.client.Get(url)
+ req, err := http.NewRequest("GET", url, nil)
+ if err != nil {
+ fmt.Printf("✗ failed to create request: %v\n", err)
+ return "", fmt.Errorf("failed to create request: %w", err)
+ }
+
+ req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36")
+
+ resp, err := t.client.Do(req)
if err != nil {
fmt.Printf("✗ Tidal API request failed: %v\n", err)
return "", fmt.Errorf("failed to get download URL: %w", err)
@@ -214,7 +224,14 @@ func (t *TidalDownloader) DownloadFile(url, filepath string) error {
return t.DownloadFromManifest(strings.TrimPrefix(url, "MANIFEST:"), filepath)
}
- resp, err := t.client.Get(url)
+ req, err := http.NewRequest("GET", url, nil)
+ if err != nil {
+ return fmt.Errorf("failed to create request: %w", err)
+ }
+
+ req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36")
+
+ resp, err := t.client.Do(req)
if err != nil {
return fmt.Errorf("failed to download file: %w", err)
@@ -244,7 +261,7 @@ func (t *TidalDownloader) DownloadFile(url, filepath string) error {
}
func (t *TidalDownloader) DownloadFromManifest(manifestB64, outputPath string) error {
- directURL, initURL, mediaURLs, err := parseManifest(manifestB64)
+ directURL, initURL, mediaURLs, mimeType, err := parseManifest(manifestB64)
if err != nil {
return fmt.Errorf("failed to parse manifest: %w", err)
}
@@ -253,10 +270,19 @@ func (t *TidalDownloader) DownloadFromManifest(manifestB64, outputPath string) e
Timeout: 120 * time.Second,
}
- if directURL != "" {
+ doRequest := func(url string) (*http.Response, error) {
+ req, err := http.NewRequest("GET", url, nil)
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36")
+ return client.Do(req)
+ }
+
+ if directURL != "" && (strings.Contains(strings.ToLower(mimeType), "flac") || mimeType == "") {
fmt.Println("Downloading file...")
- resp, err := client.Get(directURL)
+ resp, err := doRequest(directURL)
if err != nil {
return fmt.Errorf("failed to download file: %w", err)
}
@@ -283,83 +309,116 @@ func (t *TidalDownloader) DownloadFromManifest(manifestB64, outputPath string) e
return nil
}
- fmt.Printf("Downloading %d segments...\n", len(mediaURLs)+1)
-
tempPath := outputPath + ".m4a.tmp"
- out, err := os.Create(tempPath)
- if err != nil {
- return fmt.Errorf("failed to create temp file: %w", err)
- }
- fmt.Print("Downloading init segment... ")
- resp, err := client.Get(initURL)
- if err != nil {
- out.Close()
- os.Remove(tempPath)
- return fmt.Errorf("failed to download init segment: %w", err)
- }
- if resp.StatusCode != 200 {
- resp.Body.Close()
- out.Close()
- os.Remove(tempPath)
- return fmt.Errorf("init segment download failed with status %d", resp.StatusCode)
- }
- _, err = io.Copy(out, resp.Body)
- resp.Body.Close()
- if err != nil {
- out.Close()
- os.Remove(tempPath)
- return fmt.Errorf("failed to write init segment: %w", err)
- }
- fmt.Println("OK")
+ if directURL != "" {
+ fmt.Printf("Downloading non-FLAC file (%s)...\n", mimeType)
- totalSegments := len(mediaURLs)
- var totalBytes int64
- lastTime := time.Now()
- var lastBytes int64
- for i, mediaURL := range mediaURLs {
- resp, err := client.Get(mediaURL)
+ resp, err := doRequest(directURL)
+ if err != nil {
+ return fmt.Errorf("failed to download file: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != 200 {
+ return fmt.Errorf("download failed with status %d", resp.StatusCode)
+ }
+
+ out, err := os.Create(tempPath)
+ if err != nil {
+ return fmt.Errorf("failed to create temp file: %w", err)
+ }
+
+ pw := NewProgressWriter(out)
+ _, err = io.Copy(pw, resp.Body)
+ out.Close()
+
+ if err != nil {
+ os.Remove(tempPath)
+ return fmt.Errorf("failed to write temp file: %w", err)
+ }
+
+ fmt.Printf("\rDownloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024))
+
+ } else {
+
+ fmt.Printf("Downloading %d segments...\n", len(mediaURLs)+1)
+
+ out, err := os.Create(tempPath)
+ if err != nil {
+ return fmt.Errorf("failed to create temp file: %w", err)
+ }
+
+ fmt.Print("Downloading init segment... ")
+ resp, err := doRequest(initURL)
if err != nil {
out.Close()
os.Remove(tempPath)
- return fmt.Errorf("failed to download segment %d: %w", i+1, err)
+ return fmt.Errorf("failed to download init segment: %w", err)
}
if resp.StatusCode != 200 {
resp.Body.Close()
out.Close()
os.Remove(tempPath)
- return fmt.Errorf("segment %d download failed with status %d", i+1, resp.StatusCode)
+ return fmt.Errorf("init segment download failed with status %d", resp.StatusCode)
}
- n, err := io.Copy(out, resp.Body)
- totalBytes += n
+ _, err = io.Copy(out, resp.Body)
resp.Body.Close()
if err != nil {
out.Close()
os.Remove(tempPath)
- return fmt.Errorf("failed to write segment %d: %w", i+1, err)
+ return fmt.Errorf("failed to write init segment: %w", err)
+ }
+ fmt.Println("OK")
+
+ totalSegments := len(mediaURLs)
+ var totalBytes int64
+ lastTime := time.Now()
+ var lastBytes int64
+ for i, mediaURL := range mediaURLs {
+ resp, err := doRequest(mediaURL)
+ if err != nil {
+ out.Close()
+ os.Remove(tempPath)
+ return fmt.Errorf("failed to download segment %d: %w", i+1, err)
+ }
+ if resp.StatusCode != 200 {
+ resp.Body.Close()
+ out.Close()
+ os.Remove(tempPath)
+ return fmt.Errorf("segment %d download failed with status %d", i+1, resp.StatusCode)
+ }
+ n, err := io.Copy(out, resp.Body)
+ totalBytes += n
+ resp.Body.Close()
+ if err != nil {
+ out.Close()
+ os.Remove(tempPath)
+ return fmt.Errorf("failed to write segment %d: %w", i+1, err)
+ }
+
+ mbDownloaded := float64(totalBytes) / (1024 * 1024)
+ now := time.Now()
+ timeDiff := now.Sub(lastTime).Seconds()
+ var speedMBps float64
+ if timeDiff > 0.1 {
+ bytesDiff := float64(totalBytes - lastBytes)
+ speedMBps = (bytesDiff / (1024 * 1024)) / timeDiff
+ SetDownloadSpeed(speedMBps)
+ lastTime = now
+ lastBytes = totalBytes
+ }
+ SetDownloadProgress(mbDownloaded)
+
+ fmt.Printf("\rDownloading: %.2f MB (%d/%d segments)", mbDownloaded, i+1, totalSegments)
}
- mbDownloaded := float64(totalBytes) / (1024 * 1024)
- now := time.Now()
- timeDiff := now.Sub(lastTime).Seconds()
- var speedMBps float64
- if timeDiff > 0.1 {
- bytesDiff := float64(totalBytes - lastBytes)
- speedMBps = (bytesDiff / (1024 * 1024)) / timeDiff
- SetDownloadSpeed(speedMBps)
- lastTime = now
- lastBytes = totalBytes
- }
- SetDownloadProgress(mbDownloaded)
+ out.Close()
- fmt.Printf("\rDownloading: %.2f MB (%d/%d segments)", mbDownloaded, i+1, totalSegments)
+ tempInfo, _ := os.Stat(tempPath)
+ fmt.Printf("\rDownloaded: %.2f MB (Complete) \n", float64(tempInfo.Size())/(1024*1024))
}
- out.Close()
-
- tempInfo, _ := os.Stat(tempPath)
- fmt.Printf("\rDownloaded: %.2f MB (Complete) \n", float64(tempInfo.Size())/(1024*1024))
-
fmt.Println("Converting to FLAC...")
ffmpegPath, err := GetFFmpegPath()
if err != nil {
@@ -633,10 +692,10 @@ type MPD struct {
} `xml:"Period"`
}
-func parseManifest(manifestB64 string) (directURL string, initURL string, mediaURLs []string, err error) {
+func parseManifest(manifestB64 string) (directURL string, initURL string, mediaURLs []string, mimeType string, err error) {
manifestBytes, err := base64.StdEncoding.DecodeString(manifestB64)
if err != nil {
- return "", "", nil, fmt.Errorf("failed to decode manifest: %w", err)
+ return "", "", nil, "", fmt.Errorf("failed to decode manifest: %w", err)
}
manifestStr := string(manifestBytes)
@@ -644,15 +703,15 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
if strings.HasPrefix(strings.TrimSpace(manifestStr), "{") {
var btsManifest TidalBTSManifest
if err := json.Unmarshal(manifestBytes, &btsManifest); err != nil {
- return "", "", nil, fmt.Errorf("failed to parse BTS manifest: %w", err)
+ return "", "", nil, "", fmt.Errorf("failed to parse BTS manifest: %w", err)
}
if len(btsManifest.URLs) == 0 {
- return "", "", nil, fmt.Errorf("no URLs in BTS manifest")
+ return "", "", nil, "", fmt.Errorf("no URLs in BTS manifest")
}
fmt.Printf("Manifest: BTS format (%s, %s)\n", btsManifest.MimeType, btsManifest.Codecs)
- return btsManifest.URLs[0], "", nil, nil
+ return btsManifest.URLs[0], "", nil, btsManifest.MimeType, nil
}
fmt.Println("Manifest: DASH format")
@@ -717,7 +776,7 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
mediaURL := strings.ReplaceAll(mediaTemplate, "$Number$", fmt.Sprintf("%d", i))
mediaURLs = append(mediaURLs, mediaURL)
}
- return "", initURL, mediaURLs, nil
+ return "", initURL, mediaURLs, "", nil
}
fmt.Println("Using regex fallback for DASH manifest...")
@@ -733,7 +792,7 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
}
if initURL == "" {
- return "", "", nil, fmt.Errorf("no initialization URL found in manifest")
+ return "", "", nil, "", fmt.Errorf("no initialization URL found in manifest")
}
initURL = strings.ReplaceAll(initURL, "&", "&")
@@ -754,7 +813,7 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
}
if segmentCount == 0 {
- return "", "", nil, fmt.Errorf("no segments found in manifest (XML: %d, Regex: 0)", len(matches))
+ return "", "", nil, "", fmt.Errorf("no segments found in manifest (XML: %d, Regex: 0)", len(matches))
}
fmt.Printf("Parsed manifest via Regex: %d segments\n", segmentCount)
@@ -764,7 +823,7 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
mediaURLs = append(mediaURLs, mediaURL)
}
- return "", initURL, mediaURLs, nil
+ return "", initURL, mediaURLs, "", nil
}
func getDownloadURLRotated(apis []string, trackID int64, quality string) (string, string, error) {
diff --git a/backend/uploader.go b/backend/uploader.go
index 4303577..2f14d10 100644
--- a/backend/uploader.go
+++ b/backend/uploader.go
@@ -66,7 +66,12 @@ func uploadToService(filename string, fileReader io.Reader) (string, error) {
writer.Close()
- req, err := http.NewRequest("POST", "https://u1112.send.now/cgi-bin/upload.cgi?upload_type=file&utype=anon", body)
+ uploadURL, err := getUploadURL()
+ if err != nil {
+ return "", fmt.Errorf("failed to get upload server: %v", err)
+ }
+
+ req, err := http.NewRequest("POST", uploadURL, body)
if err != nil {
return "", err
}
@@ -113,6 +118,45 @@ func uploadToService(filename string, fileReader io.Reader) (string, error) {
return fetchDirectImageLink(downloadLink)
}
+func getUploadURL() (string, error) {
+ req, err := http.NewRequest("GET", "https://send.now/", nil)
+ if err != nil {
+ return "", err
+ }
+ req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36")
+
+ client := &http.Client{Timeout: 30 * time.Second}
+ resp, err := client.Do(req)
+ if err != nil {
+ return "", err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != 200 {
+ return "", fmt.Errorf("failed to fetch main page: status %d", resp.StatusCode)
+ }
+
+ bodyBytes, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return "", err
+ }
+ body := string(bodyBytes)
+
+ re := regexp.MustCompile(`action=["'](https://[^"']+/cgi-bin/upload\.cgi\?upload_type=file[^"']*)["']`)
+ matches := re.FindStringSubmatch(body)
+ if len(matches) > 1 {
+ return matches[1], nil
+ }
+
+ reFallback := regexp.MustCompile(`action=["'](https://[^"']+/cgi-bin/upload\.cgi)`)
+ matchesFallback := reFallback.FindStringSubmatch(body)
+ if len(matchesFallback) > 1 {
+ return matchesFallback[1] + "?upload_type=file&utype=anon", nil
+ }
+
+ return "", fmt.Errorf("upload URL not found in main page")
+}
+
func fetchDirectImageLink(url string) (string, error) {
req, err := http.NewRequest("GET", url, nil)
if err != nil {
diff --git a/frontend/package.json b/frontend/package.json
index eb76449..224a2d7 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -16,6 +16,7 @@
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
+ "@radix-ui/react-menubar": "^1.1.16",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
diff --git a/frontend/package.json.md5 b/frontend/package.json.md5
index 108221f..911c462 100644
--- a/frontend/package.json.md5
+++ b/frontend/package.json.md5
@@ -1 +1 @@
-629a5f17426ea4202a25837a341483dd
\ No newline at end of file
+9fee02ec6592ede9ade4b36d56bd4d6d
\ No newline at end of file
diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml
index 0654fe9..0828289 100644
--- a/frontend/pnpm-lock.yaml
+++ b/frontend/pnpm-lock.yaml
@@ -20,6 +20,9 @@ importers:
'@radix-ui/react-label':
specifier: ^2.1.8
version: 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ '@radix-ui/react-menubar':
+ specifier: ^1.1.16
+ version: 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-progress':
specifier: ^1.1.8
version: 1.1.8(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -467,89 +470,105 @@ packages:
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@img/sharp-libvips-linux-arm@1.2.4':
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
cpu: [arm]
os: [linux]
+ libc: [glibc]
'@img/sharp-libvips-linux-ppc64@1.2.4':
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
cpu: [ppc64]
os: [linux]
+ libc: [glibc]
'@img/sharp-libvips-linux-riscv64@1.2.4':
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
cpu: [riscv64]
os: [linux]
+ libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.2.4':
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
cpu: [s390x]
os: [linux]
+ libc: [glibc]
'@img/sharp-libvips-linux-x64@1.2.4':
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@img/sharp-linux-arm64@0.34.5':
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@img/sharp-linux-arm@0.34.5':
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
+ libc: [glibc]
'@img/sharp-linux-ppc64@0.34.5':
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ppc64]
os: [linux]
+ libc: [glibc]
'@img/sharp-linux-riscv64@0.34.5':
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [riscv64]
os: [linux]
+ libc: [glibc]
'@img/sharp-linux-s390x@0.34.5':
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
+ libc: [glibc]
'@img/sharp-linux-x64@0.34.5':
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.34.5':
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@img/sharp-linuxmusl-x64@0.34.5':
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@img/sharp-wasm32@0.34.5':
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
@@ -767,6 +786,19 @@ packages:
'@types/react-dom':
optional: true
+ '@radix-ui/react-menubar@1.1.16':
+ resolution: {integrity: sha512-EB1FktTz5xRRi2Er974AUQZWg2yVBb1yjip38/lgwtCVRd3a+maUoGHN/xs9Yv8SY8QwbSEb+YrxGadVWbEutA==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
'@radix-ui/react-popper@1.2.8':
resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==}
peerDependencies:
@@ -1092,66 +1124,79 @@ packages:
resolution: {integrity: sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==}
cpu: [arm]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.55.1':
resolution: {integrity: sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==}
cpu: [arm]
os: [linux]
+ libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.55.1':
resolution: {integrity: sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.55.1':
resolution: {integrity: sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.55.1':
resolution: {integrity: sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==}
cpu: [loong64]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-loong64-musl@4.55.1':
resolution: {integrity: sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==}
cpu: [loong64]
os: [linux]
+ libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.55.1':
resolution: {integrity: sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==}
cpu: [ppc64]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-ppc64-musl@4.55.1':
resolution: {integrity: sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==}
cpu: [ppc64]
os: [linux]
+ libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.55.1':
resolution: {integrity: sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==}
cpu: [riscv64]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.55.1':
resolution: {integrity: sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==}
cpu: [riscv64]
os: [linux]
+ libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.55.1':
resolution: {integrity: sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==}
cpu: [s390x]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.55.1':
resolution: {integrity: sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.55.1':
resolution: {integrity: sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@rollup/rollup-openbsd-x64@4.55.1':
resolution: {integrity: sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==}
@@ -1221,24 +1266,28 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.1.18':
resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.1.18':
resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@tailwindcss/oxide-linux-x64-musl@4.1.18':
resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@tailwindcss/oxide-wasm32-wasi@4.1.18':
resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==}
@@ -1723,24 +1772,28 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
lightningcss-linux-arm64-musl@1.30.2:
resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
+ libc: [musl]
lightningcss-linux-x64-gnu@1.30.2:
resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
+ libc: [glibc]
lightningcss-linux-x64-musl@1.30.2:
resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
+ libc: [musl]
lightningcss-win32-arm64-msvc@1.30.2:
resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==}
@@ -2658,6 +2711,24 @@ snapshots:
'@types/react': 19.2.8
'@types/react-dom': 19.2.3(@types/react@19.2.8)
+ '@radix-ui/react-menubar@1.1.16(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.8)(react@19.2.3)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.8)(react@19.2.3)
+ '@radix-ui/react-direction': 1.1.1(@types/react@19.2.8)(react@19.2.3)
+ '@radix-ui/react-id': 1.1.1(@types/react@19.2.8)(react@19.2.3)
+ '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.8)(react@19.2.3)
+ react: 19.2.3
+ react-dom: 19.2.3(react@19.2.3)
+ optionalDependencies:
+ '@types/react': 19.2.8
+ '@types/react-dom': 19.2.3(@types/react@19.2.8)
+
'@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies:
'@floating-ui/react-dom': 2.1.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
diff --git a/frontend/src/assets/bmc-logo-side-white.svg b/frontend/src/assets/bmc-logo-side-white.svg
new file mode 100644
index 0000000..197f1de
--- /dev/null
+++ b/frontend/src/assets/bmc-logo-side-white.svg
@@ -0,0 +1,16 @@
+
diff --git a/frontend/src/assets/bmc-logo-side.svg b/frontend/src/assets/bmc-logo-side.svg
new file mode 100644
index 0000000..764be24
--- /dev/null
+++ b/frontend/src/assets/bmc-logo-side.svg
@@ -0,0 +1,16 @@
+
diff --git a/frontend/src/assets/bmc-logo.svg b/frontend/src/assets/bmc-logo.svg
new file mode 100644
index 0000000..7963395
--- /dev/null
+++ b/frontend/src/assets/bmc-logo.svg
@@ -0,0 +1,16 @@
+
diff --git a/frontend/src/assets/kofi_symbol.svg b/frontend/src/assets/kofi_symbol.svg
new file mode 100644
index 0000000..ade749d
--- /dev/null
+++ b/frontend/src/assets/kofi_symbol.svg
@@ -0,0 +1,13 @@
+
diff --git a/frontend/src/components/AboutPage.tsx b/frontend/src/components/AboutPage.tsx
index 89da21e..e5cac33 100644
--- a/frontend/src/components/AboutPage.tsx
+++ b/frontend/src/components/AboutPage.tsx
@@ -7,7 +7,7 @@ import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
-import { Bug, Lightbulb, ExternalLink, Star, GitFork, Clock, Download, CircleHelp, Blocks } from "lucide-react";
+import { Bug, Lightbulb, ExternalLink, Star, GitFork, Clock, Download, CircleHelp, Blocks, Heart } from "lucide-react";
import AudioTTSProIcon from "@/assets/audiotts-pro.webp";
import ChatGPTTTSIcon from "@/assets/chatgpt-tts.webp";
import XProIcon from "@/assets/x-pro.webp";
@@ -15,6 +15,8 @@ import SpotubeDLIcon from "@/assets/icons/spotubedl.svg";
import SpotiDownloaderIcon from "@/assets/icons/spotidownloader.svg";
import XBatchDLIcon from "@/assets/icons/xbatchdl.svg";
import SpotiFLACNextIcon from "@/assets/icons/next.svg";
+import BmcLogo from "@/assets/bmc-logo.svg";
+import KofiLogo from "@/assets/kofi_symbol.svg";
import { langColors } from "@/assets/github-lang-colors";
import { ScrollArea } from "@/components/ui/scroll-area";
import { DragDropMedia } from "./DragDropTextarea";
@@ -24,7 +26,7 @@ interface AboutPageProps {
export function AboutPage({ version }: AboutPageProps) {
const [os, setOs] = useState("Unknown");
const [location, setLocation] = useState("Unknown");
- const [activeTab, setActiveTab] = useState<"bug_report" | "feature_request" | "faq" | "projects">("bug_report");
+ const [activeTab, setActiveTab] = useState<"bug_report" | "feature_request" | "faq" | "projects" | "support">("bug_report");
const [bugType, setBugType] = useState("Track");
const [problem, setProblem] = useState("");
const [spotifyUrl, setSpotifyUrl] = useState("");
@@ -266,6 +268,10 @@ ${contextContent}`;
Other Projects
+
@@ -433,6 +439,28 @@ ${contextContent}`;
)}
+
+
+ {activeTab === "support" && (