Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2bc2c0bf03 | |||
| f13359df7f | |||
| ff14990bd8 | |||
| 0e62356183 | |||
| acec3c350e | |||
| fc520c1cc4 | |||
| 6137995fea | |||
| ae2e4eb155 | |||
| eb468b16df | |||
| 1be4d825fd | |||
| 01d039947a | |||
| 066b6bcbdb | |||
| b3273b7602 | |||
| d495a9851c | |||
| 6f5fd1d16e | |||
| f4b7049f4a | |||
| 4cccdcae77 | |||
| c21d08f050 | |||
| 00d3fb9212 | |||
| 7b12866334 | |||
| 1b415961cc | |||
| 74001462b4 | |||
| fdca1ab461 | |||
| 3d8ff2cedd | |||
| 9ef24f5a91 | |||
| 1314c14c59 | |||
| cb3a6a32cb | |||
| df56049db2 | |||
| 36a77ad8d1 | |||
| 71bce5d33e | |||
| b74dec7369 | |||
| d5c5f34d4c | |||
| 27be5c1b91 | |||
| 0c41d72ab2 | |||
| 25233349b9 | |||
| e04f6e4fdd | |||
| 24bcc56a8f | |||
| 45ad82bb66 | |||
| 13fcb5787d | |||
| 556e720574 | |||
| 791553bdc0 | |||
| 9361c608ca | |||
| 12729e2ca1 | |||
| b620112886 | |||
| cc1c80d367 | |||
| 63149c91a2 | |||
| 1e99d8b5c6 | |||
| b160d3c790 | |||
| d9cf5a5361 | |||
| 4f135f1153 | |||
| 4ee252f438 | |||
| 2fc08de757 | |||
| 6e3ca48d3f | |||
| 46a7777698 | |||
| 0f2174bf80 |
@@ -0,0 +1 @@
|
||||
ko_fi: afkarxyz
|
||||
@@ -0,0 +1,77 @@
|
||||
name: Bug Report
|
||||
description: Bug Report
|
||||
title: "[Bug Report] "
|
||||
labels: ["bug"]
|
||||
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
> **WARNING: Issues that do not follow this template will be deleted without review.**
|
||||
>
|
||||
> **Please keep `[Bug Report]` in the issue title and only continue after it.**
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Problem
|
||||
placeholder: e.g. Downloading a playlist stops after the first track with no error message.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: type
|
||||
attributes:
|
||||
label: Type
|
||||
description: Select the Spotify item type related to this bug.
|
||||
options:
|
||||
- Track
|
||||
- Album
|
||||
- Playlist
|
||||
- Artist
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: spotify-url
|
||||
attributes:
|
||||
label: Spotify URL
|
||||
placeholder: e.g. https://open.spotify.com/track/4cOdK2wGLETKBW3PvgPWqT
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: Additional Context
|
||||
placeholder: e.g. Happens every time on this link. Screenshot or recording attached.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: "### Environment"
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: SpotiFLAC Version
|
||||
placeholder: e.g. v7.1.0
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: os
|
||||
attributes:
|
||||
label: OS
|
||||
placeholder: e.g. Windows 11 23H2
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: location
|
||||
attributes:
|
||||
label: Location
|
||||
placeholder: e.g. Indonesia
|
||||
validations:
|
||||
required: true
|
||||
@@ -0,0 +1 @@
|
||||
blank_issues_enabled: false
|
||||
@@ -0,0 +1,36 @@
|
||||
name: Feature Request
|
||||
description: Feature Request
|
||||
title: "[Feature Request] "
|
||||
labels: ["enhancement"]
|
||||
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
> **WARNING: Issues that do not follow this template will be deleted without review.**
|
||||
>
|
||||
> **Please keep `[Feature Request]` in the issue title and only continue after it.**
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Description
|
||||
placeholder: e.g. Add an option to choose the output naming format for downloaded tracks.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: use-case
|
||||
attributes:
|
||||
label: Use Case
|
||||
placeholder: e.g. I want downloaded files to follow a custom format like Artist - Title for easier library management.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: Additional Context
|
||||
placeholder: e.g. Similar tools allow custom naming templates. Screenshot or mockup attached if needed.
|
||||
validations:
|
||||
required: true
|
||||
@@ -7,7 +7,7 @@ on:
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
GO_VERSION: '1.25.5'
|
||||
GO_VERSION: '1.26'
|
||||
NODE_VERSION: '24'
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -1,55 +1,121 @@
|
||||
[](https://github.com/afkarxyz/SpotiFLAC/releases)
|
||||
# SpotiFLAC
|
||||
|
||||
<!--  -->
|
||||
|
||||

|
||||
|
||||
<div align="center">
|
||||
<a href="https://trendshift.io/repositories/15737" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15737" alt="afkarxyz%2FSpotiFLAC | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
|
||||
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account required.
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
<a href="https://trendshift.io/repositories/15737" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15737" alt="afkarxyz%2FSpotiFLAC | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
|
||||
</div>
|
||||
[](https://t.me/spotiflac)
|
||||
[](https://t.me/spotiflac_chat)
|
||||
|
||||
### [Download](https://github.com/afkarxyz/SpotiFLAC/releases)
|
||||
|
||||
## Screenshot
|
||||
|
||||

|
||||

|
||||
|
||||
## Other projects
|
||||
|
||||
### [SpotiDownloader](https://github.com/afkarxyz/SpotiDownloader)
|
||||
Get Spotify tracks in MP3 and FLAC via the spotidownloader.com API.
|
||||
### [SpotiFLAC Next](https://github.com/afkarxyz/SpotiFLAC-Next)
|
||||
|
||||
Get Spotify tracks in true Lossless from Tidal, Qobuz, Amazon Music, Deezer & Apple Music — no account required.
|
||||
|
||||
### [SpotiDownloader](https://github.com/afkarxyz/SpotiDownloader)
|
||||
|
||||
Get Spotify tracks, albums, playlists and discography in MP3 and FLAC.
|
||||
|
||||
### [SpotubeDL](https://spotubedl.com)
|
||||
|
||||
Download Spotify Tracks, Albums, Playlists as MP3/OGG/Opus with High Quality.
|
||||
|
||||
### [SpotiFLAC (Mobile)](https://github.com/zarzet/SpotiFLAC-Mobile)
|
||||
|
||||
SpotiFLAC for Android & iOS — maintained by [@zarzet](https://github.com/zarzet)
|
||||
|
||||
[](https://ko-fi.com/afkarxyz)
|
||||
### [SpotiFLAC (CLI)](https://github.com/Nizarberyan/SpotiFLAC)
|
||||
|
||||
> Every coffee helps me keep going
|
||||
SpotiFLAC for command-line environments — maintained by [@Nizarberyan](https://github.com/Nizarberyan)
|
||||
|
||||
### [SpotiFLAC (Python Module)](https://github.com/ShuShuzinhuu/SpotiFLAC-Module-Version)
|
||||
|
||||
SpotiFLAC Python library for SpotiFLAC integration — maintained by [@ShuShuzinhuu](https://github.com/ShuShuzinhuu)
|
||||
|
||||
## FAQ
|
||||
|
||||
<details>
|
||||
<summary>Is this software free?</summary>
|
||||
|
||||
_Yes. This software is completely free.
|
||||
You do not need an account, login, or subscription.
|
||||
All you need is an internet connection._
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Can using this software get my Spotify account suspended or banned?</summary>
|
||||
|
||||
_No.
|
||||
This software has no connection to your Spotify account.
|
||||
Spotify data is obtained through reverse engineering of the Spotify Web Player, not through user authentication._
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Where does the audio come from?</summary>
|
||||
|
||||
_The audio is fetched using third-party APIs._
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Why does metadata fetching sometimes fail?</summary>
|
||||
|
||||
_This usually happens because your IP address has been rate-limited.
|
||||
You can wait and try again later, or use a VPN to bypass the rate limit._
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Why does Windows Defender or antivirus flag or delete the file?</summary>
|
||||
|
||||
_This is a false positive.
|
||||
It likely happens because the executable is compressed using UPX._
|
||||
|
||||
_If you are concerned, you can fork the repository and build the software yourself from source._
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Want to support the project?</summary>
|
||||
|
||||
_If this software is useful and brings you value,
|
||||
consider supporting the project by buying me a coffee.
|
||||
Your support helps keep development going._
|
||||
|
||||
</details>
|
||||
|
||||
[](https://ko-fi.com/afkarxyz)
|
||||
|
||||
## Disclaimer
|
||||
|
||||
This project is for **educational and private use only**. The developer does not condone or encourage copyright infringement.
|
||||
|
||||
**SpotiFLAC** is a third-party tool and is not affiliated with, endorsed by, or connected to Spotify, Tidal, Qobuz, Amazon Music, or any other streaming service.
|
||||
**SpotiFLAC** is a third-party tool and is not affiliated with, endorsed by, or connected to Spotify, Tidal, Qobuz, Amazon Music or any other streaming service.
|
||||
|
||||
You are solely responsible for:
|
||||
|
||||
1. Ensuring your use of this software complies with your local laws.
|
||||
2. Reading and adhering to the Terms of Service of the respective platforms.
|
||||
3. Any legal consequences resulting from the misuse of this tool.
|
||||
|
||||
The software is provided "as is", without warranty of any kind. The author assumes no liability for any bans, damages, or legal issues arising from its use.
|
||||
|
||||
## API Credits
|
||||
|
||||
[MusicBrainz](https://musicbrainz.org) · [LRCLIB](https://lrclib.net) · [Songlink/Odesli](https://song.link) · [Songstats](https://songstats.com) · [hifi-api](https://github.com/binimum/hifi-api) · [dabmusic.xyz](https://dabmusic.xyz)
|
||||
|
||||
> [!TIP]
|
||||
>
|
||||
> **Star Us**, You will receive all release notifications from GitHub without any delay ~
|
||||
|
||||
[](https://repostars.dev/?repos=afkarxyz%2FSpotiFLAC&theme=forest)
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
@@ -16,32 +14,13 @@ import (
|
||||
)
|
||||
|
||||
type AmazonDownloader struct {
|
||||
client *http.Client
|
||||
regions []string
|
||||
lastAPICallTime time.Time
|
||||
apiCallCount int
|
||||
apiCallResetTime time.Time
|
||||
client *http.Client
|
||||
regions []string
|
||||
}
|
||||
|
||||
type SongLinkResponse struct {
|
||||
LinksByPlatform map[string]struct {
|
||||
URL string `json:"url"`
|
||||
} `json:"linksByPlatform"`
|
||||
}
|
||||
|
||||
type DoubleDoubleSubmitResponse struct {
|
||||
Success bool `json:"success"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
type DoubleDoubleStatusResponse struct {
|
||||
Status string `json:"status"`
|
||||
FriendlyStatus string `json:"friendlyStatus"`
|
||||
URL string `json:"url"`
|
||||
Current struct {
|
||||
Name string `json:"name"`
|
||||
Artist string `json:"artist"`
|
||||
} `json:"current"`
|
||||
type AmazonStreamResponse struct {
|
||||
StreamURL string `json:"streamUrl"`
|
||||
DecryptionKey string `json:"decryptionKey"`
|
||||
}
|
||||
|
||||
func NewAmazonDownloader() *AmazonDownloader {
|
||||
@@ -49,315 +28,183 @@ func NewAmazonDownloader() *AmazonDownloader {
|
||||
client: &http.Client{
|
||||
Timeout: 120 * time.Second,
|
||||
},
|
||||
regions: []string{"us", "eu"},
|
||||
apiCallResetTime: time.Now(),
|
||||
regions: []string{"us", "eu"},
|
||||
}
|
||||
}
|
||||
|
||||
func (a *AmazonDownloader) getRandomUserAgent() string {
|
||||
return fmt.Sprintf("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_%d_%d) AppleWebKit/%d.%d (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/%d.%d",
|
||||
rand.Intn(4)+11, rand.Intn(5)+4,
|
||||
rand.Intn(7)+530, rand.Intn(7)+30,
|
||||
rand.Intn(25)+80, rand.Intn(1500)+3000, rand.Intn(65)+60,
|
||||
rand.Intn(7)+530, rand.Intn(6)+30)
|
||||
}
|
||||
|
||||
func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (string, error) {
|
||||
|
||||
now := time.Now()
|
||||
if now.Sub(a.apiCallResetTime) >= time.Minute {
|
||||
a.apiCallCount = 0
|
||||
a.apiCallResetTime = now
|
||||
}
|
||||
|
||||
if a.apiCallCount >= 9 {
|
||||
waitTime := time.Minute - now.Sub(a.apiCallResetTime)
|
||||
if waitTime > 0 {
|
||||
fmt.Printf("Rate limit reached, waiting %v...\n", waitTime.Round(time.Second))
|
||||
time.Sleep(waitTime)
|
||||
a.apiCallCount = 0
|
||||
a.apiCallResetTime = time.Now()
|
||||
}
|
||||
}
|
||||
|
||||
if !a.lastAPICallTime.IsZero() {
|
||||
timeSinceLastCall := now.Sub(a.lastAPICallTime)
|
||||
minDelay := 7 * time.Second
|
||||
if timeSinceLastCall < minDelay {
|
||||
waitTime := minDelay - timeSinceLastCall
|
||||
fmt.Printf("Rate limiting: waiting %v...\n", waitTime.Round(time.Second))
|
||||
time.Sleep(waitTime)
|
||||
}
|
||||
}
|
||||
|
||||
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
|
||||
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID)
|
||||
|
||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==")
|
||||
apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(spotifyURL))
|
||||
|
||||
req, err := http.NewRequest("GET", apiURL, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", a.getRandomUserAgent())
|
||||
|
||||
fmt.Println("Getting Amazon URL...")
|
||||
|
||||
maxRetries := 3
|
||||
var resp *http.Response
|
||||
for i := 0; i < maxRetries; i++ {
|
||||
resp, err = a.client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get Amazon URL: %w", err)
|
||||
}
|
||||
|
||||
a.lastAPICallTime = time.Now()
|
||||
a.apiCallCount++
|
||||
|
||||
if resp.StatusCode == 429 {
|
||||
resp.Body.Close()
|
||||
if i < maxRetries-1 {
|
||||
waitTime := 15 * time.Second
|
||||
fmt.Printf("Rate limited by API, waiting %v before retry...\n", waitTime)
|
||||
time.Sleep(waitTime)
|
||||
continue
|
||||
}
|
||||
return "", fmt.Errorf("API rate limit exceeded after %d retries", maxRetries)
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
resp.Body.Close()
|
||||
return "", fmt.Errorf("API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
client := NewSongLinkClient()
|
||||
urls, err := client.GetAllURLsFromSpotify(spotifyTrackID, "")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read response body: %w", err)
|
||||
return "", fmt.Errorf("failed to get Amazon URL: %w", err)
|
||||
}
|
||||
|
||||
if len(body) == 0 {
|
||||
return "", fmt.Errorf("API returned empty response")
|
||||
}
|
||||
|
||||
var songLinkResp SongLinkResponse
|
||||
if err := json.Unmarshal(body, &songLinkResp); err != nil {
|
||||
|
||||
bodyStr := string(body)
|
||||
if len(bodyStr) > 200 {
|
||||
bodyStr = bodyStr[:200] + "..."
|
||||
}
|
||||
return "", fmt.Errorf("failed to decode response: %w (response: %s)", err, bodyStr)
|
||||
}
|
||||
|
||||
amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]
|
||||
if !ok || amazonLink.URL == "" {
|
||||
amazonURL := normalizeAmazonMusicURL(urls.AmazonURL)
|
||||
if amazonURL == "" {
|
||||
return "", fmt.Errorf("amazon Music link not found")
|
||||
}
|
||||
|
||||
amazonURL := amazonLink.URL
|
||||
|
||||
if strings.Contains(amazonURL, "trackAsin=") {
|
||||
parts := strings.Split(amazonURL, "trackAsin=")
|
||||
if len(parts) > 1 {
|
||||
trackAsin := strings.Split(parts[1], "&")[0]
|
||||
musicBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9tdXNpYy5hbWF6b24uY29tL3RyYWNrcy8=")
|
||||
amazonURL = fmt.Sprintf("%s%s?musicTerritory=US", string(musicBase), trackAsin)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("Found Amazon URL: %s\n", amazonURL)
|
||||
return amazonURL, nil
|
||||
}
|
||||
|
||||
func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir string) (string, error) {
|
||||
var lastError error
|
||||
func (a *AmazonDownloader) DownloadFromAfkarXYZ(amazonURL, outputDir, quality string) (string, error) {
|
||||
|
||||
for _, region := range a.regions {
|
||||
fmt.Printf("\nTrying region: %s...\n", region)
|
||||
|
||||
serviceBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly8=")
|
||||
serviceDomain, _ := base64.StdEncoding.DecodeString("LmRvdWJsZWRvdWJsZS50b3A=")
|
||||
baseURL := fmt.Sprintf("%s%s%s", string(serviceBase), region, string(serviceDomain))
|
||||
|
||||
encodedURL := url.QueryEscape(amazonURL)
|
||||
submitURL := fmt.Sprintf("%s/dl?url=%s", baseURL, encodedURL)
|
||||
|
||||
req, err := http.NewRequest("GET", submitURL, nil)
|
||||
if err != nil {
|
||||
lastError = fmt.Errorf("failed to create request: %w", err)
|
||||
continue
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", a.getRandomUserAgent())
|
||||
|
||||
fmt.Println("Submitting download request...")
|
||||
resp, err := a.client.Do(req)
|
||||
if err != nil {
|
||||
lastError = fmt.Errorf("failed to submit request: %w", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
resp.Body.Close()
|
||||
lastError = fmt.Errorf("submit failed with status %d", resp.StatusCode)
|
||||
continue
|
||||
}
|
||||
|
||||
var submitResp DoubleDoubleSubmitResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&submitResp); err != nil {
|
||||
resp.Body.Close()
|
||||
lastError = fmt.Errorf("failed to decode submit response: %w", err)
|
||||
continue
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if !submitResp.Success || submitResp.ID == "" {
|
||||
lastError = fmt.Errorf("submit request failed")
|
||||
continue
|
||||
}
|
||||
|
||||
downloadID := submitResp.ID
|
||||
fmt.Printf("Download ID: %s\n", downloadID)
|
||||
|
||||
statusURL := fmt.Sprintf("%s/dl/%s", baseURL, downloadID)
|
||||
fmt.Println("Waiting for download to complete...")
|
||||
|
||||
maxWait := 300 * time.Second
|
||||
elapsed := time.Duration(0)
|
||||
pollInterval := 3 * time.Second
|
||||
|
||||
for elapsed < maxWait {
|
||||
time.Sleep(pollInterval)
|
||||
elapsed += pollInterval
|
||||
|
||||
statusReq, err := http.NewRequest("GET", statusURL, nil)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
statusReq.Header.Set("User-Agent", a.getRandomUserAgent())
|
||||
|
||||
statusResp, err := a.client.Do(statusReq)
|
||||
if err != nil {
|
||||
fmt.Printf("\rStatus check failed, retrying...")
|
||||
continue
|
||||
}
|
||||
|
||||
if statusResp.StatusCode != 200 {
|
||||
statusResp.Body.Close()
|
||||
fmt.Printf("\rStatus check failed (status %d), retrying...", statusResp.StatusCode)
|
||||
continue
|
||||
}
|
||||
|
||||
var status DoubleDoubleStatusResponse
|
||||
if err := json.NewDecoder(statusResp.Body).Decode(&status); err != nil {
|
||||
statusResp.Body.Close()
|
||||
fmt.Printf("\rInvalid JSON response, retrying...")
|
||||
continue
|
||||
}
|
||||
statusResp.Body.Close()
|
||||
|
||||
if status.Status == "done" {
|
||||
fmt.Println("\nDownload ready!")
|
||||
|
||||
fileURL := status.URL
|
||||
if strings.HasPrefix(fileURL, "./") {
|
||||
fileURL = fmt.Sprintf("%s/%s", baseURL, fileURL[2:])
|
||||
} else if strings.HasPrefix(fileURL, "/") {
|
||||
fileURL = fmt.Sprintf("%s%s", baseURL, fileURL)
|
||||
}
|
||||
|
||||
trackName := status.Current.Name
|
||||
artist := status.Current.Artist
|
||||
|
||||
fmt.Printf("Downloading: %s - %s\n", artist, trackName)
|
||||
|
||||
downloadReq, err := http.NewRequest("GET", fileURL, nil)
|
||||
if err != nil {
|
||||
lastError = fmt.Errorf("failed to create download request: %w", err)
|
||||
break
|
||||
}
|
||||
|
||||
downloadReq.Header.Set("User-Agent", a.getRandomUserAgent())
|
||||
|
||||
fileResp, err := a.client.Do(downloadReq)
|
||||
if err != nil {
|
||||
lastError = fmt.Errorf("failed to download file: %w", err)
|
||||
break
|
||||
}
|
||||
defer fileResp.Body.Close()
|
||||
|
||||
if fileResp.StatusCode != 200 {
|
||||
lastError = fmt.Errorf("download failed with status %d", fileResp.StatusCode)
|
||||
break
|
||||
}
|
||||
|
||||
fileName := fmt.Sprintf("%s - %s.flac", artist, trackName)
|
||||
for _, char := range `<>:"/\|?*` {
|
||||
fileName = strings.ReplaceAll(fileName, string(char), "")
|
||||
}
|
||||
fileName = strings.TrimSpace(fileName)
|
||||
|
||||
filePath := filepath.Join(outputDir, fileName)
|
||||
|
||||
out, err := os.Create(filePath)
|
||||
if err != nil {
|
||||
lastError = fmt.Errorf("failed to create file: %w", err)
|
||||
break
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
fmt.Println("Downloading...")
|
||||
|
||||
pw := NewProgressWriter(out)
|
||||
_, err = io.Copy(pw, fileResp.Body)
|
||||
if err != nil {
|
||||
out.Close()
|
||||
return "", fmt.Errorf("failed to write file: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("\rDownloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024))
|
||||
fmt.Println("Download complete!")
|
||||
return filePath, nil
|
||||
|
||||
} else if status.Status == "error" {
|
||||
errorMsg := status.FriendlyStatus
|
||||
if errorMsg == "" {
|
||||
errorMsg = "Unknown error"
|
||||
}
|
||||
lastError = fmt.Errorf("processing failed: %s", errorMsg)
|
||||
break
|
||||
} else {
|
||||
|
||||
friendlyStatus := status.FriendlyStatus
|
||||
if friendlyStatus == "" {
|
||||
friendlyStatus = status.Status
|
||||
}
|
||||
fmt.Printf("\r%s...", friendlyStatus)
|
||||
}
|
||||
}
|
||||
|
||||
if elapsed >= maxWait {
|
||||
lastError = fmt.Errorf("download timeout")
|
||||
fmt.Printf("\nError with %s region: %v\n", region, lastError)
|
||||
continue
|
||||
}
|
||||
|
||||
if lastError != nil {
|
||||
fmt.Printf("\nError with %s region: %v\n", region, lastError)
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("all regions failed. Last error: %v", lastError)
|
||||
apiURL := fmt.Sprintf("https://amzn.afkarxyz.qzz.io/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/145.0.0.0 Safari/537.36")
|
||||
|
||||
fmt.Printf("Fetching from Amazon API (ASIN: %s)...\n", asin)
|
||||
resp, err := a.client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return "", fmt.Errorf("Amazon API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
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.StreamURL == "" {
|
||||
return "", fmt.Errorf("no stream URL found in response")
|
||||
}
|
||||
|
||||
downloadURL := apiResp.StreamURL
|
||||
fileName := fmt.Sprintf("%s.m4a", asin)
|
||||
filePath := filepath.Join(outputDir, fileName)
|
||||
|
||||
out, err := os.Create(filePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
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/145.0.0.0 Safari/537.36")
|
||||
|
||||
dlResp, err := a.client.Do(dlReq)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer dlResp.Body.Close()
|
||||
|
||||
fmt.Printf("Downloading track: %s\n", fileName)
|
||||
pw := NewProgressWriter(out)
|
||||
_, err = io.Copy(pw, dlResp.Body)
|
||||
if err != nil {
|
||||
out.Close()
|
||||
os.Remove(filePath)
|
||||
return "", err
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string) (string, error) {
|
||||
func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir, quality string) (string, error) {
|
||||
return a.DownloadFromAfkarXYZ(amazonURL, outputDir, quality)
|
||||
}
|
||||
|
||||
func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filenameFormat, playlistName, playlistOwner string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) {
|
||||
|
||||
if outputDir != "." {
|
||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||
@@ -366,7 +213,13 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, filenameFormat st
|
||||
}
|
||||
|
||||
if spotifyTrackName != "" && spotifyArtistName != "" {
|
||||
expectedFilename := BuildExpectedFilename(spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, filenameFormat, includeTrackNumber, position, spotifyDiscNumber, false)
|
||||
filenameArtist := spotifyArtistName
|
||||
filenameAlbumArtist := spotifyAlbumArtist
|
||||
if useFirstArtistOnly {
|
||||
filenameArtist = GetFirstArtist(spotifyArtistName)
|
||||
filenameAlbumArtist = GetFirstArtist(spotifyAlbumArtist)
|
||||
}
|
||||
expectedFilename := BuildExpectedFilename(spotifyTrackName, filenameArtist, spotifyAlbumName, filenameAlbumArtist, spotifyReleaseDate, filenameFormat, playlistName, playlistOwner, includeTrackNumber, position, spotifyDiscNumber, false)
|
||||
expectedPath := filepath.Join(outputDir, expectedFilename)
|
||||
|
||||
if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 0 {
|
||||
@@ -375,18 +228,71 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, filenameFormat st
|
||||
}
|
||||
}
|
||||
|
||||
type mbResult struct {
|
||||
ISRC string
|
||||
Metadata Metadata
|
||||
}
|
||||
|
||||
metaChan := make(chan mbResult, 1)
|
||||
if embedGenre && spotifyURL != "" {
|
||||
go func() {
|
||||
res := mbResult{}
|
||||
var isrc string
|
||||
parts := strings.Split(spotifyURL, "/")
|
||||
if len(parts) > 0 {
|
||||
sID := strings.Split(parts[len(parts)-1], "?")[0]
|
||||
if sID != "" {
|
||||
client := NewSongLinkClient()
|
||||
if val, err := client.GetISRC(sID); err == nil {
|
||||
isrc = val
|
||||
}
|
||||
}
|
||||
}
|
||||
res.ISRC = isrc
|
||||
if isrc != "" {
|
||||
fmt.Println("Fetching MusicBrainz metadata...")
|
||||
if fetchedMeta, err := FetchMusicBrainzMetadata(isrc, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useSingleGenre, embedGenre); err == nil {
|
||||
res.Metadata = fetchedMeta
|
||||
fmt.Println("✓ MusicBrainz metadata fetched")
|
||||
} else {
|
||||
fmt.Printf("Warning: Failed to fetch MusicBrainz metadata: %v\n", err)
|
||||
}
|
||||
}
|
||||
metaChan <- res
|
||||
}()
|
||||
} else {
|
||||
close(metaChan)
|
||||
}
|
||||
|
||||
fmt.Printf("Using Amazon URL: %s\n", amazonURL)
|
||||
|
||||
filePath, err := a.DownloadFromService(amazonURL, outputDir)
|
||||
filePath, err := a.DownloadFromService(amazonURL, outputDir, quality)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var isrc string
|
||||
var mbMeta Metadata
|
||||
if spotifyURL != "" {
|
||||
result := <-metaChan
|
||||
isrc = result.ISRC
|
||||
mbMeta = result.Metadata
|
||||
}
|
||||
|
||||
originalFileDir := filepath.Dir(filePath)
|
||||
originalFileBase := strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
|
||||
|
||||
if spotifyTrackName != "" && spotifyArtistName != "" {
|
||||
safeArtist := sanitizeFilename(spotifyArtistName)
|
||||
safeAlbumArtist := sanitizeFilename(spotifyAlbumArtist)
|
||||
|
||||
if useFirstArtistOnly {
|
||||
safeArtist = sanitizeFilename(GetFirstArtist(spotifyArtistName))
|
||||
safeAlbumArtist = sanitizeFilename(GetFirstArtist(spotifyAlbumArtist))
|
||||
}
|
||||
|
||||
safeTitle := sanitizeFilename(spotifyTrackName)
|
||||
safeAlbum := sanitizeFilename(spotifyAlbumName)
|
||||
safeAlbumArtist := sanitizeFilename(spotifyAlbumArtist)
|
||||
|
||||
year := ""
|
||||
if len(spotifyReleaseDate) >= 4 {
|
||||
@@ -402,6 +308,7 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, filenameFormat st
|
||||
newFilename = strings.ReplaceAll(newFilename, "{album}", safeAlbum)
|
||||
newFilename = strings.ReplaceAll(newFilename, "{album_artist}", safeAlbumArtist)
|
||||
newFilename = strings.ReplaceAll(newFilename, "{year}", year)
|
||||
newFilename = strings.ReplaceAll(newFilename, "{date}", SanitizeFilename(spotifyReleaseDate))
|
||||
|
||||
if spotifyDiscNumber > 0 {
|
||||
newFilename = strings.ReplaceAll(newFilename, "{disc}", fmt.Sprintf("%d", spotifyDiscNumber))
|
||||
@@ -433,7 +340,11 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, filenameFormat st
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -476,28 +387,45 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, filenameFormat st
|
||||
DiscNumber: spotifyDiscNumber,
|
||||
TotalDiscs: spotifyTotalDiscs,
|
||||
URL: spotifyURL,
|
||||
Comment: spotifyURL,
|
||||
Copyright: spotifyCopyright,
|
||||
Publisher: spotifyPublisher,
|
||||
Description: "https://github.com/afkarxyz/SpotiFLAC",
|
||||
ISRC: isrc,
|
||||
Genre: mbMeta.Genre,
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func (a *AmazonDownloader) DownloadBySpotifyID(spotifyTrackID, outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string) (string, error) {
|
||||
func (a *AmazonDownloader) DownloadBySpotifyID(spotifyTrackID, outputDir, quality, filenameFormat, playlistName, playlistOwner string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string,
|
||||
useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool,
|
||||
) (string, error) {
|
||||
|
||||
amazonURL, err := a.GetAmazonURLFromSpotify(spotifyTrackID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return a.DownloadByURL(amazonURL, outputDir, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, embedMaxQualityCover, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyURL)
|
||||
return a.DownloadByURL(amazonURL, outputDir, quality, filenameFormat, playlistName, playlistOwner, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, embedMaxQualityCover, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyURL, useFirstArtistOnly, useSingleGenre, embedGenre)
|
||||
}
|
||||
|
||||
@@ -1,164 +1,214 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
|
||||
"github.com/go-flac/go-flac"
|
||||
mewflac "github.com/mewkiz/flac"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type AnalysisResult struct {
|
||||
FilePath string `json:"file_path"`
|
||||
FileSize int64 `json:"file_size"`
|
||||
SampleRate uint32 `json:"sample_rate"`
|
||||
Channels uint8 `json:"channels"`
|
||||
BitsPerSample uint8 `json:"bits_per_sample"`
|
||||
TotalSamples uint64 `json:"total_samples"`
|
||||
Duration float64 `json:"duration"`
|
||||
BitDepth string `json:"bit_depth"`
|
||||
DynamicRange float64 `json:"dynamic_range"`
|
||||
PeakAmplitude float64 `json:"peak_amplitude"`
|
||||
RMSLevel float64 `json:"rms_level"`
|
||||
Spectrum *SpectrumData `json:"spectrum,omitempty"`
|
||||
FilePath string `json:"file_path"`
|
||||
FileSize int64 `json:"file_size"`
|
||||
SampleRate uint32 `json:"sample_rate"`
|
||||
Channels uint8 `json:"channels"`
|
||||
BitsPerSample uint8 `json:"bits_per_sample"`
|
||||
TotalSamples uint64 `json:"total_samples"`
|
||||
Duration float64 `json:"duration"`
|
||||
Bitrate int `json:"bit_rate"`
|
||||
BitDepth string `json:"bit_depth"`
|
||||
DynamicRange float64 `json:"dynamic_range"`
|
||||
PeakAmplitude float64 `json:"peak_amplitude"`
|
||||
RMSLevel float64 `json:"rms_level"`
|
||||
}
|
||||
|
||||
func AnalyzeTrack(filepath string) (*AnalysisResult, error) {
|
||||
type AnalysisDecodeResponse struct {
|
||||
PCMBase64 string `json:"pcm_base64"`
|
||||
SampleRate uint32 `json:"sample_rate"`
|
||||
Channels uint8 `json:"channels"`
|
||||
BitsPerSample uint8 `json:"bits_per_sample"`
|
||||
Duration float64 `json:"duration"`
|
||||
BitrateKbps int `json:"bitrate_kbps,omitempty"`
|
||||
BitDepth string `json:"bit_depth,omitempty"`
|
||||
}
|
||||
|
||||
func GetTrackMetadata(filepath string) (*AnalysisResult, error) {
|
||||
if !fileExists(filepath) {
|
||||
return nil, fmt.Errorf("file does not exist: %s", filepath)
|
||||
}
|
||||
|
||||
fileInfo, err := os.Stat(filepath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get file info: %w", err)
|
||||
}
|
||||
|
||||
f, err := flac.ParseFile(filepath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse FLAC file: %w", err)
|
||||
}
|
||||
|
||||
result := &AnalysisResult{
|
||||
FilePath: filepath,
|
||||
FileSize: fileInfo.Size(),
|
||||
}
|
||||
|
||||
if len(f.Meta) > 0 {
|
||||
streamInfo := f.Meta[0]
|
||||
if streamInfo.Type == flac.StreamInfo {
|
||||
|
||||
data := streamInfo.Data
|
||||
if len(data) >= 18 {
|
||||
|
||||
result.SampleRate = uint32(data[10])<<12 | uint32(data[11])<<4 | uint32(data[12])>>4
|
||||
|
||||
result.Channels = ((data[12] >> 1) & 0x07) + 1
|
||||
|
||||
result.BitsPerSample = ((data[12]&0x01)<<4 | data[13]>>4) + 1
|
||||
|
||||
result.TotalSamples = uint64(data[13]&0x0F)<<32 |
|
||||
uint64(data[14])<<24 |
|
||||
uint64(data[15])<<16 |
|
||||
uint64(data[16])<<8 |
|
||||
uint64(data[17])
|
||||
|
||||
if result.SampleRate > 0 {
|
||||
result.Duration = float64(result.TotalSamples) / float64(result.SampleRate)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
spectrum, err := AnalyzeSpectrum(filepath)
|
||||
if err != nil {
|
||||
|
||||
fmt.Printf("Warning: failed to analyze spectrum: %v\n", err)
|
||||
} else {
|
||||
result.Spectrum = spectrum
|
||||
|
||||
calculateRealAudioMetrics(result, filepath)
|
||||
}
|
||||
|
||||
result.BitDepth = fmt.Sprintf("%d-bit", result.BitsPerSample)
|
||||
|
||||
return result, nil
|
||||
return GetMetadataWithFFprobe(filepath)
|
||||
}
|
||||
|
||||
func calculateRealAudioMetrics(result *AnalysisResult, filepath string) {
|
||||
|
||||
samples, err := decodeFLACForMetrics(filepath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var peak float64
|
||||
var sumSquares float64
|
||||
|
||||
for _, sample := range samples {
|
||||
absVal := sample
|
||||
if absVal < 0 {
|
||||
absVal = -absVal
|
||||
}
|
||||
if absVal > peak {
|
||||
peak = absVal
|
||||
}
|
||||
sumSquares += sample * sample
|
||||
}
|
||||
|
||||
peakDB := 20.0 * math.Log10(peak)
|
||||
result.PeakAmplitude = peakDB
|
||||
|
||||
rms := math.Sqrt(sumSquares / float64(len(samples)))
|
||||
rmsDB := 20.0 * math.Log10(rms)
|
||||
result.RMSLevel = rmsDB
|
||||
|
||||
result.DynamicRange = peakDB - rmsDB
|
||||
}
|
||||
|
||||
func decodeFLACForMetrics(filepath string) ([]float64, error) {
|
||||
stream, err := mewflac.ParseFile(filepath)
|
||||
func GetMetadataWithFFprobe(filePath string) (*AnalysisResult, error) {
|
||||
ffprobePath, err := GetFFprobePath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer stream.Close()
|
||||
|
||||
maxSamples := 10000000
|
||||
samples := make([]float64, 0, maxSamples)
|
||||
|
||||
for {
|
||||
frame, err := stream.ParseNext()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
var channelSamples []int32
|
||||
if len(frame.Subframes) > 0 {
|
||||
channelSamples = frame.Subframes[0].Samples
|
||||
}
|
||||
|
||||
maxVal := float64(int64(1) << (stream.Info.BitsPerSample - 1))
|
||||
for _, sample := range channelSamples {
|
||||
if len(samples) >= maxSamples {
|
||||
return samples, nil
|
||||
}
|
||||
normalized := float64(sample) / maxVal
|
||||
samples = append(samples, normalized)
|
||||
}
|
||||
|
||||
if len(samples) >= maxSamples {
|
||||
for i := 0; i < 5; i++ {
|
||||
if f, err := os.Open(filePath); err == nil {
|
||||
f.Close()
|
||||
break
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
|
||||
return samples, nil
|
||||
}
|
||||
|
||||
func GetFileSize(filepath string) (int64, error) {
|
||||
info, err := os.Stat(filepath)
|
||||
args := []string{
|
||||
"-v", "error",
|
||||
"-select_streams", "a:0",
|
||||
"-show_entries", "stream=sample_rate,channels,bits_per_raw_sample,bits_per_sample,duration,bit_rate",
|
||||
"-of", "default=noprint_wrappers=0",
|
||||
filePath,
|
||||
}
|
||||
cmd := exec.Command(ffprobePath, args...)
|
||||
setHideWindow(cmd)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
return nil, fmt.Errorf("ffprobe failed: %v - %s", err, string(output))
|
||||
}
|
||||
return info.Size(), nil
|
||||
|
||||
infoMap := make(map[string]string)
|
||||
lines := strings.Split(string(output), "\n")
|
||||
for _, line := range lines {
|
||||
if strings.Contains(line, "=") {
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
infoMap[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
|
||||
}
|
||||
}
|
||||
|
||||
res := &AnalysisResult{
|
||||
FilePath: filePath,
|
||||
}
|
||||
|
||||
if info, err := os.Stat(filePath); err == nil {
|
||||
res.FileSize = info.Size()
|
||||
}
|
||||
|
||||
if val, ok := infoMap["sample_rate"]; ok {
|
||||
s, _ := strconv.Atoi(val)
|
||||
res.SampleRate = uint32(s)
|
||||
}
|
||||
if val, ok := infoMap["channels"]; ok {
|
||||
c, _ := strconv.Atoi(val)
|
||||
res.Channels = uint8(c)
|
||||
}
|
||||
if val, ok := infoMap["duration"]; ok {
|
||||
d, _ := strconv.ParseFloat(val, 64)
|
||||
res.Duration = d
|
||||
}
|
||||
if val, ok := infoMap["bit_rate"]; ok && val != "N/A" {
|
||||
br, _ := strconv.Atoi(val)
|
||||
res.Bitrate = br
|
||||
}
|
||||
|
||||
bits := 0
|
||||
if val, ok := infoMap["bits_per_raw_sample"]; ok && val != "N/A" {
|
||||
bits, _ = strconv.Atoi(val)
|
||||
}
|
||||
if bits == 0 {
|
||||
if val, ok := infoMap["bits_per_sample"]; ok && val != "N/A" {
|
||||
bits, _ = strconv.Atoi(val)
|
||||
}
|
||||
}
|
||||
|
||||
res.BitsPerSample = uint8(bits)
|
||||
if bits > 0 {
|
||||
res.BitDepth = fmt.Sprintf("%d-bit", bits)
|
||||
} else {
|
||||
res.BitDepth = "Unknown"
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func DecodeAudioForAnalysis(filePath string) (*AnalysisDecodeResponse, error) {
|
||||
metadata, err := GetTrackMetadata(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pcmBase64, err := extractAnalysisPCMBase64(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp := &AnalysisDecodeResponse{
|
||||
PCMBase64: pcmBase64,
|
||||
SampleRate: metadata.SampleRate,
|
||||
Channels: metadata.Channels,
|
||||
BitsPerSample: metadata.BitsPerSample,
|
||||
Duration: metadata.Duration,
|
||||
BitDepth: metadata.BitDepth,
|
||||
}
|
||||
|
||||
if metadata.Bitrate > 0 {
|
||||
resp.BitrateKbps = metadata.Bitrate / 1000
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func extractAnalysisPCMBase64(filePath string) (string, error) {
|
||||
ffmpegPath, err := GetFFmpegPath()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
argSets := [][]string{
|
||||
{
|
||||
"-v", "error",
|
||||
"-i", filePath,
|
||||
"-vn",
|
||||
"-map", "0:a:0",
|
||||
"-af", "pan=mono|c0=c0",
|
||||
"-f", "s16le",
|
||||
"-acodec", "pcm_s16le",
|
||||
"pipe:1",
|
||||
},
|
||||
{
|
||||
"-v", "error",
|
||||
"-i", filePath,
|
||||
"-vn",
|
||||
"-map", "0:a:0",
|
||||
"-ac", "1",
|
||||
"-f", "s16le",
|
||||
"-acodec", "pcm_s16le",
|
||||
"pipe:1",
|
||||
},
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
|
||||
for _, args := range argSets {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
cmd := exec.Command(ffmpegPath, args...)
|
||||
setHideWindow(cmd)
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
lastErr = fmt.Errorf("ffmpeg analysis decode failed: %w - %s", err, strings.TrimSpace(stderr.String()))
|
||||
continue
|
||||
}
|
||||
|
||||
if stdout.Len() == 0 {
|
||||
lastErr = fmt.Errorf("ffmpeg analysis decode returned empty PCM output")
|
||||
continue
|
||||
}
|
||||
|
||||
return base64.StdEncoding.EncodeToString(stdout.Bytes()), nil
|
||||
}
|
||||
|
||||
if lastErr != nil {
|
||||
return "", lastErr
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("ffmpeg analysis decode failed")
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func GetDefaultMusicPath() string {
|
||||
@@ -15,3 +17,87 @@ func GetDefaultMusicPath() string {
|
||||
|
||||
return filepath.Join(homeDir, "Music")
|
||||
}
|
||||
|
||||
func GetConfigPath() (string, error) {
|
||||
dir, err := EnsureAppDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return filepath.Join(dir, "config.json"), nil
|
||||
}
|
||||
|
||||
func LoadConfigSettings() (map[string]interface{}, error) {
|
||||
configPath, err := GetConfigPath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var settings map[string]interface{}
|
||||
if err := json.Unmarshal(data, &settings); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
func GetSpotFetchAPISettings() (bool, string) {
|
||||
settings, err := LoadConfigSettings()
|
||||
if err != nil || settings == nil {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
useAPI, _ := settings["useSpotFetchAPI"].(bool)
|
||||
if !useAPI {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
apiURL, _ := settings["spotFetchAPIUrl"].(string)
|
||||
if apiURL == "" {
|
||||
apiURL = "https://sp.afkarxyz.qzz.io/api"
|
||||
}
|
||||
|
||||
return true, apiURL
|
||||
}
|
||||
|
||||
func GetLinkResolverSetting() string {
|
||||
settings, err := LoadConfigSettings()
|
||||
if err != nil || settings == nil {
|
||||
return linkResolverProviderDeezerSongLink
|
||||
}
|
||||
|
||||
resolver, _ := settings["linkResolver"].(string)
|
||||
switch strings.TrimSpace(strings.ToLower(resolver)) {
|
||||
case "songlink", linkResolverProviderDeezerSongLink:
|
||||
return linkResolverProviderDeezerSongLink
|
||||
case "songstats":
|
||||
return linkResolverProviderSongstats
|
||||
case "":
|
||||
return linkResolverProviderDeezerSongLink
|
||||
default:
|
||||
return linkResolverProviderDeezerSongLink
|
||||
}
|
||||
}
|
||||
|
||||
func GetLinkResolverAllowFallback() bool {
|
||||
settings, err := LoadConfigSettings()
|
||||
if err != nil || settings == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
allowFallback, ok := settings["allowResolverFallback"].(bool)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
|
||||
return allowFallback
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/png"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -9,6 +12,9 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
xdraw "golang.org/x/image/draw"
|
||||
_ "image/jpeg"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -83,6 +89,7 @@ func buildCoverFilename(trackName, artistName, albumName, albumArtist, releaseDa
|
||||
filename = strings.ReplaceAll(filename, "{album}", safeAlbum)
|
||||
filename = strings.ReplaceAll(filename, "{album_artist}", safeAlbumArtist)
|
||||
filename = strings.ReplaceAll(filename, "{year}", year)
|
||||
filename = strings.ReplaceAll(filename, "{date}", sanitizeFilename(releaseDate))
|
||||
|
||||
if discNumber > 0 {
|
||||
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
|
||||
@@ -116,7 +123,7 @@ func buildCoverFilename(trackName, artistName, albumName, albumArtist, releaseDa
|
||||
}
|
||||
}
|
||||
|
||||
return filename + ".cover.jpg"
|
||||
return filename + ".jpg"
|
||||
}
|
||||
|
||||
func convertSmallToMedium(imageURL string) string {
|
||||
@@ -169,6 +176,69 @@ func (c *CoverClient) DownloadCoverToPath(coverURL, outputPath string, embedMaxQ
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *CoverClient) ApplyMacOSFLACFileIcon(filePath, coverURL string, iconSize int, embedMaxQualityCover bool) error {
|
||||
if filePath == "" {
|
||||
return fmt.Errorf("file path is required")
|
||||
}
|
||||
if coverURL == "" {
|
||||
return fmt.Errorf("cover URL is required")
|
||||
}
|
||||
|
||||
tmpFile, err := os.CreateTemp("", "spotiflac-file-icon-*.jpg")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create temporary cover file: %w", err)
|
||||
}
|
||||
tmpPath := tmpFile.Name()
|
||||
tmpFile.Close()
|
||||
defer os.Remove(tmpPath)
|
||||
|
||||
if err := c.DownloadCoverToPath(coverURL, tmpPath, embedMaxQualityCover); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return SetMacOSFileIconFromImage(filePath, tmpPath, iconSize)
|
||||
}
|
||||
|
||||
func ResizeImageForIcon(sourcePath string, iconSize int) (string, error) {
|
||||
if sourcePath == "" {
|
||||
return "", fmt.Errorf("source image path is required")
|
||||
}
|
||||
if iconSize <= 0 {
|
||||
iconSize = 256
|
||||
}
|
||||
|
||||
in, err := os.Open(sourcePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to open source image: %w", err)
|
||||
}
|
||||
defer in.Close()
|
||||
|
||||
srcImage, _, err := image.Decode(in)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to decode source image: %w", err)
|
||||
}
|
||||
|
||||
dst := image.NewRGBA(image.Rect(0, 0, iconSize, iconSize))
|
||||
xdraw.CatmullRom.Scale(dst, dst.Bounds(), srcImage, srcImage.Bounds(), xdraw.Over, nil)
|
||||
|
||||
tmpFile, err := os.CreateTemp("", "spotiflac-resized-icon-*.png")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create resized icon temp file: %w", err)
|
||||
}
|
||||
tmpPath := tmpFile.Name()
|
||||
defer tmpFile.Close()
|
||||
|
||||
var encoded bytes.Buffer
|
||||
if err := png.Encode(&encoded, dst); err != nil {
|
||||
return "", fmt.Errorf("failed to encode resized icon image: %w", err)
|
||||
}
|
||||
if _, err := io.Copy(tmpFile, &encoded); err != nil {
|
||||
return "", fmt.Errorf("failed to write resized icon image: %w", err)
|
||||
}
|
||||
|
||||
return tmpPath, nil
|
||||
}
|
||||
|
||||
func (c *CoverClient) DownloadCover(req CoverDownloadRequest) (*CoverDownloadResponse, error) {
|
||||
if req.CoverURL == "" {
|
||||
return &CoverDownloadResponse{
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
|
||||
const (
|
||||
previewMaxSeconds = 35
|
||||
previewExpectedMinSeconds = 60
|
||||
largeMismatchMinExpected = 90
|
||||
minAllowedDurationDiff = 15
|
||||
durationDiffRatio = 0.25
|
||||
)
|
||||
|
||||
func ValidateDownloadedTrackDuration(filePath string, expectedSeconds int) (bool, error) {
|
||||
if filePath == "" || expectedSeconds <= 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
actualDuration, err := GetAudioDuration(filePath)
|
||||
if err != nil || actualDuration <= 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
actualSeconds := int(math.Round(actualDuration))
|
||||
if actualSeconds <= 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if expectedSeconds >= previewExpectedMinSeconds && actualSeconds <= previewMaxSeconds {
|
||||
return true, fmt.Errorf("detected preview/sample download: file is %ds, expected about %ds. file was removed", actualSeconds, expectedSeconds)
|
||||
}
|
||||
|
||||
if expectedSeconds >= largeMismatchMinExpected {
|
||||
allowedDiff := int(math.Max(minAllowedDurationDiff, math.Round(float64(expectedSeconds)*durationDiffRatio)))
|
||||
diff := int(math.Abs(float64(actualSeconds - expectedSeconds)))
|
||||
if diff > allowedDiff {
|
||||
return true, fmt.Errorf("downloaded file duration mismatch: file is %ds, expected about %ds. file was removed", actualSeconds, expectedSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
@@ -3,7 +3,7 @@ package backend
|
||||
import (
|
||||
"archive/tar"
|
||||
"archive/zip"
|
||||
"encoding/base64"
|
||||
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -16,16 +16,9 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/ulikunitz/xz"
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
|
||||
func decodeBase64(encoded string) (string, error) {
|
||||
decoded, err := base64.StdEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(decoded), nil
|
||||
}
|
||||
|
||||
func ValidateExecutable(path string) error {
|
||||
cleanedPath := filepath.Clean(path)
|
||||
if cleanedPath == "" {
|
||||
@@ -65,14 +58,7 @@ func ValidateExecutable(path string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
const (
|
||||
ffmpegWindowsURL = "aHR0cHM6Ly9naXRodWIuY29tL0J0Yk4vRkZtcGVnLUJ1aWxkcy9yZWxlYXNlcy9kb3dubG9hZC9sYXRlc3QvZmZtcGVnLW1hc3Rlci1sYXRlc3Qtd2luNjQtZ3BsLnppcA=="
|
||||
ffmpegLinuxURL = "aHR0cHM6Ly9naXRodWIuY29tL0J0Yk4vRkZtcGVnLUJ1aWxkcy9yZWxlYXNlcy9kb3dubG9hZC9sYXRlc3QvZmZtcGVnLW1hc3Rlci1sYXRlc3QtbGludXg2NC1ncGwudGFyLnh6"
|
||||
ffmpegMacOSURL = "aHR0cHM6Ly9ldmVybWVldC5jeC9mZm1wZWcvZ2V0cmVsZWFzZS96aXA="
|
||||
ffprobeMacOSURL = "aHR0cHM6Ly9ldmVybWVldC5jeC9mZm1wZWcvZ2V0cmVsZWFzZS9mZnByb2JlL3ppcA=="
|
||||
)
|
||||
|
||||
func GetFFmpegDir() (string, error) {
|
||||
func GetAppDir() (string, error) {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get home directory: %w", err)
|
||||
@@ -80,6 +66,23 @@ func GetFFmpegDir() (string, error) {
|
||||
return filepath.Join(homeDir, ".spotiflac"), nil
|
||||
}
|
||||
|
||||
func EnsureAppDir() (string, error) {
|
||||
appDir, err := GetAppDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(appDir, 0o755); err != nil {
|
||||
return "", fmt.Errorf("failed to create app directory: %w", err)
|
||||
}
|
||||
|
||||
return appDir, nil
|
||||
}
|
||||
|
||||
func GetFFmpegDir() (string, error) {
|
||||
return EnsureAppDir()
|
||||
}
|
||||
|
||||
func GetFFmpegPath() (string, error) {
|
||||
ffmpegDir, err := GetFFmpegDir()
|
||||
if err != nil {
|
||||
@@ -91,7 +94,39 @@ func GetFFmpegPath() (string, error) {
|
||||
ffmpegName = "ffmpeg.exe"
|
||||
}
|
||||
|
||||
return filepath.Join(ffmpegDir, ffmpegName), nil
|
||||
localPath := filepath.Join(ffmpegDir, ffmpegName)
|
||||
if _, err := os.Stat(localPath); err == nil {
|
||||
return localPath, nil
|
||||
}
|
||||
|
||||
if runtime.GOOS == "darwin" && runtime.GOARCH == "arm64" {
|
||||
homebrewPath := "/opt/homebrew/bin/" + ffmpegName
|
||||
if _, err := os.Stat(homebrewPath); err == nil {
|
||||
return homebrewPath, nil
|
||||
}
|
||||
} else if runtime.GOOS == "darwin" && runtime.GOARCH == "amd64" {
|
||||
homebrewPath := "/usr/local/bin/" + ffmpegName
|
||||
if _, err := os.Stat(homebrewPath); err == nil {
|
||||
return homebrewPath, nil
|
||||
}
|
||||
}
|
||||
|
||||
if runtime.GOOS != "windows" {
|
||||
path, err := exec.Command("which", ffmpegName).Output()
|
||||
if err == nil {
|
||||
trimmed := strings.TrimSpace(string(path))
|
||||
if trimmed != "" {
|
||||
return trimmed, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
path, err := exec.LookPath(ffmpegName)
|
||||
if err == nil {
|
||||
return path, nil
|
||||
}
|
||||
|
||||
return localPath, nil
|
||||
}
|
||||
|
||||
func GetFFprobePath() (string, error) {
|
||||
@@ -105,12 +140,39 @@ func GetFFprobePath() (string, error) {
|
||||
ffprobeName = "ffprobe.exe"
|
||||
}
|
||||
|
||||
ffprobePath := filepath.Join(ffmpegDir, ffprobeName)
|
||||
if _, err := os.Stat(ffprobePath); err == nil {
|
||||
return ffprobePath, nil
|
||||
localPath := filepath.Join(ffmpegDir, ffprobeName)
|
||||
if _, err := os.Stat(localPath); err == nil {
|
||||
return localPath, nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("ffprobe not found in app directory")
|
||||
if runtime.GOOS == "darwin" && runtime.GOARCH == "arm64" {
|
||||
homebrewPath := "/opt/homebrew/bin/" + ffprobeName
|
||||
if _, err := os.Stat(homebrewPath); err == nil {
|
||||
return homebrewPath, nil
|
||||
}
|
||||
} else if runtime.GOOS == "darwin" && runtime.GOARCH == "amd64" {
|
||||
homebrewPath := "/usr/local/bin/" + ffprobeName
|
||||
if _, err := os.Stat(homebrewPath); err == nil {
|
||||
return homebrewPath, nil
|
||||
}
|
||||
}
|
||||
|
||||
if runtime.GOOS != "windows" {
|
||||
path, err := exec.Command("which", ffprobeName).Output()
|
||||
if err == nil {
|
||||
trimmed := strings.TrimSpace(string(path))
|
||||
if trimmed != "" {
|
||||
return trimmed, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
path, err := exec.LookPath(ffprobeName)
|
||||
if err == nil {
|
||||
return path, nil
|
||||
}
|
||||
|
||||
return localPath, fmt.Errorf("ffprobe not found in app directory or system path")
|
||||
}
|
||||
|
||||
func IsFFprobeInstalled() (bool, error) {
|
||||
@@ -146,6 +208,58 @@ func IsFFmpegInstalled() (bool, error) {
|
||||
return err == nil, nil
|
||||
}
|
||||
|
||||
func GetBrewPath() string {
|
||||
brewPaths := []string{
|
||||
"/opt/homebrew/bin/brew",
|
||||
"/usr/local/bin/brew",
|
||||
}
|
||||
|
||||
for _, path := range brewPaths {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func IsBrewFFmpegInstalled() (bool, error) {
|
||||
brewPath := GetBrewPath()
|
||||
if brewPath == "" {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
cmd := exec.Command(brewPath, "list", "ffmpeg")
|
||||
setHideWindow(cmd)
|
||||
err := cmd.Run()
|
||||
return err == nil, nil
|
||||
}
|
||||
|
||||
func InstallFFmpegWithBrew(progressCallback func(int, string)) error {
|
||||
brewPath := GetBrewPath()
|
||||
if brewPath == "" {
|
||||
return fmt.Errorf("brew not found")
|
||||
}
|
||||
|
||||
progressCallback(10, "Installing FFmpeg via Homebrew...")
|
||||
|
||||
cmd := exec.Command(brewPath, "install", "ffmpeg")
|
||||
setHideWindow(cmd)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to install ffmpeg: %w - %s", err, string(output))
|
||||
}
|
||||
|
||||
progressCallback(100, "done")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
const (
|
||||
ffmpegWindowsURL = "https://github.com/afkarxyz/ffmpeg-binaries/releases/download/v8.0/ffmpeg-windows-amd64.zip"
|
||||
ffmpegLinuxURL = "https://github.com/afkarxyz/ffmpeg-binaries/releases/download/v8.0/ffmpeg-linux-amd64.tar.xz"
|
||||
)
|
||||
|
||||
func DownloadFFmpeg(progressCallback func(int)) error {
|
||||
|
||||
SetDownloadProgress(0)
|
||||
@@ -166,54 +280,51 @@ func DownloadFFmpeg(progressCallback func(int)) error {
|
||||
ffmpegInstalled, _ := IsFFmpegInstalled()
|
||||
ffprobeInstalled, _ := IsFFprobeInstalled()
|
||||
|
||||
if !ffmpegInstalled && !ffprobeInstalled {
|
||||
isARM := runtime.GOARCH == "arm64"
|
||||
|
||||
ffmpegURL, _ := decodeBase64(ffmpegMacOSURL)
|
||||
fmt.Printf("[FFmpeg] Downloading ffmpeg from: %s\n", ffmpegURL)
|
||||
if err := downloadAndExtract(ffmpegURL, ffmpegDir, progressCallback, 0, 50); err != nil {
|
||||
var macFFmpegURLs []string
|
||||
var macFFprobeURLs []string
|
||||
|
||||
if isARM {
|
||||
|
||||
macFFmpegURLs = []string{"https://github.com/afkarxyz/ffmpeg-binaries/releases/download/v8.0/ffmpeg-macos-arm64.zip"}
|
||||
macFFprobeURLs = []string{"https://github.com/afkarxyz/ffmpeg-binaries/releases/download/v8.0/ffprobe-macos-arm64.zip"}
|
||||
} else {
|
||||
|
||||
macFFmpegURLs = []string{"https://github.com/afkarxyz/ffmpeg-binaries/releases/download/v8.0/ffmpeg-macos-intel.zip"}
|
||||
macFFprobeURLs = []string{"https://github.com/afkarxyz/ffmpeg-binaries/releases/download/v8.0/ffprobe-macos-intel.zip"}
|
||||
}
|
||||
|
||||
if !ffmpegInstalled && !ffprobeInstalled {
|
||||
if err := downloadWithFallback(macFFmpegURLs, ffmpegDir, progressCallback, 0, 50); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ffprobeURL, _ := decodeBase64(ffprobeMacOSURL)
|
||||
fmt.Printf("[FFmpeg] Downloading ffprobe from: %s\n", ffprobeURL)
|
||||
if err := downloadAndExtract(ffprobeURL, ffmpegDir, progressCallback, 50, 100); err != nil {
|
||||
return fmt.Errorf("failed to download ffprobe: %w", err)
|
||||
if err := downloadWithFallback(macFFprobeURLs, ffmpegDir, progressCallback, 50, 100); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if !ffmpegInstalled {
|
||||
|
||||
ffmpegURL, _ := decodeBase64(ffmpegMacOSURL)
|
||||
fmt.Printf("[FFmpeg] Downloading ffmpeg from: %s\n", ffmpegURL)
|
||||
if err := downloadAndExtract(ffmpegURL, ffmpegDir, progressCallback, 0, 100); err != nil {
|
||||
if err := downloadWithFallback(macFFmpegURLs, ffmpegDir, progressCallback, 0, 100); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if !ffprobeInstalled {
|
||||
|
||||
ffprobeURL, _ := decodeBase64(ffprobeMacOSURL)
|
||||
fmt.Printf("[FFmpeg] Downloading ffprobe from: %s\n", ffprobeURL)
|
||||
if err := downloadAndExtract(ffprobeURL, ffmpegDir, progressCallback, 0, 100); err != nil {
|
||||
return fmt.Errorf("failed to download ffprobe: %w", err)
|
||||
if err := downloadWithFallback(macFFprobeURLs, ffmpegDir, progressCallback, 0, 100); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var encodedURL string
|
||||
var url string
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
encodedURL = ffmpegWindowsURL
|
||||
url = ffmpegWindowsURL
|
||||
case "linux":
|
||||
encodedURL = ffmpegLinuxURL
|
||||
url = ffmpegLinuxURL
|
||||
default:
|
||||
return fmt.Errorf("unsupported operating system: %s", runtime.GOOS)
|
||||
}
|
||||
|
||||
url, err := decodeBase64(encodedURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decode ffmpeg URL: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("[FFmpeg] Downloading from: %s\n", url)
|
||||
|
||||
if err := downloadAndExtract(url, ffmpegDir, progressCallback, 0, 100); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -221,6 +332,20 @@ func DownloadFFmpeg(progressCallback func(int)) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func downloadWithFallback(urls []string, destDir string, progressCallback func(int), start, end int) error {
|
||||
var lastErr error
|
||||
for _, url := range urls {
|
||||
fmt.Printf("[FFmpeg] Trying to download from: %s\n", url)
|
||||
err := downloadAndExtract(url, destDir, progressCallback, start, end)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
lastErr = err
|
||||
fmt.Printf("[FFmpeg] Attempt failed: %v\n", err)
|
||||
}
|
||||
return fmt.Errorf("all download attempts failed: %w", lastErr)
|
||||
}
|
||||
|
||||
func downloadAndExtract(url, destDir string, progressCallback func(int), progressStart, progressEnd int) error {
|
||||
|
||||
tmpFile, err := os.CreateTemp("", "ffmpeg-*")
|
||||
@@ -230,7 +355,14 @@ func downloadAndExtract(url, destDir string, progressCallback func(int), progres
|
||||
defer os.Remove(tmpFile.Name())
|
||||
defer tmpFile.Close()
|
||||
|
||||
resp, err := http.Get(url)
|
||||
client := &http.Client{}
|
||||
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/145.0.0.0 Safari/537.36")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to download: %w", err)
|
||||
}
|
||||
@@ -536,6 +668,7 @@ func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) {
|
||||
|
||||
outputExt := "." + strings.ToLower(req.OutputFormat)
|
||||
outputFile := filepath.Join(outputDir, baseName+outputExt)
|
||||
outputFile = norm.NFC.String(outputFile)
|
||||
|
||||
if inputExt == outputExt {
|
||||
result.Error = "Input and output formats are the same"
|
||||
@@ -557,7 +690,11 @@ func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) {
|
||||
fmt.Printf("[FFmpeg] Warning: Failed to extract metadata from %s: %v\n", inputFile, err)
|
||||
}
|
||||
|
||||
coverArtPath, _ = ExtractCoverArt(inputFile)
|
||||
inputFile = norm.NFC.String(inputFile)
|
||||
coverArtPath, err = ExtractCoverArt(inputFile)
|
||||
if err != nil {
|
||||
fmt.Printf("[FFmpeg] Warning: Failed to extract cover art from %s: %v\n", inputFile, err)
|
||||
}
|
||||
lyrics, err = ExtractLyrics(inputFile)
|
||||
if err != nil {
|
||||
fmt.Printf("[FFmpeg] Warning: Failed to extract lyrics from %s: %v\n", inputFile, err)
|
||||
|
||||
@@ -11,8 +11,8 @@ func SelectMultipleFiles(ctx context.Context) ([]string, error) {
|
||||
Title: "Select Audio Files",
|
||||
Filters: []runtime.FileFilter{
|
||||
{
|
||||
DisplayName: "Audio Files (*.mp3, *.m4a, *.flac)",
|
||||
Pattern: "*.mp3;*.m4a;*.flac",
|
||||
DisplayName: "Audio Files (*.mp3, *.m4a, *.flac, *.aac)",
|
||||
Pattern: "*.mp3;*.m4a;*.flac;*.aac",
|
||||
},
|
||||
{
|
||||
DisplayName: "MP3 Files (*.mp3)",
|
||||
@@ -26,6 +26,10 @@ func SelectMultipleFiles(ctx context.Context) ([]string, error) {
|
||||
DisplayName: "FLAC Files (*.flac)",
|
||||
Pattern: "*.flac",
|
||||
},
|
||||
{
|
||||
DisplayName: "AAC Files (*.aac)",
|
||||
Pattern: "*.aac",
|
||||
},
|
||||
{
|
||||
DisplayName: "All Files (*.*)",
|
||||
Pattern: "*.*",
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
//go:build darwin
|
||||
|
||||
package backend
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func SetMacOSFileIconFromImage(filePath, imagePath string, iconSize int) error {
|
||||
if filePath == "" {
|
||||
return fmt.Errorf("file path is required")
|
||||
}
|
||||
if imagePath == "" {
|
||||
return fmt.Errorf("image path is required")
|
||||
}
|
||||
|
||||
resizedPath, err := ResizeImageForIcon(imagePath, iconSize)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(resizedPath)
|
||||
|
||||
script := `
|
||||
use framework "AppKit"
|
||||
on run argv
|
||||
set imagePath to item 1 of argv
|
||||
set targetPath to item 2 of argv
|
||||
set iconImage to current application's NSImage's alloc()'s initWithContentsOfFile:imagePath
|
||||
if iconImage is missing value then error "Failed to load icon image"
|
||||
set didSet to (current application's NSWorkspace's sharedWorkspace()'s setIcon:iconImage forFile:targetPath options:0) as boolean
|
||||
if didSet is false then error "Failed to set custom file icon"
|
||||
end run
|
||||
`
|
||||
|
||||
cmd := exec.Command("osascript", "-", resizedPath, filePath)
|
||||
cmd.Stdin = strings.NewReader(script)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to apply macOS file icon: %v (%s)", err, strings.TrimSpace(string(output)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build !darwin
|
||||
|
||||
package backend
|
||||
|
||||
func SetMacOSFileIconFromImage(filePath, imagePath string, iconSize int) error {
|
||||
return nil
|
||||
}
|
||||
@@ -94,7 +94,7 @@ func ListAudioFiles(dirPath string) ([]FileInfo, error) {
|
||||
}
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
if ext == ".flac" || ext == ".mp3" || ext == ".m4a" {
|
||||
if ext == ".flac" || ext == ".mp3" || ext == ".m4a" || ext == ".aac" {
|
||||
result = append(result, FileInfo{
|
||||
Name: info.Name(),
|
||||
Path: path,
|
||||
@@ -332,6 +332,7 @@ func GenerateFilename(metadata *AudioMetadata, format string, ext string) string
|
||||
result = strings.ReplaceAll(result, "{album}", sanitizeFilenameForRename(metadata.Album))
|
||||
result = strings.ReplaceAll(result, "{album_artist}", sanitizeFilenameForRename(metadata.AlbumArtist))
|
||||
result = strings.ReplaceAll(result, "{year}", sanitizeFilenameForRename(year))
|
||||
result = strings.ReplaceAll(result, "{date}", sanitizeFilenameForRename(metadata.Year))
|
||||
|
||||
if metadata.TrackNumber > 0 {
|
||||
result = strings.ReplaceAll(result, "{track}", fmt.Sprintf("%02d", metadata.TrackNumber))
|
||||
|
||||
@@ -9,12 +9,15 @@ import (
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
func BuildExpectedFilename(trackName, artistName, albumName, albumArtist, releaseDate, filenameFormat string, includeTrackNumber bool, position, discNumber int, useAlbumTrackNumber bool) string {
|
||||
func BuildExpectedFilename(trackName, artistName, albumName, albumArtist, releaseDate, filenameFormat, playlistName, playlistOwner string, includeTrackNumber bool, position, discNumber int, useAlbumTrackNumber bool) string {
|
||||
|
||||
safeTitle := sanitizeFilename(trackName)
|
||||
safeArtist := sanitizeFilename(artistName)
|
||||
safeAlbum := sanitizeFilename(albumName)
|
||||
safeAlbumArtist := sanitizeFilename(albumArtist)
|
||||
safeTitle := SanitizeFilename(trackName)
|
||||
safeArtist := SanitizeFilename(artistName)
|
||||
safeAlbum := SanitizeFilename(albumName)
|
||||
safeAlbumArtist := SanitizeFilename(albumArtist)
|
||||
|
||||
safePlaylist := SanitizeFilename(playlistName)
|
||||
safeCreator := SanitizeFilename(playlistOwner)
|
||||
|
||||
year := ""
|
||||
if len(releaseDate) >= 4 {
|
||||
@@ -30,6 +33,9 @@ func BuildExpectedFilename(trackName, artistName, albumName, albumArtist, releas
|
||||
filename = strings.ReplaceAll(filename, "{album}", safeAlbum)
|
||||
filename = strings.ReplaceAll(filename, "{album_artist}", safeAlbumArtist)
|
||||
filename = strings.ReplaceAll(filename, "{year}", year)
|
||||
filename = strings.ReplaceAll(filename, "{date}", SanitizeFilename(releaseDate))
|
||||
filename = strings.ReplaceAll(filename, "{playlist}", safePlaylist)
|
||||
filename = strings.ReplaceAll(filename, "{creator}", safeCreator)
|
||||
|
||||
if discNumber > 0 {
|
||||
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
|
||||
@@ -64,7 +70,7 @@ func BuildExpectedFilename(trackName, artistName, albumName, albumArtist, releas
|
||||
return filename + ".flac"
|
||||
}
|
||||
|
||||
func sanitizeFilename(name string) string {
|
||||
func SanitizeFilename(name string) string {
|
||||
|
||||
sanitized := strings.ReplaceAll(name, "/", " ")
|
||||
|
||||
@@ -113,11 +119,40 @@ func sanitizeFilename(name string) string {
|
||||
return sanitized
|
||||
}
|
||||
|
||||
func NormalizePath(folderPath string) string {
|
||||
func GetFirstArtist(artistString string) string {
|
||||
if artistString == "" {
|
||||
return ""
|
||||
}
|
||||
delimiters := []string{", ", " & ", " feat. ", " ft. ", " featuring "}
|
||||
for _, d := range delimiters {
|
||||
if idx := strings.Index(strings.ToLower(artistString), d); idx != -1 {
|
||||
return strings.TrimSpace(artistString[:idx])
|
||||
}
|
||||
}
|
||||
return artistString
|
||||
}
|
||||
|
||||
func NormalizePath(folderPath string) string {
|
||||
return strings.ReplaceAll(folderPath, "/", string(filepath.Separator))
|
||||
}
|
||||
|
||||
func GetSeparator() string {
|
||||
settings, err := LoadConfigSettings()
|
||||
if err != nil || settings == nil {
|
||||
return "; "
|
||||
}
|
||||
|
||||
if sep, ok := settings["separator"].(string); ok {
|
||||
if sep == "comma" {
|
||||
return ", "
|
||||
}
|
||||
if sep == "semicolon" {
|
||||
return "; "
|
||||
}
|
||||
}
|
||||
return "; "
|
||||
}
|
||||
|
||||
func SanitizeFolderPath(folderPath string) string {
|
||||
|
||||
normalizedPath := strings.ReplaceAll(folderPath, "/", string(filepath.Separator))
|
||||
@@ -148,7 +183,8 @@ func SanitizeFolderPath(folderPath string) string {
|
||||
return strings.Join(sanitizedParts, sep)
|
||||
}
|
||||
|
||||
func sanitizeFolderName(name string) string {
|
||||
func sanitizeFolderName(name string) string { return SanitizeFilename(name) }
|
||||
|
||||
return sanitizeFilename(name)
|
||||
func sanitizeFilename(name string) string {
|
||||
return SanitizeFilename(name)
|
||||
}
|
||||
|
||||
@@ -50,12 +50,28 @@ func SelectFolderDialog(ctx context.Context, defaultPath string) (string, error)
|
||||
|
||||
func SelectFileDialog(ctx context.Context) (string, error) {
|
||||
options := wailsRuntime.OpenDialogOptions{
|
||||
Title: "Select FLAC File for Analysis",
|
||||
Title: "Select Audio File for Analysis",
|
||||
Filters: []wailsRuntime.FileFilter{
|
||||
{
|
||||
DisplayName: "Audio Files (*.flac;*.mp3;*.m4a;*.aac)",
|
||||
Pattern: "*.flac;*.mp3;*.m4a;*.aac",
|
||||
},
|
||||
{
|
||||
DisplayName: "FLAC Audio Files (*.flac)",
|
||||
Pattern: "*.flac",
|
||||
},
|
||||
{
|
||||
DisplayName: "MP3 Audio Files (*.mp3)",
|
||||
Pattern: "*.mp3",
|
||||
},
|
||||
{
|
||||
DisplayName: "M4A Audio Files (*.m4a)",
|
||||
Pattern: "*.m4a",
|
||||
},
|
||||
{
|
||||
DisplayName: "AAC Audio Files (*.aac)",
|
||||
Pattern: "*.aac",
|
||||
},
|
||||
{
|
||||
DisplayName: "All Files (*.*)",
|
||||
Pattern: "*.*",
|
||||
@@ -74,3 +90,26 @@ func SelectFileDialog(ctx context.Context) (string, error) {
|
||||
|
||||
return selectedFile, nil
|
||||
}
|
||||
|
||||
func SelectImageVideoDialog(ctx context.Context) ([]string, error) {
|
||||
options := wailsRuntime.OpenDialogOptions{
|
||||
Title: "Select Image or Video",
|
||||
Filters: []wailsRuntime.FileFilter{
|
||||
{
|
||||
DisplayName: "Supported Files (*.jpg, *.png, *.mp4, *.mov, ...)",
|
||||
Pattern: "*.jpg;*.jpeg;*.png;*.gif;*.webp;*.mp4;*.mkv;*.webm;*.mov",
|
||||
},
|
||||
{
|
||||
DisplayName: "All Files (*.*)",
|
||||
Pattern: "*.*",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
selectedPaths, err := wailsRuntime.OpenMultipleFilesDialog(ctx, options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return selectedPaths, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
bolt "go.etcd.io/bbolt"
|
||||
)
|
||||
|
||||
type HistoryItem struct {
|
||||
ID string `json:"id"`
|
||||
SpotifyID string `json:"spotify_id"`
|
||||
Title string `json:"title"`
|
||||
Artists string `json:"artists"`
|
||||
Album string `json:"album"`
|
||||
DurationStr string `json:"duration_str"`
|
||||
CoverURL string `json:"cover_url"`
|
||||
Quality string `json:"quality"`
|
||||
Format string `json:"format"`
|
||||
Path string `json:"path"`
|
||||
Source string `json:"source"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
}
|
||||
|
||||
var historyDB *bolt.DB
|
||||
|
||||
const (
|
||||
historyBucket = "DownloadHistory"
|
||||
maxHistory = 10000
|
||||
)
|
||||
|
||||
func InitHistoryDB(appName string) error {
|
||||
|
||||
appDir, err := EnsureAppDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dbPath := filepath.Join(appDir, "history.db")
|
||||
|
||||
db, err := bolt.Open(dbPath, 0600, &bolt.Options{Timeout: 1 * time.Second})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = db.Update(func(tx *bolt.Tx) error {
|
||||
_, err := tx.CreateBucketIfNotExists([]byte(historyBucket))
|
||||
return err
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
db.Close()
|
||||
return err
|
||||
}
|
||||
|
||||
historyDB = db
|
||||
return nil
|
||||
}
|
||||
|
||||
func CloseHistoryDB() {
|
||||
if historyDB != nil {
|
||||
historyDB.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func AddHistoryItem(item HistoryItem, appName string) error {
|
||||
if historyDB == nil {
|
||||
if err := InitHistoryDB(appName); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return historyDB.Update(func(tx *bolt.Tx) error {
|
||||
b, err := tx.CreateBucketIfNotExists([]byte(historyBucket))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id, _ := b.NextSequence()
|
||||
|
||||
item.ID = fmt.Sprintf("%d-%d", time.Now().UnixNano(), id)
|
||||
item.Timestamp = time.Now().Unix()
|
||||
|
||||
buf, err := json.Marshal(item)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if b.Stats().KeyN >= maxHistory {
|
||||
c := b.Cursor()
|
||||
|
||||
toDelete := maxHistory / 20
|
||||
if toDelete < 1 {
|
||||
toDelete = 1
|
||||
}
|
||||
|
||||
count := 0
|
||||
for k, _ := c.First(); k != nil && count < toDelete; k, _ = c.Next() {
|
||||
if err := b.Delete(k); err != nil {
|
||||
return err
|
||||
}
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
return b.Put([]byte(item.ID), buf)
|
||||
})
|
||||
}
|
||||
|
||||
func GetHistoryItems(appName string) ([]HistoryItem, error) {
|
||||
if historyDB == nil {
|
||||
if err := InitHistoryDB(appName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
var items []HistoryItem
|
||||
err := historyDB.View(func(tx *bolt.Tx) error {
|
||||
b := tx.Bucket([]byte(historyBucket))
|
||||
if b == nil {
|
||||
return nil
|
||||
}
|
||||
c := b.Cursor()
|
||||
|
||||
for k, v := c.First(); k != nil; k, v = c.Next() {
|
||||
var item HistoryItem
|
||||
if err := json.Unmarshal(v, &item); err == nil {
|
||||
items = append(items, item)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
return items[i].Timestamp > items[j].Timestamp
|
||||
})
|
||||
|
||||
return items, err
|
||||
}
|
||||
|
||||
func ClearHistory(appName string) error {
|
||||
if historyDB == nil {
|
||||
if err := InitHistoryDB(appName); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return historyDB.Update(func(tx *bolt.Tx) error {
|
||||
return tx.DeleteBucket([]byte(historyBucket))
|
||||
})
|
||||
}
|
||||
|
||||
type FetchHistoryItem struct {
|
||||
ID string `json:"id"`
|
||||
URL string `json:"url"`
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Info string `json:"info"`
|
||||
Image string `json:"image"`
|
||||
Data string `json:"data"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
}
|
||||
|
||||
const (
|
||||
fetchHistoryBucket = "FetchHistory"
|
||||
)
|
||||
|
||||
func AddFetchHistoryItem(item FetchHistoryItem, appName string) error {
|
||||
if historyDB == nil {
|
||||
if err := InitHistoryDB(appName); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return historyDB.Update(func(tx *bolt.Tx) error {
|
||||
b, err := tx.CreateBucketIfNotExists([]byte(fetchHistoryBucket))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id, _ := b.NextSequence()
|
||||
|
||||
if item.URL != "" {
|
||||
c := b.Cursor()
|
||||
for k, v := c.First(); k != nil; k, v = c.Next() {
|
||||
var existing FetchHistoryItem
|
||||
if err := json.Unmarshal(v, &existing); err == nil {
|
||||
if existing.URL == item.URL && existing.Type == item.Type {
|
||||
if err := b.Delete(k); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item.ID = fmt.Sprintf("%d-%d", time.Now().UnixNano(), id)
|
||||
item.Timestamp = time.Now().Unix()
|
||||
|
||||
buf, err := json.Marshal(item)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if b.Stats().KeyN >= maxHistory {
|
||||
c := b.Cursor()
|
||||
toDelete := maxHistory / 20
|
||||
if toDelete < 1 {
|
||||
toDelete = 1
|
||||
}
|
||||
count := 0
|
||||
for k, _ := c.First(); k != nil && count < toDelete; k, _ = c.Next() {
|
||||
if err := b.Delete(k); err != nil {
|
||||
return err
|
||||
}
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
return b.Put([]byte(item.ID), buf)
|
||||
})
|
||||
}
|
||||
|
||||
func GetFetchHistoryItems(appName string) ([]FetchHistoryItem, error) {
|
||||
if historyDB == nil {
|
||||
if err := InitHistoryDB(appName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
var items []FetchHistoryItem
|
||||
err := historyDB.View(func(tx *bolt.Tx) error {
|
||||
b := tx.Bucket([]byte(fetchHistoryBucket))
|
||||
if b == nil {
|
||||
return nil
|
||||
}
|
||||
c := b.Cursor()
|
||||
|
||||
for k, v := c.First(); k != nil; k, v = c.Next() {
|
||||
var item FetchHistoryItem
|
||||
if err := json.Unmarshal(v, &item); err == nil {
|
||||
items = append(items, item)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
return items[i].Timestamp > items[j].Timestamp
|
||||
})
|
||||
|
||||
return items, err
|
||||
}
|
||||
|
||||
func ClearFetchHistory(appName string) error {
|
||||
if historyDB == nil {
|
||||
if err := InitHistoryDB(appName); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return historyDB.Update(func(tx *bolt.Tx) error {
|
||||
return tx.DeleteBucket([]byte(fetchHistoryBucket))
|
||||
})
|
||||
}
|
||||
|
||||
func ClearFetchHistoryByType(itemType string, appName string) error {
|
||||
if historyDB == nil {
|
||||
if err := InitHistoryDB(appName); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return historyDB.Update(func(tx *bolt.Tx) error {
|
||||
b := tx.Bucket([]byte(fetchHistoryBucket))
|
||||
if b == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var keysToDelete [][]byte
|
||||
|
||||
c := b.Cursor()
|
||||
for k, v := c.First(); k != nil; k, v = c.Next() {
|
||||
var item FetchHistoryItem
|
||||
if err := json.Unmarshal(v, &item); err == nil {
|
||||
if item.Type == itemType {
|
||||
keysToDelete = append(keysToDelete, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, k := range keysToDelete {
|
||||
if err := b.Delete(k); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func DeleteHistoryItem(id string, appName string) error {
|
||||
if historyDB == nil {
|
||||
if err := InitHistoryDB(appName); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return historyDB.Update(func(tx *bolt.Tx) error {
|
||||
b := tx.Bucket([]byte(historyBucket))
|
||||
if b == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return b.Delete([]byte(id))
|
||||
})
|
||||
}
|
||||
|
||||
func DeleteFetchHistoryItem(id string, appName string) error {
|
||||
if historyDB == nil {
|
||||
if err := InitHistoryDB(appName); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return historyDB.Update(func(tx *bolt.Tx) error {
|
||||
b := tx.Bucket([]byte(fetchHistoryBucket))
|
||||
if b == nil {
|
||||
return nil
|
||||
}
|
||||
return b.Delete([]byte(id))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
bolt "go.etcd.io/bbolt"
|
||||
)
|
||||
|
||||
const (
|
||||
isrcCacheDBFile = "isrc_cache.db"
|
||||
isrcCacheBucket = "SpotifyTrackISRC"
|
||||
)
|
||||
|
||||
type isrcCacheEntry struct {
|
||||
TrackID string `json:"track_id"`
|
||||
ISRC string `json:"isrc"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
var (
|
||||
isrcCacheDB *bolt.DB
|
||||
isrcCacheDBMu sync.Mutex
|
||||
)
|
||||
|
||||
func InitISRCCacheDB() error {
|
||||
isrcCacheDBMu.Lock()
|
||||
defer isrcCacheDBMu.Unlock()
|
||||
|
||||
if isrcCacheDB != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
appDir, err := EnsureAppDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dbPath := filepath.Join(appDir, isrcCacheDBFile)
|
||||
db, err := bolt.Open(dbPath, 0o600, &bolt.Options{Timeout: 1 * time.Second})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := db.Update(func(tx *bolt.Tx) error {
|
||||
_, err := tx.CreateBucketIfNotExists([]byte(isrcCacheBucket))
|
||||
return err
|
||||
}); err != nil {
|
||||
db.Close()
|
||||
return err
|
||||
}
|
||||
|
||||
isrcCacheDB = db
|
||||
return nil
|
||||
}
|
||||
|
||||
func CloseISRCCacheDB() {
|
||||
isrcCacheDBMu.Lock()
|
||||
defer isrcCacheDBMu.Unlock()
|
||||
|
||||
if isrcCacheDB != nil {
|
||||
_ = isrcCacheDB.Close()
|
||||
isrcCacheDB = nil
|
||||
}
|
||||
}
|
||||
|
||||
func GetCachedISRC(trackID string) (string, error) {
|
||||
normalizedTrackID := strings.TrimSpace(trackID)
|
||||
if normalizedTrackID == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
if err := InitISRCCacheDB(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var cachedISRC string
|
||||
err := isrcCacheDB.View(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte(isrcCacheBucket))
|
||||
if bucket == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
value := bucket.Get([]byte(normalizedTrackID))
|
||||
if len(value) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var entry isrcCacheEntry
|
||||
if err := json.Unmarshal(value, &entry); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cachedISRC = strings.ToUpper(strings.TrimSpace(entry.ISRC))
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return cachedISRC, nil
|
||||
}
|
||||
|
||||
func PutCachedISRC(trackID string, isrc string) error {
|
||||
normalizedTrackID := strings.TrimSpace(trackID)
|
||||
normalizedISRC := strings.ToUpper(strings.TrimSpace(isrc))
|
||||
if normalizedTrackID == "" || normalizedISRC == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := InitISRCCacheDB(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
entry := isrcCacheEntry{
|
||||
TrackID: normalizedTrackID,
|
||||
ISRC: normalizedISRC,
|
||||
UpdatedAt: time.Now().Unix(),
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(entry)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encode ISRC cache entry: %w", err)
|
||||
}
|
||||
|
||||
return isrcCacheDB.Update(func(tx *bolt.Tx) error {
|
||||
bucket, err := tx.CreateBucketIfNotExists([]byte(isrcCacheBucket))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return bucket.Put([]byte(normalizedTrackID), payload)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,572 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha1"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
spotifyServerTimeURL = "https://open.spotify.com/api/server-time"
|
||||
spotifySessionTokenURL = "https://open.spotify.com/api/token"
|
||||
spotifyTOTPSecretsURL = "https://git.gay/thereallo/totp-secrets/raw/branch/main/secrets/secretDict.json"
|
||||
spotifyGIDMetadataURL = "https://spclient.wg.spotify.com/metadata/4/%s/%s?market=from_token"
|
||||
spotifyTOTPPeriod = 30
|
||||
spotifyTOTPDigits = 6
|
||||
spotifyBase62Alphabet = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
spotifyTokenCacheFile = ".isrc-finder-token.json"
|
||||
spotifySecretsCacheFile = "spotify-secret-dict-cache.json"
|
||||
spotifySecretsCacheTTL = 24 * time.Hour
|
||||
)
|
||||
|
||||
var spotifyAnonymousTokenMu sync.Mutex
|
||||
|
||||
type spotifyAnonymousToken struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
AccessTokenExpirationTimestampMs int64 `json:"accessTokenExpirationTimestampMs"`
|
||||
}
|
||||
|
||||
type spotifyServerTimeResponse struct {
|
||||
ServerTime int64 `json:"serverTime"`
|
||||
}
|
||||
|
||||
type spotifySecretsCache struct {
|
||||
FetchedAtUnix int64 `json:"fetched_at_unix"`
|
||||
Secrets map[string][]int `json:"secrets"`
|
||||
}
|
||||
|
||||
type spotifyTrackRawData struct {
|
||||
ExternalID []struct {
|
||||
Type string `json:"type"`
|
||||
ID string `json:"id"`
|
||||
} `json:"external_id"`
|
||||
}
|
||||
|
||||
type spotFetchISRCResponse struct {
|
||||
Input string `json:"input"`
|
||||
TrackID string `json:"track_id"`
|
||||
GID string `json:"gid"`
|
||||
CanonicalURI string `json:"canonical_uri"`
|
||||
Name string `json:"name"`
|
||||
Artists []string `json:"artists"`
|
||||
AlbumName string `json:"album_name"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
Label string `json:"label"`
|
||||
ISRC string `json:"isrc"`
|
||||
}
|
||||
|
||||
func (s *SongLinkClient) lookupSpotifyISRC(spotifyTrackID string) (string, error) {
|
||||
normalizedTrackID, err := extractSpotifyTrackID(spotifyTrackID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
cachedISRC, err := GetCachedISRC(normalizedTrackID)
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: failed to read ISRC cache: %v\n", err)
|
||||
} else if cachedISRC != "" {
|
||||
fmt.Printf("Found ISRC in cache: %s\n", cachedISRC)
|
||||
return cachedISRC, nil
|
||||
}
|
||||
|
||||
useSpotFetchAPI, spotFetchAPIURL := GetSpotFetchAPISettings()
|
||||
if useSpotFetchAPI {
|
||||
isrc, resolvedTrackID, err := s.lookupSpotifyISRCViaSpotFetchAPI(normalizedTrackID, spotFetchAPIURL)
|
||||
if err == nil && isrc != "" {
|
||||
fmt.Printf("Found ISRC via SpotFetch API: %s\n", isrc)
|
||||
cacheResolvedSpotifyTrackISRC(normalizedTrackID, resolvedTrackID, isrc)
|
||||
return isrc, nil
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: SpotFetch ISRC lookup failed, falling back to Spotify metadata: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
payload, metadataErr := fetchSpotifyTrackRawData(s.client, normalizedTrackID)
|
||||
if metadataErr == nil {
|
||||
isrc, extractErr := extractSpotifyTrackISRC(payload)
|
||||
if extractErr == nil {
|
||||
fmt.Printf("Found ISRC via Spotify metadata: %s\n", isrc)
|
||||
cacheResolvedSpotifyTrackISRC(normalizedTrackID, "", isrc)
|
||||
return isrc, nil
|
||||
}
|
||||
metadataErr = extractErr
|
||||
}
|
||||
|
||||
if metadataErr != nil {
|
||||
fmt.Printf("Warning: Spotify metadata ISRC lookup failed, falling back to Soundplate: %v\n", metadataErr)
|
||||
}
|
||||
|
||||
isrc, resolvedTrackID, soundplateErr := s.lookupSpotifyISRCViaSoundplate(normalizedTrackID)
|
||||
if soundplateErr == nil && isrc != "" {
|
||||
fmt.Printf("Found ISRC via Soundplate: %s\n", isrc)
|
||||
cacheResolvedSpotifyTrackISRC(normalizedTrackID, resolvedTrackID, isrc)
|
||||
return isrc, nil
|
||||
}
|
||||
|
||||
if metadataErr != nil && soundplateErr != nil {
|
||||
return "", fmt.Errorf("spotify metadata lookup failed: %v | soundplate lookup failed: %w", metadataErr, soundplateErr)
|
||||
}
|
||||
if soundplateErr != nil {
|
||||
return "", soundplateErr
|
||||
}
|
||||
return "", metadataErr
|
||||
}
|
||||
|
||||
func cacheResolvedSpotifyTrackISRC(trackID string, resolvedTrackID string, isrc string) {
|
||||
if err := PutCachedISRC(trackID, isrc); err != nil {
|
||||
fmt.Printf("Warning: failed to write ISRC cache: %v\n", err)
|
||||
}
|
||||
if resolvedTrackID != "" && resolvedTrackID != trackID {
|
||||
if err := PutCachedISRC(resolvedTrackID, isrc); err != nil {
|
||||
fmt.Printf("Warning: failed to write ISRC cache for resolved track ID: %v\n", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SongLinkClient) lookupSpotifyISRCViaSpotFetchAPI(spotifyTrackID string, apiBaseURL string) (string, string, error) {
|
||||
normalizedTrackID := strings.TrimSpace(spotifyTrackID)
|
||||
baseURL := strings.TrimRight(strings.TrimSpace(apiBaseURL), "/")
|
||||
if normalizedTrackID == "" {
|
||||
return "", "", fmt.Errorf("spotify track ID is required")
|
||||
}
|
||||
if baseURL == "" {
|
||||
return "", "", fmt.Errorf("spotfetch api url is required")
|
||||
}
|
||||
|
||||
requestURL := fmt.Sprintf("%s/isrc/%s", baseURL, url.PathEscape(normalizedTrackID))
|
||||
req, err := http.NewRequest(http.MethodGet, requestURL, nil)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to create SpotFetch ISRC request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", songLinkUserAgent)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("SpotFetch ISRC request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyPreview, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
|
||||
return "", "", fmt.Errorf("SpotFetch ISRC returned status %d (%s)", resp.StatusCode, strings.TrimSpace(string(bodyPreview)))
|
||||
}
|
||||
|
||||
var payload spotFetchISRCResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||
return "", "", fmt.Errorf("failed to decode SpotFetch ISRC response: %w", err)
|
||||
}
|
||||
|
||||
isrc := firstISRCMatch(payload.ISRC)
|
||||
if isrc == "" {
|
||||
return "", "", fmt.Errorf("ISRC missing in SpotFetch response")
|
||||
}
|
||||
|
||||
return isrc, strings.TrimSpace(payload.TrackID), nil
|
||||
}
|
||||
|
||||
func requestSpotifyBytes(client *http.Client, targetURL string, headers map[string]string) ([]byte, error) {
|
||||
req, err := http.NewRequest(http.MethodGet, targetURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for key, value := range headers {
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
details := strings.TrimSpace(string(body))
|
||||
if details == "" {
|
||||
details = resp.Status
|
||||
}
|
||||
return nil, fmt.Errorf("request failed: %s", details)
|
||||
}
|
||||
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func requestSpotifyJSON(client *http.Client, targetURL string, headers map[string]string, target interface{}) error {
|
||||
body, err := requestSpotifyBytes(client, targetURL, headers)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, target); err != nil {
|
||||
return fmt.Errorf("failed to parse JSON response: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadSpotifyCachedToken() (*spotifyAnonymousToken, error) {
|
||||
cachePath, err := spotifyTokenCachePath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
body, err := os.ReadFile(cachePath)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to read token cache: %w", err)
|
||||
}
|
||||
|
||||
var token spotifyAnonymousToken
|
||||
if err := json.Unmarshal(body, &token); err != nil {
|
||||
return nil, fmt.Errorf("failed to read token cache: %w", err)
|
||||
}
|
||||
|
||||
return &token, nil
|
||||
}
|
||||
|
||||
func saveSpotifyCachedToken(token *spotifyAnonymousToken) error {
|
||||
cachePath, err := spotifyTokenCachePath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(cachePath), 0o755); err != nil {
|
||||
return fmt.Errorf("failed to create token cache directory: %w", err)
|
||||
}
|
||||
|
||||
body, err := json.MarshalIndent(token, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.WriteFile(cachePath, body, 0o644); err != nil {
|
||||
return fmt.Errorf("failed to write token cache: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadSpotifyCachedSecrets() (*spotifySecretsCache, error) {
|
||||
cachePath, err := spotifySecretsCachePath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
body, err := os.ReadFile(cachePath)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to read secrets cache: %w", err)
|
||||
}
|
||||
|
||||
var cache spotifySecretsCache
|
||||
if err := json.Unmarshal(body, &cache); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse secrets cache: %w", err)
|
||||
}
|
||||
|
||||
return &cache, nil
|
||||
}
|
||||
|
||||
func saveSpotifyCachedSecrets(cache *spotifySecretsCache) error {
|
||||
cachePath, err := spotifySecretsCachePath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(cachePath), 0o755); err != nil {
|
||||
return fmt.Errorf("failed to create secrets cache directory: %w", err)
|
||||
}
|
||||
|
||||
body, err := json.MarshalIndent(cache, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.WriteFile(cachePath, body, 0o644); err != nil {
|
||||
return fmt.Errorf("failed to write secrets cache: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func spotifyTokenCachePath() (string, error) {
|
||||
appDir, err := EnsureAppDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return filepath.Join(appDir, spotifyTokenCacheFile), nil
|
||||
}
|
||||
|
||||
func spotifySecretsCachePath() (string, error) {
|
||||
appDir, err := EnsureAppDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return filepath.Join(appDir, spotifySecretsCacheFile), nil
|
||||
}
|
||||
|
||||
func spotifyTokenIsValid(token *spotifyAnonymousToken) bool {
|
||||
if token == nil || token.AccessToken == "" || token.AccessTokenExpirationTimestampMs == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
return time.Now().UnixMilli() < token.AccessTokenExpirationTimestampMs-30_000
|
||||
}
|
||||
|
||||
func spotifySecretsCacheIsValid(cache *spotifySecretsCache) bool {
|
||||
if cache == nil || cache.FetchedAtUnix == 0 || len(cache.Secrets) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
return time.Since(time.Unix(cache.FetchedAtUnix, 0)) < spotifySecretsCacheTTL
|
||||
}
|
||||
|
||||
func deriveSpotifyTOTPSecret(ciphertext []int) []byte {
|
||||
var builder strings.Builder
|
||||
|
||||
for index, value := range ciphertext {
|
||||
builder.WriteString(strconv.Itoa(value ^ ((index % 33) + 9)))
|
||||
}
|
||||
|
||||
return []byte(builder.String())
|
||||
}
|
||||
|
||||
func generateSpotifyTOTP(secret []byte, timestampMs int64) string {
|
||||
counter := timestampMs / 1000 / spotifyTOTPPeriod
|
||||
counterBytes := make([]byte, 8)
|
||||
binary.BigEndian.PutUint64(counterBytes, uint64(counter))
|
||||
|
||||
mac := hmac.New(sha1.New, secret)
|
||||
mac.Write(counterBytes)
|
||||
digest := mac.Sum(nil)
|
||||
|
||||
offset := digest[len(digest)-1] & 0x0f
|
||||
binaryCode := (int(digest[offset])&0x7f)<<24 |
|
||||
(int(digest[offset+1])&0xff)<<16 |
|
||||
(int(digest[offset+2])&0xff)<<8 |
|
||||
(int(digest[offset+3]) & 0xff)
|
||||
|
||||
modulo := 1
|
||||
for i := 0; i < spotifyTOTPDigits; i++ {
|
||||
modulo *= 10
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%0*d", spotifyTOTPDigits, binaryCode%modulo)
|
||||
}
|
||||
|
||||
func requestSpotifyAnonymousAccessToken(client *http.Client) (string, error) {
|
||||
spotifyAnonymousTokenMu.Lock()
|
||||
defer spotifyAnonymousTokenMu.Unlock()
|
||||
|
||||
cachedToken, err := loadSpotifyCachedToken()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if spotifyTokenIsValid(cachedToken) {
|
||||
return cachedToken.AccessToken, nil
|
||||
}
|
||||
|
||||
var serverTime spotifyServerTimeResponse
|
||||
if err := requestSpotifyJSON(client, spotifyServerTimeURL, nil, &serverTime); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var secrets map[string][]int
|
||||
cachedSecrets, err := loadSpotifyCachedSecrets()
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: failed to read Spotify secrets cache: %v\n", err)
|
||||
}
|
||||
|
||||
if spotifySecretsCacheIsValid(cachedSecrets) {
|
||||
secrets = cachedSecrets.Secrets
|
||||
} else {
|
||||
if err := requestSpotifyJSON(client, spotifyTOTPSecretsURL, nil, &secrets); err != nil {
|
||||
if cachedSecrets != nil && len(cachedSecrets.Secrets) > 0 {
|
||||
fmt.Printf("Warning: failed to refresh Spotify secrets cache, using stale cache: %v\n", err)
|
||||
secrets = cachedSecrets.Secrets
|
||||
} else {
|
||||
return "", err
|
||||
}
|
||||
} else {
|
||||
cache := &spotifySecretsCache{
|
||||
FetchedAtUnix: time.Now().Unix(),
|
||||
Secrets: secrets,
|
||||
}
|
||||
if err := saveSpotifyCachedSecrets(cache); err != nil {
|
||||
fmt.Printf("Warning: failed to write Spotify secrets cache: %v\n", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
version, err := latestSpotifySecretVersion(secrets)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
secret := deriveSpotifyTOTPSecret(secrets[version])
|
||||
generatedTOTP := generateSpotifyTOTP(secret, serverTime.ServerTime*1000)
|
||||
|
||||
query := url.Values{
|
||||
"reason": {"init"},
|
||||
"productType": {"web-player"},
|
||||
"totp": {generatedTOTP},
|
||||
"totpServer": {generatedTOTP},
|
||||
"totpVer": {version},
|
||||
}
|
||||
|
||||
var token spotifyAnonymousToken
|
||||
if err := requestSpotifyJSON(client, spotifySessionTokenURL+"?"+query.Encode(), nil, &token); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err := saveSpotifyCachedToken(&token); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return token.AccessToken, nil
|
||||
}
|
||||
|
||||
func latestSpotifySecretVersion(secrets map[string][]int) (string, error) {
|
||||
var (
|
||||
bestVersion string
|
||||
bestNumber int
|
||||
)
|
||||
|
||||
for version := range secrets {
|
||||
number, err := strconv.Atoi(version)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid secret version %q: %w", version, err)
|
||||
}
|
||||
if bestVersion == "" || number > bestNumber {
|
||||
bestVersion = version
|
||||
bestNumber = number
|
||||
}
|
||||
}
|
||||
|
||||
if bestVersion == "" {
|
||||
return "", errors.New("no TOTP secret versions available")
|
||||
}
|
||||
|
||||
return bestVersion, nil
|
||||
}
|
||||
|
||||
func extractSpotifyTrackID(value string) (string, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return "", errors.New("track input is required")
|
||||
}
|
||||
|
||||
if strings.HasPrefix(value, "spotify:track:") {
|
||||
return value[strings.LastIndex(value, ":")+1:], nil
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(value)
|
||||
if err == nil && (parsed.Scheme == "http" || parsed.Scheme == "https") {
|
||||
parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
|
||||
if len(parts) >= 2 && parts[0] == "track" {
|
||||
return parts[1], nil
|
||||
}
|
||||
return "", errors.New("expected URL like https://open.spotify.com/track/<id>")
|
||||
}
|
||||
|
||||
if len(value) == 22 {
|
||||
return value, nil
|
||||
}
|
||||
|
||||
return "", errors.New("track must be a Spotify track ID, URL, or URI")
|
||||
}
|
||||
|
||||
func spotifyTrackIDToGID(trackID string) (string, error) {
|
||||
if trackID == "" {
|
||||
return "", errors.New("track ID is empty")
|
||||
}
|
||||
|
||||
value := big.NewInt(0)
|
||||
base := big.NewInt(62)
|
||||
|
||||
for _, char := range trackID {
|
||||
index := strings.IndexRune(spotifyBase62Alphabet, char)
|
||||
if index < 0 {
|
||||
return "", fmt.Errorf("invalid base62 character: %q", string(char))
|
||||
}
|
||||
|
||||
value.Mul(value, base)
|
||||
value.Add(value, big.NewInt(int64(index)))
|
||||
}
|
||||
|
||||
hexValue := value.Text(16)
|
||||
if len(hexValue) < 32 {
|
||||
hexValue = strings.Repeat("0", 32-len(hexValue)) + hexValue
|
||||
}
|
||||
|
||||
return hexValue, nil
|
||||
}
|
||||
|
||||
func fetchSpotifyTrackRawData(client *http.Client, trackID string) ([]byte, error) {
|
||||
accessToken, err := requestSpotifyAnonymousAccessToken(client)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
gid, err := spotifyTrackIDToGID(trackID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return requestSpotifyBytes(
|
||||
client,
|
||||
fmt.Sprintf(spotifyGIDMetadataURL, "track", gid),
|
||||
map[string]string{
|
||||
"authorization": "Bearer " + accessToken,
|
||||
"accept": "application/json",
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func extractSpotifyTrackISRC(payload []byte) (string, error) {
|
||||
var track spotifyTrackRawData
|
||||
if err := json.Unmarshal(payload, &track); err != nil {
|
||||
return "", fmt.Errorf("failed to decode Spotify track metadata: %w", err)
|
||||
}
|
||||
|
||||
for _, externalID := range track.ExternalID {
|
||||
if strings.EqualFold(strings.TrimSpace(externalID.Type), "isrc") {
|
||||
if isrc := firstISRCMatch(externalID.ID); isrc != "" {
|
||||
return isrc, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if fallbackISRC := firstISRCMatch(string(payload)); fallbackISRC != "" {
|
||||
return fallbackISRC, nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("ISRC not found in Spotify track metadata")
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type resolvedTrackLinks struct {
|
||||
TidalURL string
|
||||
AmazonURL string
|
||||
DeezerURL string
|
||||
ISRC string
|
||||
}
|
||||
|
||||
const (
|
||||
linkResolverProviderSongstats = "songstats"
|
||||
linkResolverProviderDeezerSongLink = "deezer-songlink"
|
||||
)
|
||||
|
||||
func (s *SongLinkClient) resolveSpotifyTrackLinks(spotifyTrackID string, region string) (*resolvedTrackLinks, error) {
|
||||
links := &resolvedTrackLinks{}
|
||||
var attempts []string
|
||||
|
||||
isrc, err := s.lookupSpotifyISRC(spotifyTrackID)
|
||||
if err != nil {
|
||||
attempts = append(attempts, fmt.Sprintf("spotify isrc: %v", err))
|
||||
} else {
|
||||
links.ISRC = isrc
|
||||
}
|
||||
|
||||
if links.ISRC != "" {
|
||||
resolvers := orderedLinkResolvers()
|
||||
|
||||
for _, resolver := range resolvers {
|
||||
switch resolver {
|
||||
case linkResolverProviderSongstats:
|
||||
addedData, songstatsErr := s.resolveLinksViaSongstats(links)
|
||||
if songstatsErr != nil {
|
||||
attempts = append(attempts, fmt.Sprintf("songstats: %v", songstatsErr))
|
||||
} else if addedData {
|
||||
fmt.Println("Using Songstats as configured link resolver")
|
||||
}
|
||||
case linkResolverProviderDeezerSongLink:
|
||||
addedData, deezerSongLinkErr := s.resolveLinksViaDeezerSongLink(links, region)
|
||||
if deezerSongLinkErr != nil {
|
||||
attempts = append(attempts, fmt.Sprintf("deezer-songlink: %v", deezerSongLinkErr))
|
||||
} else if addedData {
|
||||
fmt.Println("Using Songlink as configured link resolver")
|
||||
}
|
||||
}
|
||||
|
||||
if links.TidalURL != "" && links.AmazonURL != "" {
|
||||
return links, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if hasAnySongLinkData(links) {
|
||||
return links, nil
|
||||
}
|
||||
|
||||
if len(attempts) == 0 {
|
||||
attempts = append(attempts, "no streaming URLs found")
|
||||
}
|
||||
|
||||
return links, errors.New(strings.Join(attempts, " | "))
|
||||
}
|
||||
|
||||
func orderedLinkResolvers() []string {
|
||||
preferred := GetLinkResolverSetting()
|
||||
if !GetLinkResolverAllowFallback() {
|
||||
if preferred == linkResolverProviderDeezerSongLink {
|
||||
return []string{linkResolverProviderDeezerSongLink}
|
||||
}
|
||||
return []string{linkResolverProviderSongstats}
|
||||
}
|
||||
|
||||
if preferred == linkResolverProviderDeezerSongLink {
|
||||
return []string{
|
||||
linkResolverProviderDeezerSongLink,
|
||||
linkResolverProviderSongstats,
|
||||
}
|
||||
}
|
||||
|
||||
return []string{
|
||||
linkResolverProviderSongstats,
|
||||
linkResolverProviderDeezerSongLink,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SongLinkClient) resolveLinksViaSongstats(links *resolvedTrackLinks) (bool, error) {
|
||||
if links == nil || links.ISRC == "" {
|
||||
return false, fmt.Errorf("ISRC is required for Songstats resolver")
|
||||
}
|
||||
|
||||
before := *links
|
||||
|
||||
fmt.Printf("Fetching Songstats links for ISRC %s\n", links.ISRC)
|
||||
if err := s.populateLinksFromSongstats(links, links.ISRC); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return *links != before, nil
|
||||
}
|
||||
|
||||
func (s *SongLinkClient) resolveLinksViaDeezerSongLink(links *resolvedTrackLinks, region string) (bool, error) {
|
||||
if links == nil || links.ISRC == "" {
|
||||
return false, fmt.Errorf("ISRC is required for Deezer song.link resolver")
|
||||
}
|
||||
|
||||
before := *links
|
||||
var attempts []string
|
||||
|
||||
if links.DeezerURL == "" {
|
||||
fmt.Printf("Resolving Deezer track from ISRC %s\n", links.ISRC)
|
||||
deezerURL, err := s.lookupDeezerTrackURLByISRC(links.ISRC)
|
||||
if err != nil {
|
||||
attempts = append(attempts, fmt.Sprintf("deezer isrc: %v", err))
|
||||
} else {
|
||||
links.DeezerURL = deezerURL
|
||||
fmt.Printf("Found Deezer URL: %s\n", links.DeezerURL)
|
||||
}
|
||||
}
|
||||
|
||||
if links.DeezerURL != "" {
|
||||
fmt.Println("Resolving streaming URLs from song.link via Deezer URL...")
|
||||
deezerResp, err := s.fetchSongLinkLinksByURL(links.DeezerURL, region)
|
||||
if err != nil {
|
||||
attempts = append(attempts, fmt.Sprintf("song.link deezer: %v", err))
|
||||
} else {
|
||||
mergeSongLinkResponse(links, deezerResp)
|
||||
}
|
||||
|
||||
if links.ISRC == "" {
|
||||
if resolvedISRC, deezerISRCErr := getDeezerISRC(links.DeezerURL); deezerISRCErr == nil {
|
||||
links.ISRC = resolvedISRC
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if *links != before {
|
||||
if len(attempts) == 0 {
|
||||
return true, nil
|
||||
}
|
||||
return true, errors.New(strings.Join(attempts, " | "))
|
||||
}
|
||||
|
||||
if len(attempts) == 0 {
|
||||
attempts = append(attempts, "no links found via deezer-songlink")
|
||||
}
|
||||
|
||||
return false, errors.New(strings.Join(attempts, " | "))
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -71,14 +70,16 @@ func NewLyricsClient() *LyricsClient {
|
||||
}
|
||||
}
|
||||
|
||||
func (c *LyricsClient) FetchLyricsWithMetadata(trackName, artistName string, duration int) (*LyricsResponse, error) {
|
||||
func (c *LyricsClient) FetchLyricsWithMetadata(trackName, artistName, albumName string, duration int) (*LyricsResponse, error) {
|
||||
|
||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9scmNsaWIubmV0L2FwaS9nZXQ/YXJ0aXN0X25hbWU9")
|
||||
apiURL := fmt.Sprintf("%s%s&track_name=%s",
|
||||
string(apiBase),
|
||||
apiURL := fmt.Sprintf("https://lrclib.net/api/get?artist_name=%s&track_name=%s",
|
||||
url.QueryEscape(artistName),
|
||||
url.QueryEscape(trackName))
|
||||
|
||||
if albumName != "" {
|
||||
apiURL = fmt.Sprintf("%s&album_name=%s", apiURL, url.QueryEscape(albumName))
|
||||
}
|
||||
|
||||
if duration > 0 {
|
||||
apiURL = fmt.Sprintf("%s&duration=%d", apiURL, duration)
|
||||
}
|
||||
@@ -103,6 +104,10 @@ func (c *LyricsClient) FetchLyricsWithMetadata(trackName, artistName string, dur
|
||||
return nil, fmt.Errorf("failed to parse LRCLIB response: %v", err)
|
||||
}
|
||||
|
||||
if lrcLibResp.SyncedLyrics == "" && lrcLibResp.PlainLyrics == "" {
|
||||
return nil, fmt.Errorf("LRCLIB returned empty lyrics")
|
||||
}
|
||||
|
||||
return c.convertLRCLibToLyricsResponse(&lrcLibResp), nil
|
||||
}
|
||||
|
||||
@@ -166,9 +171,10 @@ func lrcTimestampToMs(timestamp string) int64 {
|
||||
}
|
||||
|
||||
func (c *LyricsClient) FetchLyricsFromLRCLibSearch(trackName, artistName string) (*LyricsResponse, error) {
|
||||
query := fmt.Sprintf("%s %s", artistName, trackName)
|
||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9scmNsaWIubmV0L2FwaS9zZWFyY2g/cT0=")
|
||||
apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(query))
|
||||
|
||||
apiURL := fmt.Sprintf("https://lrclib.net/api/search?artist_name=%s&track_name=%s",
|
||||
url.QueryEscape(artistName),
|
||||
url.QueryEscape(trackName))
|
||||
|
||||
resp, err := c.httpClient.Get(apiURL)
|
||||
if err != nil {
|
||||
@@ -194,21 +200,32 @@ func (c *LyricsClient) FetchLyricsFromLRCLibSearch(trackName, artistName string)
|
||||
return nil, fmt.Errorf("no results found")
|
||||
}
|
||||
|
||||
var best *LRCLibResponse
|
||||
var bestSynced *LRCLibResponse
|
||||
var bestPlain *LRCLibResponse
|
||||
for i := range results {
|
||||
if results[i].SyncedLyrics != "" {
|
||||
best = &results[i]
|
||||
break
|
||||
if results[i].SyncedLyrics != "" && bestSynced == nil {
|
||||
bestSynced = &results[i]
|
||||
}
|
||||
if best == nil && results[i].PlainLyrics != "" {
|
||||
best = &results[i]
|
||||
if results[i].PlainLyrics != "" && bestPlain == nil {
|
||||
bestPlain = &results[i]
|
||||
}
|
||||
if bestSynced != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
best := bestSynced
|
||||
if best == nil {
|
||||
best = bestPlain
|
||||
}
|
||||
if best == nil {
|
||||
best = &results[0]
|
||||
}
|
||||
|
||||
if best.SyncedLyrics == "" && best.PlainLyrics == "" {
|
||||
return nil, fmt.Errorf("no lyrics found in search results")
|
||||
}
|
||||
|
||||
return c.convertLRCLibToLyricsResponse(best), nil
|
||||
}
|
||||
|
||||
@@ -224,35 +241,88 @@ func simplifyTrackName(name string) string {
|
||||
return name
|
||||
}
|
||||
|
||||
func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName string, duration int) (*LyricsResponse, string, error) {
|
||||
func isSynced(resp *LyricsResponse) bool {
|
||||
return resp != nil && !resp.Error && resp.SyncType == "LINE_SYNCED" && len(resp.Lines) > 0
|
||||
}
|
||||
|
||||
resp, err := c.FetchLyricsWithMetadata(trackName, artistName, duration)
|
||||
if err == nil && resp != nil && !resp.Error && len(resp.Lines) > 0 {
|
||||
return resp, "LRCLIB", nil
|
||||
}
|
||||
fmt.Printf(" LRCLIB exact: %v\n", err)
|
||||
func hasLyrics(resp *LyricsResponse) bool {
|
||||
return resp != nil && !resp.Error && len(resp.Lines) > 0
|
||||
}
|
||||
|
||||
resp, err = c.FetchLyricsFromLRCLibSearch(trackName, artistName)
|
||||
if err == nil && resp != nil && !resp.Error && len(resp.Lines) > 0 {
|
||||
return resp, "LRCLIB Search", nil
|
||||
func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName, albumName string, duration int) (*LyricsResponse, string, error) {
|
||||
|
||||
var unsyncedFallback *LyricsResponse
|
||||
var unsyncedSource string
|
||||
|
||||
check := func(resp *LyricsResponse, err error, source string) (*LyricsResponse, string, bool) {
|
||||
if err != nil || resp == nil || resp.Error || len(resp.Lines) == 0 {
|
||||
return nil, "", false
|
||||
}
|
||||
if isSynced(resp) {
|
||||
return resp, source, true
|
||||
}
|
||||
|
||||
if unsyncedFallback == nil {
|
||||
unsyncedFallback = resp
|
||||
unsyncedSource = source
|
||||
}
|
||||
return nil, "", false
|
||||
}
|
||||
fmt.Printf(" LRCLIB search: %v\n", err)
|
||||
|
||||
var resp *LyricsResponse
|
||||
var src string
|
||||
var found bool
|
||||
|
||||
resp, _ = c.FetchLyricsWithMetadata(trackName, artistName, albumName, duration)
|
||||
resp, src, found = check(resp, nil, "LRCLIB")
|
||||
if found {
|
||||
fmt.Printf(" [LRCLIB] Synced found via exact match (with album)\n")
|
||||
return resp, src, nil
|
||||
}
|
||||
fmt.Printf(" LRCLIB exact (with album): no synced\n")
|
||||
|
||||
if albumName != "" {
|
||||
resp, _ = c.FetchLyricsWithMetadata(trackName, artistName, "", duration)
|
||||
resp, src, found = check(resp, nil, "LRCLIB (no album)")
|
||||
if found {
|
||||
fmt.Printf(" [LRCLIB] Synced found via exact match (no album)\n")
|
||||
return resp, src, nil
|
||||
}
|
||||
fmt.Printf(" LRCLIB exact (no album): no synced\n")
|
||||
}
|
||||
|
||||
resp, _ = c.FetchLyricsFromLRCLibSearch(trackName, artistName)
|
||||
resp, src, found = check(resp, nil, "LRCLIB Search")
|
||||
if found {
|
||||
fmt.Printf(" [LRCLIB] Synced found via search\n")
|
||||
return resp, src, nil
|
||||
}
|
||||
fmt.Printf(" LRCLIB search: no synced\n")
|
||||
|
||||
simplifiedTrack := simplifyTrackName(trackName)
|
||||
if simplifiedTrack != trackName {
|
||||
fmt.Printf(" Trying simplified name: %s\n", simplifiedTrack)
|
||||
|
||||
resp, err = c.FetchLyricsWithMetadata(simplifiedTrack, artistName, duration)
|
||||
if err == nil && resp != nil && !resp.Error && len(resp.Lines) > 0 {
|
||||
return resp, "LRCLIB (simplified)", nil
|
||||
resp, _ = c.FetchLyricsWithMetadata(simplifiedTrack, artistName, albumName, duration)
|
||||
resp, src, found = check(resp, nil, "LRCLIB (simplified)")
|
||||
if found {
|
||||
fmt.Printf(" [LRCLIB] Synced found via simplified exact\n")
|
||||
return resp, src, nil
|
||||
}
|
||||
|
||||
resp, err = c.FetchLyricsFromLRCLibSearch(simplifiedTrack, artistName)
|
||||
if err == nil && resp != nil && !resp.Error && len(resp.Lines) > 0 {
|
||||
return resp, "LRCLIB Search (simplified)", nil
|
||||
resp, _ = c.FetchLyricsFromLRCLibSearch(simplifiedTrack, artistName)
|
||||
resp, src, found = check(resp, nil, "LRCLIB Search (simplified)")
|
||||
if found {
|
||||
fmt.Printf(" [LRCLIB] Synced found via simplified search\n")
|
||||
return resp, src, nil
|
||||
}
|
||||
}
|
||||
|
||||
if unsyncedFallback != nil {
|
||||
fmt.Printf(" [LRCLIB] No synced found, using unsynced from: %s\n", unsyncedSource)
|
||||
return unsyncedFallback, unsyncedSource + " (unsynced)", nil
|
||||
}
|
||||
|
||||
return nil, "", fmt.Errorf("lyrics not found in any source")
|
||||
}
|
||||
|
||||
@@ -313,6 +383,7 @@ func buildLyricsFilename(trackName, artistName, albumName, albumArtist, releaseD
|
||||
filename = strings.ReplaceAll(filename, "{album}", safeAlbum)
|
||||
filename = strings.ReplaceAll(filename, "{album_artist}", safeAlbumArtist)
|
||||
filename = strings.ReplaceAll(filename, "{year}", year)
|
||||
filename = strings.ReplaceAll(filename, "{date}", sanitizeFilename(releaseDate))
|
||||
|
||||
if discNumber > 0 {
|
||||
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
|
||||
@@ -436,7 +507,7 @@ func (c *LyricsClient) DownloadLyrics(req LyricsDownloadRequest) (*LyricsDownloa
|
||||
}
|
||||
}
|
||||
|
||||
lyrics, _, err := c.FetchLyricsAllSources(req.SpotifyID, req.TrackName, req.ArtistName, audioDuration)
|
||||
lyrics, _, err := c.FetchLyricsAllSources(req.SpotifyID, req.TrackName, req.ArtistName, req.AlbumName, audioDuration)
|
||||
if err != nil {
|
||||
return &LyricsDownloadResponse{
|
||||
Success: false,
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/go-flac/flacpicture"
|
||||
"github.com/go-flac/flacvorbis"
|
||||
"github.com/go-flac/go-flac"
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
|
||||
type Metadata struct {
|
||||
@@ -27,10 +28,13 @@ type Metadata struct {
|
||||
DiscNumber int
|
||||
TotalDiscs int
|
||||
URL string
|
||||
Comment string
|
||||
Copyright string
|
||||
Publisher string
|
||||
Lyrics string
|
||||
Description string
|
||||
ISRC string
|
||||
Genre string
|
||||
}
|
||||
|
||||
func EmbedMetadata(filepath string, metadata Metadata, coverPath string) error {
|
||||
@@ -85,6 +89,17 @@ func EmbedMetadata(filepath string, metadata Metadata, coverPath string) error {
|
||||
if metadata.Description != "" {
|
||||
_ = cmt.Add("DESCRIPTION", metadata.Description)
|
||||
}
|
||||
if comment := resolveMetadataComment(metadata); comment != "" {
|
||||
_ = cmt.Add("COMMENT", comment)
|
||||
}
|
||||
|
||||
if metadata.ISRC != "" {
|
||||
_ = cmt.Add("ISRC", metadata.ISRC)
|
||||
}
|
||||
|
||||
if metadata.Genre != "" {
|
||||
_ = cmt.Add("GENRE", metadata.Genre)
|
||||
}
|
||||
|
||||
if metadata.Lyrics != "" {
|
||||
_ = cmt.Add("LYRICS", metadata.Lyrics)
|
||||
@@ -155,6 +170,14 @@ func extractYear(releaseDate string) string {
|
||||
return releaseDate
|
||||
}
|
||||
|
||||
func resolveMetadataComment(metadata Metadata) string {
|
||||
if comment := strings.TrimSpace(metadata.Comment); comment != "" {
|
||||
return comment
|
||||
}
|
||||
|
||||
return strings.TrimSpace(metadata.URL)
|
||||
}
|
||||
|
||||
func EmbedLyricsOnly(filepath string, lyrics string) error {
|
||||
if lyrics == "" {
|
||||
return nil
|
||||
@@ -208,16 +231,68 @@ func EmbedLyricsOnly(filepath string, lyrics string) error {
|
||||
}
|
||||
|
||||
func ExtractCoverArt(filePath string) (string, error) {
|
||||
filePath = norm.NFC.String(filePath)
|
||||
ext := strings.ToLower(pathfilepath.Ext(filePath))
|
||||
|
||||
var coverPath string
|
||||
var err error
|
||||
|
||||
switch ext {
|
||||
case ".mp3":
|
||||
return extractCoverFromMp3(filePath)
|
||||
coverPath, err = extractCoverFromMp3(filePath)
|
||||
case ".m4a", ".flac":
|
||||
return extractCoverFromM4AOrFlac(filePath)
|
||||
coverPath, err = extractCoverFromM4AOrFlac(filePath)
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported file format: %s", ext)
|
||||
}
|
||||
|
||||
if err != nil || coverPath == "" {
|
||||
fmt.Printf("[ExtractCoverArt] Library extraction failed for %s, trying FFmpeg fallback...\n", filePath)
|
||||
ffmpegCover, ffmpegErr := extractCoverWithFFmpeg(filePath)
|
||||
if ffmpegErr == nil {
|
||||
return ffmpegCover, nil
|
||||
}
|
||||
return coverPath, err
|
||||
}
|
||||
|
||||
return coverPath, nil
|
||||
}
|
||||
|
||||
func extractCoverWithFFmpeg(filePath string) (string, error) {
|
||||
ffmpegPath, err := GetFFmpegPath()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
tmpFile, err := os.CreateTemp("", "cover-*.jpg")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
tmpPath := tmpFile.Name()
|
||||
tmpFile.Close()
|
||||
|
||||
cmd := exec.Command(ffmpegPath,
|
||||
"-i", filePath,
|
||||
"-an",
|
||||
"-vframes", "1",
|
||||
"-f", "image2",
|
||||
"-update", "1",
|
||||
"-y",
|
||||
tmpPath,
|
||||
)
|
||||
|
||||
setHideWindow(cmd)
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return "", fmt.Errorf("ffmpeg cover extraction failed: %v, output: %s", err, string(output))
|
||||
}
|
||||
|
||||
if info, err := os.Stat(tmpPath); err != nil || info.Size() == 0 {
|
||||
os.Remove(tmpPath)
|
||||
return "", fmt.Errorf("ffmpeg produced empty cover file")
|
||||
}
|
||||
|
||||
return tmpPath, nil
|
||||
}
|
||||
|
||||
func extractCoverFromMp3(filePath string) (string, error) {
|
||||
@@ -288,19 +363,71 @@ func extractCoverFromM4AOrFlac(filePath string) (string, error) {
|
||||
}
|
||||
|
||||
func ExtractLyrics(filePath string) (string, error) {
|
||||
filePath = norm.NFC.String(filePath)
|
||||
ext := strings.ToLower(pathfilepath.Ext(filePath))
|
||||
|
||||
var lyrics string
|
||||
var err error
|
||||
|
||||
switch ext {
|
||||
case ".mp3":
|
||||
return extractLyricsFromMp3(filePath)
|
||||
lyrics, err = extractLyricsFromMp3(filePath)
|
||||
case ".flac":
|
||||
return extractLyricsFromFlac(filePath)
|
||||
lyrics, err = extractLyricsFromFlac(filePath)
|
||||
case ".m4a":
|
||||
|
||||
return "", nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported file format: %s", ext)
|
||||
}
|
||||
|
||||
if (err != nil || lyrics == "") && ext != ".m4a" {
|
||||
fmt.Printf("[ExtractLyrics] Library extraction failed for %s, trying ffprobe fallback...\n", filePath)
|
||||
ffprobeLyrics, ffprobeErr := extractLyricsWithFFprobe(filePath)
|
||||
if ffprobeErr == nil && ffprobeLyrics != "" {
|
||||
return ffprobeLyrics, nil
|
||||
}
|
||||
}
|
||||
|
||||
return lyrics, err
|
||||
}
|
||||
|
||||
func extractLyricsWithFFprobe(filePath string) (string, error) {
|
||||
ffprobePath, err := GetFFprobePath()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
cmd := exec.Command(ffprobePath,
|
||||
"-v", "quiet",
|
||||
"-show_entries", "format_tags=lyrics:format_tags=unsyncedlyrics:format_tags=lyric",
|
||||
"-of", "json",
|
||||
filePath,
|
||||
)
|
||||
|
||||
setHideWindow(cmd)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Format struct {
|
||||
Tags map[string]string `json:"tags"`
|
||||
} `json:"format"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(output, &result); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
tags := result.Format.Tags
|
||||
for _, key := range []string{"lyrics", "unsyncedlyrics", "lyric", "LYRICS", "UNSYNCEDLYRICS", "LYRIC"} {
|
||||
if val, ok := tags[key]; ok && val != "" {
|
||||
return val, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func extractLyricsFromMp3(filePath string) (string, error) {
|
||||
@@ -504,6 +631,13 @@ func EmbedLyricsOnlyUniversal(filepath string, lyrics string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
validatedLyrics, err := validateLyricsDuration(lyrics, filepath)
|
||||
if err != nil {
|
||||
fmt.Printf("[EmbedLyricsOnlyUniversal] Warning: Failed to validate lyrics duration: %v, using original lyrics\n", err)
|
||||
validatedLyrics = lyrics
|
||||
}
|
||||
lyrics = validatedLyrics
|
||||
|
||||
ext := strings.ToLower(pathfilepath.Ext(filepath))
|
||||
switch ext {
|
||||
case ".mp3":
|
||||
@@ -635,27 +769,22 @@ func validateLyricsDuration(lyrics string, filepath string) (string, error) {
|
||||
|
||||
if strings.HasPrefix(trimmedLine, "[") {
|
||||
|
||||
if strings.Index(trimmedLine, ":") > 0 {
|
||||
|
||||
validLines = append(validLines, line)
|
||||
continue
|
||||
}
|
||||
|
||||
closeBracket := strings.Index(trimmedLine, "]")
|
||||
if closeBracket > 0 {
|
||||
timestampStr := trimmedLine[1:closeBracket]
|
||||
|
||||
ms := parseLRCTimestamp(timestampStr)
|
||||
if ms >= 0 && ms <= durationMs {
|
||||
|
||||
validLines = append(validLines, line)
|
||||
if ms >= 0 {
|
||||
if ms <= durationMs {
|
||||
validLines = append(validLines, line)
|
||||
} else {
|
||||
fmt.Printf("[ValidateLyrics] Filtered out line with timestamp %s (exceeds duration %d ms): %s\n", timestampStr, durationMs, trimmedLine)
|
||||
}
|
||||
} else {
|
||||
|
||||
fmt.Printf("[ValidateLyrics] Filtered out line with timestamp %s (exceeds duration %d ms): %s\n", timestampStr, durationMs, trimmedLine)
|
||||
validLines = append(validLines, line)
|
||||
}
|
||||
} else {
|
||||
|
||||
validLines = append(validLines, line)
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -676,6 +805,7 @@ func parseLRCTimestamp(timestamp string) int64 {
|
||||
}
|
||||
|
||||
func ExtractFullMetadataFromFile(filePath string) (Metadata, error) {
|
||||
filePath = norm.NFC.String(filePath)
|
||||
var metadata Metadata
|
||||
|
||||
ffprobePath, err := GetFFprobePath()
|
||||
@@ -773,7 +903,11 @@ func ExtractFullMetadataFromFile(filePath string) (Metadata, error) {
|
||||
metadata.Publisher = value
|
||||
case "url":
|
||||
metadata.URL = value
|
||||
case "description", "comment":
|
||||
case "comment", "comments":
|
||||
if metadata.Comment == "" {
|
||||
metadata.Comment = value
|
||||
}
|
||||
case "description":
|
||||
if metadata.Description == "" {
|
||||
metadata.Description = value
|
||||
}
|
||||
@@ -784,6 +918,7 @@ func ExtractFullMetadataFromFile(filePath string) (Metadata, error) {
|
||||
}
|
||||
|
||||
func EmbedMetadataToConvertedFile(filePath string, metadata Metadata, coverPath string) error {
|
||||
filePath = norm.NFC.String(filePath)
|
||||
ext := strings.ToLower(pathfilepath.Ext(filePath))
|
||||
|
||||
switch ext {
|
||||
@@ -858,6 +993,21 @@ func embedMetadataToMP3(filePath string, metadata Metadata, coverPath string) er
|
||||
tag.AddTextFrame("TPUB", id3v2.EncodingUTF8, metadata.Publisher)
|
||||
}
|
||||
|
||||
if metadata.ISRC != "" {
|
||||
tag.DeleteFrames("TSRC")
|
||||
tag.AddTextFrame("TSRC", id3v2.EncodingUTF8, metadata.ISRC)
|
||||
}
|
||||
|
||||
if comment := resolveMetadataComment(metadata); comment != "" {
|
||||
tag.DeleteFrames(tag.CommonID("Comments"))
|
||||
tag.AddCommentFrame(id3v2.CommentFrame{
|
||||
Encoding: id3v2.EncodingUTF8,
|
||||
Language: "eng",
|
||||
Description: "",
|
||||
Text: comment,
|
||||
})
|
||||
}
|
||||
|
||||
if coverPath != "" && fileExists(coverPath) {
|
||||
|
||||
tag.DeleteFrames(tag.CommonID("Attached picture"))
|
||||
@@ -941,6 +1091,12 @@ func embedMetadataToM4A(filePath string, metadata Metadata, coverPath string) er
|
||||
if metadata.Publisher != "" {
|
||||
args = append(args, "-metadata", "publisher="+metadata.Publisher)
|
||||
}
|
||||
if metadata.ISRC != "" {
|
||||
args = append(args, "-metadata", "isrc="+metadata.ISRC)
|
||||
}
|
||||
if comment := resolveMetadataComment(metadata); comment != "" {
|
||||
args = append(args, "-metadata", "comment="+comment)
|
||||
}
|
||||
|
||||
tmpOutputFile := strings.TrimSuffix(filePath, pathfilepath.Ext(filePath)) + ".tmp" + pathfilepath.Ext(filePath)
|
||||
defer func() {
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/text/cases"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
var AppVersion = "Unknown"
|
||||
|
||||
const musicBrainzAPIBase = "https://musicbrainz.org/ws/2"
|
||||
|
||||
type MusicBrainzRecordingResponse struct {
|
||||
Recordings []struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Length int `json:"length"`
|
||||
Releases []struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Status string `json:"status"`
|
||||
ReleaseGroup struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
PrimaryType string `json:"primary-type"`
|
||||
} `json:"release-group"`
|
||||
Date string `json:"date"`
|
||||
Country string `json:"country"`
|
||||
Media []struct {
|
||||
Format string `json:"format"`
|
||||
} `json:"media"`
|
||||
LabelInfo []struct {
|
||||
Label struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"label"`
|
||||
} `json:"label-info"`
|
||||
} `json:"releases"`
|
||||
ArtistCredit []struct {
|
||||
Artist struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"artist"`
|
||||
} `json:"artist-credit"`
|
||||
Tags []struct {
|
||||
Count int `json:"count"`
|
||||
Name string `json:"name"`
|
||||
} `json:"tags"`
|
||||
} `json:"recordings"`
|
||||
}
|
||||
|
||||
func FetchMusicBrainzMetadata(isrc, title, artist, album string, useSingleGenre bool, embedGenre bool) (Metadata, error) {
|
||||
var meta Metadata
|
||||
|
||||
if !embedGenre {
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
if isrc == "" {
|
||||
return meta, fmt.Errorf("no ISRC provided")
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
query := fmt.Sprintf("isrc:%s", isrc)
|
||||
reqURL := fmt.Sprintf("%s/recording?query=%s&fmt=json&inc=releases+artist-credits+tags+media+release-groups+labels", musicBrainzAPIBase, url.QueryEscape(query))
|
||||
|
||||
req, err := http.NewRequest("GET", reqURL, nil)
|
||||
if err != nil {
|
||||
return meta, err
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", fmt.Sprintf("SpotiFLAC/%s ( hi@afkarxyz.qzz.io )", AppVersion))
|
||||
|
||||
var resp *http.Response
|
||||
var lastErr error
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
resp, lastErr = client.Do(req)
|
||||
if lastErr == nil && resp.StatusCode == http.StatusOK {
|
||||
break
|
||||
}
|
||||
|
||||
if resp != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
if i < 2 {
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
if lastErr != nil {
|
||||
return meta, lastErr
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
resp.Body.Close()
|
||||
return meta, fmt.Errorf("MusicBrainz API returned status: %d", resp.StatusCode)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var mbResp MusicBrainzRecordingResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&mbResp); err != nil {
|
||||
return meta, err
|
||||
}
|
||||
|
||||
if len(mbResp.Recordings) == 0 {
|
||||
return meta, fmt.Errorf("no recordings found for ISRC: %s", isrc)
|
||||
}
|
||||
|
||||
recording := mbResp.Recordings[0]
|
||||
|
||||
var genres []string
|
||||
caser := cases.Title(language.English)
|
||||
|
||||
if useSingleGenre {
|
||||
|
||||
maxCount := -1
|
||||
var bestTag string
|
||||
|
||||
for _, tag := range recording.Tags {
|
||||
if tag.Count > maxCount {
|
||||
maxCount = tag.Count
|
||||
bestTag = tag.Name
|
||||
}
|
||||
}
|
||||
|
||||
if bestTag != "" {
|
||||
meta.Genre = caser.String(bestTag)
|
||||
}
|
||||
} else {
|
||||
for _, tag := range recording.Tags {
|
||||
|
||||
genres = append(genres, caser.String(tag.Name))
|
||||
}
|
||||
if len(genres) > 0 {
|
||||
|
||||
if len(genres) > 5 {
|
||||
genres = genres[:5]
|
||||
}
|
||||
meta.Genre = strings.Join(genres, GetSeparator())
|
||||
}
|
||||
}
|
||||
|
||||
return meta, nil
|
||||
}
|
||||
@@ -22,7 +22,7 @@ type DownloadItem struct {
|
||||
TrackName string `json:"track_name"`
|
||||
ArtistName string `json:"artist_name"`
|
||||
AlbumName string `json:"album_name"`
|
||||
ISRC string `json:"isrc"`
|
||||
SpotifyID string `json:"spotify_id"`
|
||||
Status DownloadStatus `json:"status"`
|
||||
Progress float64 `json:"progress"`
|
||||
TotalSize float64 `json:"total_size"`
|
||||
@@ -184,7 +184,7 @@ func (pw *ProgressWriter) GetTotal() int64 {
|
||||
return pw.total
|
||||
}
|
||||
|
||||
func AddToQueue(id, trackName, artistName, albumName, isrc string) {
|
||||
func AddToQueue(id, trackName, artistName, albumName, spotifyID string) {
|
||||
downloadQueueLock.Lock()
|
||||
defer downloadQueueLock.Unlock()
|
||||
|
||||
@@ -193,7 +193,7 @@ func AddToQueue(id, trackName, artistName, albumName, isrc string) {
|
||||
TrackName: trackName,
|
||||
ArtistName: artistName,
|
||||
AlbumName: albumName,
|
||||
ISRC: isrc,
|
||||
SpotifyID: spotifyID,
|
||||
Status: StatusQueued,
|
||||
Progress: 0,
|
||||
TotalSize: 0,
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
bolt "go.etcd.io/bbolt"
|
||||
)
|
||||
|
||||
const (
|
||||
providerPriorityDBFile = "provider_priority.db"
|
||||
providerPriorityBucket = "ProviderPriority"
|
||||
)
|
||||
|
||||
type providerPriorityEntry struct {
|
||||
Service string `json:"service"`
|
||||
Provider string `json:"provider"`
|
||||
LastOutcome string `json:"last_outcome"`
|
||||
LastAttempt int64 `json:"last_attempt"`
|
||||
LastSuccess int64 `json:"last_success"`
|
||||
LastFailure int64 `json:"last_failure"`
|
||||
SuccessCount int64 `json:"success_count"`
|
||||
FailureCount int64 `json:"failure_count"`
|
||||
}
|
||||
|
||||
var (
|
||||
providerPriorityDB *bolt.DB
|
||||
providerPriorityDBMu sync.Mutex
|
||||
)
|
||||
|
||||
func InitProviderPriorityDB() error {
|
||||
providerPriorityDBMu.Lock()
|
||||
defer providerPriorityDBMu.Unlock()
|
||||
|
||||
if providerPriorityDB != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
appDir, err := EnsureAppDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dbPath := filepath.Join(appDir, providerPriorityDBFile)
|
||||
db, err := bolt.Open(dbPath, 0o600, &bolt.Options{Timeout: 1 * time.Second})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := db.Update(func(tx *bolt.Tx) error {
|
||||
_, err := tx.CreateBucketIfNotExists([]byte(providerPriorityBucket))
|
||||
return err
|
||||
}); err != nil {
|
||||
db.Close()
|
||||
return err
|
||||
}
|
||||
|
||||
providerPriorityDB = db
|
||||
return nil
|
||||
}
|
||||
|
||||
func CloseProviderPriorityDB() {
|
||||
providerPriorityDBMu.Lock()
|
||||
defer providerPriorityDBMu.Unlock()
|
||||
|
||||
if providerPriorityDB != nil {
|
||||
_ = providerPriorityDB.Close()
|
||||
providerPriorityDB = nil
|
||||
}
|
||||
}
|
||||
|
||||
func prioritizeProviders(service string, providers []string) []string {
|
||||
ordered := append([]string(nil), providers...)
|
||||
if len(ordered) < 2 {
|
||||
return ordered
|
||||
}
|
||||
|
||||
if err := InitProviderPriorityDB(); err != nil {
|
||||
fmt.Printf("Warning: failed to init provider priority DB: %v\n", err)
|
||||
return ordered
|
||||
}
|
||||
|
||||
serviceKey := strings.TrimSpace(strings.ToLower(service))
|
||||
entries := make(map[string]providerPriorityEntry, len(ordered))
|
||||
|
||||
if err := providerPriorityDB.View(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte(providerPriorityBucket))
|
||||
if bucket == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, provider := range ordered {
|
||||
if raw := bucket.Get([]byte(providerPriorityKey(serviceKey, provider))); len(raw) > 0 {
|
||||
var entry providerPriorityEntry
|
||||
if err := json.Unmarshal(raw, &entry); err != nil {
|
||||
return err
|
||||
}
|
||||
entries[provider] = entry
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
fmt.Printf("Warning: failed to read provider priority DB: %v\n", err)
|
||||
return ordered
|
||||
}
|
||||
|
||||
originalIndex := make(map[string]int, len(ordered))
|
||||
for idx, provider := range ordered {
|
||||
originalIndex[provider] = idx
|
||||
}
|
||||
|
||||
sort.SliceStable(ordered, func(i, j int) bool {
|
||||
left := entries[ordered[i]]
|
||||
right := entries[ordered[j]]
|
||||
|
||||
leftRank := providerOutcomeRank(left.LastOutcome)
|
||||
rightRank := providerOutcomeRank(right.LastOutcome)
|
||||
if leftRank != rightRank {
|
||||
return leftRank > rightRank
|
||||
}
|
||||
|
||||
if left.LastSuccess != right.LastSuccess {
|
||||
return left.LastSuccess > right.LastSuccess
|
||||
}
|
||||
|
||||
if left.LastAttempt != right.LastAttempt {
|
||||
return left.LastAttempt > right.LastAttempt
|
||||
}
|
||||
|
||||
return originalIndex[ordered[i]] < originalIndex[ordered[j]]
|
||||
})
|
||||
|
||||
return ordered
|
||||
}
|
||||
|
||||
func recordProviderSuccess(service string, provider string) {
|
||||
recordProviderOutcome(service, provider, true)
|
||||
}
|
||||
|
||||
func recordProviderFailure(service string, provider string) {
|
||||
recordProviderOutcome(service, provider, false)
|
||||
}
|
||||
|
||||
func recordProviderOutcome(service string, provider string, success bool) {
|
||||
if strings.TrimSpace(service) == "" || strings.TrimSpace(provider) == "" {
|
||||
return
|
||||
}
|
||||
|
||||
if err := InitProviderPriorityDB(); err != nil {
|
||||
fmt.Printf("Warning: failed to init provider priority DB: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
serviceKey := strings.TrimSpace(strings.ToLower(service))
|
||||
providerKey := providerPriorityKey(serviceKey, provider)
|
||||
now := time.Now().Unix()
|
||||
|
||||
if err := providerPriorityDB.Update(func(tx *bolt.Tx) error {
|
||||
bucket, err := tx.CreateBucketIfNotExists([]byte(providerPriorityBucket))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
entry := providerPriorityEntry{
|
||||
Service: serviceKey,
|
||||
Provider: provider,
|
||||
}
|
||||
|
||||
if raw := bucket.Get([]byte(providerKey)); len(raw) > 0 {
|
||||
if err := json.Unmarshal(raw, &entry); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
entry.LastAttempt = now
|
||||
if success {
|
||||
entry.LastOutcome = "success"
|
||||
entry.LastSuccess = now
|
||||
entry.SuccessCount++
|
||||
} else {
|
||||
entry.LastOutcome = "failure"
|
||||
entry.LastFailure = now
|
||||
entry.FailureCount++
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(entry)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return bucket.Put([]byte(providerKey), payload)
|
||||
}); err != nil {
|
||||
fmt.Printf("Warning: failed to update provider priority DB: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
func providerOutcomeRank(outcome string) int {
|
||||
switch strings.TrimSpace(strings.ToLower(outcome)) {
|
||||
case "success":
|
||||
return 2
|
||||
case "":
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func providerPriorityKey(service string, provider string) string {
|
||||
return strings.TrimSpace(strings.ToLower(service)) + "|" + strings.TrimSpace(provider)
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -77,10 +76,9 @@ func NewQobuzDownloader() *QobuzDownloader {
|
||||
}
|
||||
}
|
||||
|
||||
func (q *QobuzDownloader) SearchByISRC(isrc string) (*QobuzTrack, error) {
|
||||
|
||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
|
||||
url := fmt.Sprintf("%s%s&limit=1&app_id=%s", string(apiBase), isrc, q.appID)
|
||||
func (q *QobuzDownloader) searchByISRC(isrc string) (*QobuzTrack, error) {
|
||||
apiBase := "https://www.qobuz.com/api.json/0.2/track/search?query="
|
||||
url := fmt.Sprintf("%s%s&limit=1&app_id=%s", apiBase, isrc, q.appID)
|
||||
|
||||
resp, err := q.client.Get(url)
|
||||
if err != nil {
|
||||
@@ -119,81 +117,131 @@ func (q *QobuzDownloader) SearchByISRC(isrc string) (*QobuzTrack, error) {
|
||||
return &searchResp.Tracks.Items[0], nil
|
||||
}
|
||||
|
||||
func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string, error) {
|
||||
|
||||
qualityCode := quality
|
||||
if qualityCode == "" {
|
||||
qualityCode = "6"
|
||||
func buildQobuzAPIURL(apiBase string, trackID int64, quality string) string {
|
||||
if strings.Contains(apiBase, "qbz.afkarxyz.qzz.io") {
|
||||
return fmt.Sprintf("%s%d?quality=%s", apiBase, trackID, quality)
|
||||
}
|
||||
return fmt.Sprintf("%s%d&quality=%s", apiBase, trackID, quality)
|
||||
}
|
||||
|
||||
fmt.Printf("Getting download URL for track ID: %d with requested quality: %s\n", trackID, qualityCode)
|
||||
fmt.Printf("Quality codes: 6=FLAC 16-bit, 7=FLAC 24-bit, 27=Hi-Res\n")
|
||||
|
||||
primaryBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9kYWIueWVldC5zdS9hcGkvc3RyZWFtP3RyYWNrSWQ9")
|
||||
|
||||
primaryURL := fmt.Sprintf("%s%d&quality=%s", string(primaryBase), trackID, qualityCode)
|
||||
fmt.Printf("Qobuz API URL: %s\n", primaryURL)
|
||||
|
||||
resp, err := q.client.Get(primaryURL)
|
||||
if err == nil && resp.StatusCode == 200 {
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
fmt.Printf("Primary API response: %s\n", string(body))
|
||||
|
||||
var streamResp QobuzStreamResponse
|
||||
if err := json.Unmarshal(body, &streamResp); err == nil && streamResp.URL != "" {
|
||||
fmt.Printf("Got download URL from primary API\n")
|
||||
return streamResp.URL, nil
|
||||
}
|
||||
}
|
||||
if resp != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
fmt.Println("Primary API failed, trying fallback...")
|
||||
fallbackBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9kYWJtdXNpYy54eXovYXBpL3N0cmVhbT90cmFja0lkPQ==")
|
||||
fallbackURL := fmt.Sprintf("%s%d&quality=%s", string(fallbackBase), trackID, qualityCode)
|
||||
|
||||
resp, err = q.client.Get(fallbackURL)
|
||||
func (q *QobuzDownloader) DownloadFromStandard(apiBase string, trackID int64, quality string) (string, error) {
|
||||
apiURL := buildQobuzAPIURL(apiBase, trackID, quality)
|
||||
resp, err := q.client.Get(apiURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get download URL: %w", err)
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
fmt.Printf("Fallback API error response: %s\n", string(body))
|
||||
return "", fmt.Errorf("API returned status %d", resp.StatusCode)
|
||||
return "", fmt.Errorf("status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read response body: %w", err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
if len(body) == 0 {
|
||||
return "", fmt.Errorf("API returned empty response")
|
||||
return "", fmt.Errorf("empty body")
|
||||
}
|
||||
|
||||
fmt.Printf("Fallback API response: %s\n", string(body))
|
||||
|
||||
var streamResp QobuzStreamResponse
|
||||
if err := json.Unmarshal(body, &streamResp); err != nil {
|
||||
if err := json.Unmarshal(body, &streamResp); err == nil && streamResp.URL != "" {
|
||||
return streamResp.URL, nil
|
||||
}
|
||||
|
||||
bodyStr := string(body)
|
||||
if len(bodyStr) > 200 {
|
||||
bodyStr = bodyStr[:200] + "..."
|
||||
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")
|
||||
}
|
||||
|
||||
func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string, allowFallback bool) (string, error) {
|
||||
qualityCode := quality
|
||||
if qualityCode == "" || qualityCode == "5" {
|
||||
qualityCode = "6"
|
||||
}
|
||||
|
||||
fmt.Printf("Getting download URL for track ID: %d with requested quality: %s\n", trackID, qualityCode)
|
||||
|
||||
standardAPIs := prioritizeProviders("qobuz", []string{
|
||||
"https://dab.yeet.su/api/stream?trackId=",
|
||||
"https://dabmusic.xyz/api/stream?trackId=",
|
||||
"https://qbz.afkarxyz.qzz.io/api/track/",
|
||||
})
|
||||
|
||||
downloadFunc := func(qual string) (string, error) {
|
||||
type Provider struct {
|
||||
Name string
|
||||
API string
|
||||
Func func() (string, error)
|
||||
}
|
||||
return "", fmt.Errorf("failed to decode response: %w (response: %s)", err, bodyStr)
|
||||
|
||||
var providers []Provider
|
||||
|
||||
for _, api := range standardAPIs {
|
||||
currentAPI := api
|
||||
providers = append(providers, Provider{
|
||||
Name: "Standard(" + currentAPI + ")",
|
||||
API: currentAPI,
|
||||
Func: func() (string, error) {
|
||||
return q.DownloadFromStandard(currentAPI, trackID, qual)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for _, p := range providers {
|
||||
fmt.Printf("Trying Provider: %s (Quality: %s)...\n", p.Name, qual)
|
||||
|
||||
url, err := p.Func()
|
||||
if err == nil {
|
||||
fmt.Printf("✓ Success\n")
|
||||
recordProviderSuccess("qobuz", p.API)
|
||||
return url, nil
|
||||
}
|
||||
|
||||
fmt.Printf("Provider failed: %v\n", err)
|
||||
recordProviderFailure("qobuz", p.API)
|
||||
lastErr = err
|
||||
}
|
||||
return "", lastErr
|
||||
}
|
||||
|
||||
if streamResp.URL == "" {
|
||||
return "", fmt.Errorf("no download URL available")
|
||||
url, err := downloadFunc(qualityCode)
|
||||
if err == nil {
|
||||
return url, nil
|
||||
}
|
||||
|
||||
fmt.Printf("Got download URL from fallback API\n")
|
||||
return streamResp.URL, nil
|
||||
currentQuality := qualityCode
|
||||
|
||||
if currentQuality == "27" && allowFallback {
|
||||
fmt.Printf("⚠ Download with quality 27 failed, trying fallback to 7 (24-bit Standard)...\n")
|
||||
url, err := downloadFunc("7")
|
||||
if err == nil {
|
||||
fmt.Println("✓ Success with fallback quality 7")
|
||||
return url, nil
|
||||
}
|
||||
|
||||
currentQuality = "7"
|
||||
}
|
||||
|
||||
if currentQuality == "7" && allowFallback {
|
||||
fmt.Printf("⚠ Download with quality 7 failed, trying fallback to 6 (16-bit Lossless)...\n")
|
||||
url, err := downloadFunc("6")
|
||||
if err == nil {
|
||||
fmt.Println("✓ Success with fallback quality 6")
|
||||
return url, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("all APIs and fallbacks failed. Last error: %v", err)
|
||||
}
|
||||
|
||||
func (q *QobuzDownloader) DownloadFile(url, filepath string) error {
|
||||
@@ -277,6 +325,7 @@ func buildQobuzFilename(title, artist, album, albumArtist, releaseDate string, t
|
||||
filename = strings.ReplaceAll(filename, "{album}", album)
|
||||
filename = strings.ReplaceAll(filename, "{album_artist}", albumArtist)
|
||||
filename = strings.ReplaceAll(filename, "{year}", year)
|
||||
filename = strings.ReplaceAll(filename, "{date}", SanitizeFilename(releaseDate))
|
||||
|
||||
if discNumber > 0 {
|
||||
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
|
||||
@@ -311,8 +360,40 @@ func buildQobuzFilename(title, artist, album, albumArtist, releaseDate string, t
|
||||
return filename + ".flac"
|
||||
}
|
||||
|
||||
func (q *QobuzDownloader) DownloadByISRC(deezerISRC, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string) (string, error) {
|
||||
fmt.Printf("Fetching track info for ISRC: %s\n", deezerISRC)
|
||||
func (q *QobuzDownloader) DownloadTrack(spotifyID, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) {
|
||||
var isrc string
|
||||
if spotifyID != "" {
|
||||
linkClient := NewSongLinkClient()
|
||||
resolvedISRC, err := linkClient.GetISRCDirect(spotifyID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get ISRC: %v", err)
|
||||
}
|
||||
isrc = resolvedISRC
|
||||
} else {
|
||||
return "", fmt.Errorf("spotify ID is required for Qobuz download")
|
||||
}
|
||||
|
||||
return q.DownloadTrackWithISRC(isrc, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyURL, allowFallback, useFirstArtistOnly, useSingleGenre, embedGenre)
|
||||
}
|
||||
|
||||
func (q *QobuzDownloader) DownloadTrackWithISRC(isrc, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) {
|
||||
fmt.Printf("Fetching track info for ISRC: %s\n", isrc)
|
||||
|
||||
metaChan := make(chan Metadata, 1)
|
||||
if embedGenre && isrc != "" {
|
||||
go func() {
|
||||
fmt.Println("Fetching MusicBrainz metadata...")
|
||||
if fetchedMeta, err := FetchMusicBrainzMetadata(isrc, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useSingleGenre, embedGenre); err == nil {
|
||||
fmt.Println("✓ MusicBrainz metadata fetched")
|
||||
metaChan <- fetchedMeta
|
||||
} else {
|
||||
fmt.Printf("Warning: Failed to fetch MusicBrainz metadata: %v\n", err)
|
||||
metaChan <- Metadata{}
|
||||
}
|
||||
}()
|
||||
} else {
|
||||
close(metaChan)
|
||||
}
|
||||
|
||||
if outputDir != "." {
|
||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||
@@ -320,7 +401,7 @@ func (q *QobuzDownloader) DownloadByISRC(deezerISRC, outputDir, quality, filenam
|
||||
}
|
||||
}
|
||||
|
||||
track, err := q.SearchByISRC(deezerISRC)
|
||||
track, err := q.searchByISRC(isrc)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -339,7 +420,7 @@ func (q *QobuzDownloader) DownloadByISRC(deezerISRC, outputDir, quality, filenam
|
||||
fmt.Printf("Quality: %s\n", qualityInfo)
|
||||
|
||||
fmt.Println("Getting download URL...")
|
||||
downloadURL, err := q.GetDownloadURL(track.ID, quality)
|
||||
downloadURL, err := q.GetDownloadURL(track.ID, quality, allowFallback)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get download URL: %w", err)
|
||||
}
|
||||
@@ -355,9 +436,15 @@ func (q *QobuzDownloader) DownloadByISRC(deezerISRC, outputDir, quality, filenam
|
||||
fmt.Printf("Download URL obtained: %s\n", urlPreview)
|
||||
|
||||
safeArtist := sanitizeFilename(artists)
|
||||
safeAlbumArtist := sanitizeFilename(spotifyAlbumArtist)
|
||||
|
||||
if useFirstArtistOnly {
|
||||
safeArtist = sanitizeFilename(GetFirstArtist(artists))
|
||||
safeAlbumArtist = sanitizeFilename(GetFirstArtist(spotifyAlbumArtist))
|
||||
}
|
||||
|
||||
safeTitle := sanitizeFilename(trackTitle)
|
||||
safeAlbum := sanitizeFilename(albumTitle)
|
||||
safeAlbumArtist := sanitizeFilename(spotifyAlbumArtist)
|
||||
|
||||
filename := buildQobuzFilename(safeTitle, safeArtist, safeAlbum, safeAlbumArtist, spotifyReleaseDate, spotifyTrackNumber, spotifyDiscNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
|
||||
filepath := filepath.Join(outputDir, filename)
|
||||
@@ -388,6 +475,11 @@ func (q *QobuzDownloader) DownloadByISRC(deezerISRC, outputDir, quality, filenam
|
||||
}
|
||||
}
|
||||
|
||||
var mbMeta Metadata
|
||||
if isrc != "" {
|
||||
mbMeta = <-metaChan
|
||||
}
|
||||
|
||||
fmt.Println("Embedding metadata and cover art...")
|
||||
|
||||
trackNumberToEmbed := spotifyTrackNumber
|
||||
@@ -406,9 +498,12 @@ func (q *QobuzDownloader) DownloadByISRC(deezerISRC, outputDir, quality, filenam
|
||||
DiscNumber: spotifyDiscNumber,
|
||||
TotalDiscs: spotifyTotalDiscs,
|
||||
URL: spotifyURL,
|
||||
Comment: spotifyURL,
|
||||
Copyright: spotifyCopyright,
|
||||
Publisher: spotifyPublisher,
|
||||
Description: "https://github.com/afkarxyz/SpotiFLAC",
|
||||
ISRC: isrc,
|
||||
Genre: mbMeta.Genre,
|
||||
}
|
||||
|
||||
if err := EmbedMetadata(filepath, metadata, coverPath); err != nil {
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type FlacInfo struct {
|
||||
Path string `json:"path"`
|
||||
SampleRate uint32 `json:"sample_rate"`
|
||||
BitsPerSample uint8 `json:"bits_per_sample"`
|
||||
}
|
||||
|
||||
func GetFlacInfoBatch(paths []string) []FlacInfo {
|
||||
results := make([]FlacInfo, len(paths))
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i, path := range paths {
|
||||
wg.Add(1)
|
||||
go func(idx int, p string) {
|
||||
defer wg.Done()
|
||||
info := FlacInfo{Path: p}
|
||||
|
||||
ffprobePath, err := GetFFprobePath()
|
||||
if err != nil {
|
||||
results[idx] = info
|
||||
return
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"-v", "error",
|
||||
"-select_streams", "a:0",
|
||||
"-show_entries", "stream=sample_rate,bits_per_raw_sample,bits_per_sample",
|
||||
"-of", "default=noprint_wrappers=0",
|
||||
p,
|
||||
}
|
||||
cmd := exec.Command(ffprobePath, args...)
|
||||
setHideWindow(cmd)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
results[idx] = info
|
||||
return
|
||||
}
|
||||
|
||||
kvMap := make(map[string]string)
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
if parts := strings.SplitN(line, "=", 2); len(parts) == 2 {
|
||||
kvMap[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
|
||||
}
|
||||
}
|
||||
|
||||
if v, ok := kvMap["sample_rate"]; ok {
|
||||
if s, err := strconv.Atoi(v); err == nil {
|
||||
info.SampleRate = uint32(s)
|
||||
}
|
||||
}
|
||||
|
||||
bits := 0
|
||||
if v, ok := kvMap["bits_per_raw_sample"]; ok && v != "N/A" && v != "" {
|
||||
bits, _ = strconv.Atoi(v)
|
||||
}
|
||||
if bits == 0 {
|
||||
if v, ok := kvMap["bits_per_sample"]; ok && v != "N/A" && v != "" {
|
||||
bits, _ = strconv.Atoi(v)
|
||||
}
|
||||
}
|
||||
info.BitsPerSample = uint8(bits)
|
||||
|
||||
results[idx] = info
|
||||
}(i, path)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
return results
|
||||
}
|
||||
|
||||
type ResampleRequest struct {
|
||||
InputFiles []string `json:"input_files"`
|
||||
SampleRate string `json:"sample_rate"`
|
||||
BitDepth string `json:"bit_depth"`
|
||||
}
|
||||
|
||||
type ResampleResult struct {
|
||||
InputFile string `json:"input_file"`
|
||||
OutputFile string `json:"output_file"`
|
||||
Success bool `json:"success"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func buildFolderLabel(sampleRate, bitDepth string) string {
|
||||
var parts []string
|
||||
|
||||
if bitDepth != "" {
|
||||
parts = append(parts, bitDepth+"bit")
|
||||
}
|
||||
|
||||
switch sampleRate {
|
||||
case "44100":
|
||||
parts = append(parts, "44.1kHz")
|
||||
case "48000":
|
||||
parts = append(parts, "48kHz")
|
||||
case "96000":
|
||||
parts = append(parts, "96kHz")
|
||||
case "192000":
|
||||
parts = append(parts, "192kHz")
|
||||
default:
|
||||
if sampleRate != "" {
|
||||
parts = append(parts, sampleRate+"Hz")
|
||||
}
|
||||
}
|
||||
|
||||
if len(parts) == 0 {
|
||||
return "Resampled"
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func ResampleAudio(req ResampleRequest) ([]ResampleResult, error) {
|
||||
ffmpegPath, err := GetFFmpegPath()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get ffmpeg path: %w", err)
|
||||
}
|
||||
|
||||
if err := ValidateExecutable(ffmpegPath); err != nil {
|
||||
return nil, fmt.Errorf("invalid ffmpeg executable: %w", err)
|
||||
}
|
||||
|
||||
installed, err := IsFFmpegInstalled()
|
||||
if err != nil || !installed {
|
||||
return nil, fmt.Errorf("ffmpeg is not installed")
|
||||
}
|
||||
|
||||
if req.SampleRate == "" && req.BitDepth == "" {
|
||||
return nil, fmt.Errorf("at least one of sample rate or bit depth must be specified")
|
||||
}
|
||||
|
||||
results := make([]ResampleResult, len(req.InputFiles))
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
|
||||
folderLabel := buildFolderLabel(req.SampleRate, req.BitDepth)
|
||||
|
||||
for i, inputFile := range req.InputFiles {
|
||||
wg.Add(1)
|
||||
go func(idx int, inputFile string) {
|
||||
defer wg.Done()
|
||||
|
||||
result := ResampleResult{
|
||||
InputFile: inputFile,
|
||||
}
|
||||
|
||||
inputExt := strings.ToLower(filepath.Ext(inputFile))
|
||||
baseName := strings.TrimSuffix(filepath.Base(inputFile), inputExt)
|
||||
inputDir := filepath.Dir(inputFile)
|
||||
|
||||
outputDir := filepath.Join(inputDir, folderLabel)
|
||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||
result.Error = fmt.Sprintf("failed to create output directory: %v", err)
|
||||
result.Success = false
|
||||
mu.Lock()
|
||||
results[idx] = result
|
||||
mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
outputFile := filepath.Join(outputDir, baseName+".flac")
|
||||
result.OutputFile = outputFile
|
||||
|
||||
args := []string{
|
||||
"-i", inputFile,
|
||||
"-y",
|
||||
}
|
||||
|
||||
if req.BitDepth != "" {
|
||||
switch req.BitDepth {
|
||||
case "16":
|
||||
args = append(args, "-c:a", "flac", "-sample_fmt", "s16")
|
||||
case "24":
|
||||
args = append(args, "-c:a", "flac", "-sample_fmt", "s32", "-bits_per_raw_sample", "24")
|
||||
default:
|
||||
args = append(args, "-c:a", "flac")
|
||||
}
|
||||
} else {
|
||||
args = append(args, "-c:a", "flac")
|
||||
}
|
||||
|
||||
if req.SampleRate != "" {
|
||||
args = append(args, "-ar", req.SampleRate)
|
||||
}
|
||||
|
||||
args = append(args, "-map_metadata", "0")
|
||||
args = append(args, outputFile)
|
||||
|
||||
fmt.Printf("[Resample] %s -> %s\n", inputFile, outputFile)
|
||||
|
||||
cmd := exec.Command(ffmpegPath, args...)
|
||||
setHideWindow(cmd)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
result.Error = fmt.Sprintf("resampling failed: %s - %s", err.Error(), string(output))
|
||||
result.Success = false
|
||||
mu.Lock()
|
||||
results[idx] = result
|
||||
mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
result.Success = true
|
||||
fmt.Printf("[Resample] Done: %s\n", outputFile)
|
||||
mu.Lock()
|
||||
results[idx] = result
|
||||
mu.Unlock()
|
||||
}(i, inputFile)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
return results, nil
|
||||
}
|
||||
@@ -1,26 +1,32 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const songLinkUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"
|
||||
|
||||
var (
|
||||
isrcPattern = regexp.MustCompile(`\b([A-Z]{2}[A-Z0-9]{3}\d{7})\b`)
|
||||
amazonAlbumTrackPath = regexp.MustCompile(`/albums/[A-Z0-9]{10}/(B[0-9A-Z]{9})`)
|
||||
amazonTrackPath = regexp.MustCompile(`/tracks/(B[0-9A-Z]{9})`)
|
||||
)
|
||||
|
||||
type SongLinkClient struct {
|
||||
client *http.Client
|
||||
lastAPICallTime time.Time
|
||||
apiCallCount int
|
||||
apiCallResetTime time.Time
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
type SongLinkURLs struct {
|
||||
TidalURL string `json:"tidal_url"`
|
||||
AmazonURL string `json:"amazon_url"`
|
||||
ISRC string `json:"isrc"`
|
||||
}
|
||||
|
||||
type TrackAvailability struct {
|
||||
@@ -28,9 +34,17 @@ type TrackAvailability struct {
|
||||
Tidal bool `json:"tidal"`
|
||||
Amazon bool `json:"amazon"`
|
||||
Qobuz bool `json:"qobuz"`
|
||||
Deezer bool `json:"deezer"`
|
||||
TidalURL string `json:"tidal_url,omitempty"`
|
||||
AmazonURL string `json:"amazon_url,omitempty"`
|
||||
QobuzURL string `json:"qobuz_url,omitempty"`
|
||||
DeezerURL string `json:"deezer_url,omitempty"`
|
||||
}
|
||||
|
||||
type songLinkAPIResponse struct {
|
||||
LinksByPlatform map[string]struct {
|
||||
URL string `json:"url"`
|
||||
} `json:"linksByPlatform"`
|
||||
}
|
||||
|
||||
func NewSongLinkClient() *SongLinkClient {
|
||||
@@ -38,258 +52,91 @@ func NewSongLinkClient() *SongLinkClient {
|
||||
client: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
apiCallResetTime: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string) (*SongLinkURLs, error) {
|
||||
|
||||
now := time.Now()
|
||||
if now.Sub(s.apiCallResetTime) >= time.Minute {
|
||||
s.apiCallCount = 0
|
||||
s.apiCallResetTime = now
|
||||
}
|
||||
|
||||
if s.apiCallCount >= 9 {
|
||||
waitTime := time.Minute - now.Sub(s.apiCallResetTime)
|
||||
if waitTime > 0 {
|
||||
fmt.Printf("Rate limit reached, waiting %v...\n", waitTime.Round(time.Second))
|
||||
time.Sleep(waitTime)
|
||||
s.apiCallCount = 0
|
||||
s.apiCallResetTime = time.Now()
|
||||
}
|
||||
}
|
||||
|
||||
if !s.lastAPICallTime.IsZero() {
|
||||
timeSinceLastCall := now.Sub(s.lastAPICallTime)
|
||||
minDelay := 7 * time.Second
|
||||
if timeSinceLastCall < minDelay {
|
||||
waitTime := minDelay - timeSinceLastCall
|
||||
fmt.Printf("Rate limiting: waiting %v...\n", waitTime.Round(time.Second))
|
||||
time.Sleep(waitTime)
|
||||
}
|
||||
}
|
||||
|
||||
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
|
||||
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID)
|
||||
|
||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==")
|
||||
apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(spotifyURL))
|
||||
|
||||
req, err := http.NewRequest("GET", apiURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("Getting streaming URLs from song.link...")
|
||||
|
||||
maxRetries := 3
|
||||
var resp *http.Response
|
||||
for i := 0; i < maxRetries; i++ {
|
||||
resp, err = s.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get URLs: %w", err)
|
||||
}
|
||||
|
||||
s.lastAPICallTime = time.Now()
|
||||
s.apiCallCount++
|
||||
|
||||
if resp.StatusCode == 429 {
|
||||
resp.Body.Close()
|
||||
if i < maxRetries-1 {
|
||||
waitTime := 15 * time.Second
|
||||
fmt.Printf("Rate limited by API, waiting %v before retry...\n", waitTime)
|
||||
time.Sleep(waitTime)
|
||||
continue
|
||||
}
|
||||
return nil, fmt.Errorf("API rate limit exceeded after %d retries", maxRetries)
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
resp.Body.Close()
|
||||
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var songLinkResp struct {
|
||||
LinksByPlatform map[string]struct {
|
||||
URL string `json:"url"`
|
||||
} `json:"linksByPlatform"`
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
|
||||
if len(body) == 0 {
|
||||
return nil, fmt.Errorf("API returned empty response")
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &songLinkResp); err != nil {
|
||||
|
||||
bodyStr := string(body)
|
||||
if len(bodyStr) > 200 {
|
||||
bodyStr = bodyStr[:200] + "..."
|
||||
}
|
||||
return nil, fmt.Errorf("failed to decode response: %w (response: %s)", err, bodyStr)
|
||||
func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string, region string) (*SongLinkURLs, error) {
|
||||
links, err := s.resolveSpotifyTrackLinks(spotifyTrackID, region)
|
||||
if err != nil && (links == nil || (links.TidalURL == "" && links.AmazonURL == "")) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
urls := &SongLinkURLs{}
|
||||
|
||||
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
||||
urls.TidalURL = tidalLink.URL
|
||||
fmt.Printf("✓ Tidal URL found\n")
|
||||
}
|
||||
|
||||
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
||||
amazonURL := amazonLink.URL
|
||||
|
||||
if len(amazonURL) > 0 {
|
||||
urls.AmazonURL = amazonURL
|
||||
fmt.Printf("✓ Amazon URL found\n")
|
||||
}
|
||||
if links != nil {
|
||||
urls.TidalURL = links.TidalURL
|
||||
urls.AmazonURL = normalizeAmazonMusicURL(links.AmazonURL)
|
||||
urls.ISRC = links.ISRC
|
||||
}
|
||||
|
||||
if urls.TidalURL == "" && urls.AmazonURL == "" {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, fmt.Errorf("no streaming URLs found")
|
||||
}
|
||||
|
||||
return urls, nil
|
||||
}
|
||||
|
||||
func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, error) {
|
||||
|
||||
now := time.Now()
|
||||
if now.Sub(s.apiCallResetTime) >= time.Minute {
|
||||
s.apiCallCount = 0
|
||||
s.apiCallResetTime = now
|
||||
}
|
||||
|
||||
if s.apiCallCount >= 9 {
|
||||
waitTime := time.Minute - now.Sub(s.apiCallResetTime)
|
||||
if waitTime > 0 {
|
||||
fmt.Printf("Rate limit reached, waiting %v...\n", waitTime.Round(time.Second))
|
||||
time.Sleep(waitTime)
|
||||
s.apiCallCount = 0
|
||||
s.apiCallResetTime = time.Now()
|
||||
}
|
||||
}
|
||||
|
||||
if !s.lastAPICallTime.IsZero() {
|
||||
timeSinceLastCall := now.Sub(s.lastAPICallTime)
|
||||
minDelay := 7 * time.Second
|
||||
if timeSinceLastCall < minDelay {
|
||||
waitTime := minDelay - timeSinceLastCall
|
||||
fmt.Printf("Rate limiting: waiting %v...\n", waitTime.Round(time.Second))
|
||||
time.Sleep(waitTime)
|
||||
}
|
||||
}
|
||||
|
||||
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
|
||||
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID)
|
||||
|
||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==")
|
||||
apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(spotifyURL))
|
||||
|
||||
req, err := http.NewRequest("GET", apiURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Checking availability for track: %s\n", spotifyTrackID)
|
||||
|
||||
maxRetries := 3
|
||||
var resp *http.Response
|
||||
for i := 0; i < maxRetries; i++ {
|
||||
resp, err = s.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check availability: %w", err)
|
||||
}
|
||||
|
||||
s.lastAPICallTime = time.Now()
|
||||
s.apiCallCount++
|
||||
|
||||
if resp.StatusCode == 429 {
|
||||
resp.Body.Close()
|
||||
if i < maxRetries-1 {
|
||||
waitTime := 15 * time.Second
|
||||
fmt.Printf("Rate limited by API, waiting %v before retry...\n", waitTime)
|
||||
time.Sleep(waitTime)
|
||||
continue
|
||||
}
|
||||
return nil, fmt.Errorf("API rate limit exceeded after %d retries", maxRetries)
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
resp.Body.Close()
|
||||
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var songLinkResp struct {
|
||||
LinksByPlatform map[string]struct {
|
||||
URL string `json:"url"`
|
||||
} `json:"linksByPlatform"`
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
|
||||
if len(body) == 0 {
|
||||
return nil, fmt.Errorf("API returned empty response")
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &songLinkResp); err != nil {
|
||||
|
||||
bodyStr := string(body)
|
||||
if len(bodyStr) > 200 {
|
||||
bodyStr = bodyStr[:200] + "..."
|
||||
}
|
||||
return nil, fmt.Errorf("failed to decode response: %w (response: %s)", err, bodyStr)
|
||||
}
|
||||
func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string) (*TrackAvailability, error) {
|
||||
links, err := s.resolveSpotifyTrackLinks(spotifyTrackID, "")
|
||||
|
||||
availability := &TrackAvailability{
|
||||
SpotifyID: spotifyTrackID,
|
||||
}
|
||||
|
||||
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
||||
availability.Tidal = true
|
||||
availability.TidalURL = tidalLink.URL
|
||||
if links != nil {
|
||||
availability.TidalURL = links.TidalURL
|
||||
availability.AmazonURL = normalizeAmazonMusicURL(links.AmazonURL)
|
||||
availability.DeezerURL = normalizeDeezerTrackURL(links.DeezerURL)
|
||||
availability.Tidal = availability.TidalURL != ""
|
||||
availability.Amazon = availability.AmazonURL != ""
|
||||
availability.Deezer = availability.DeezerURL != ""
|
||||
}
|
||||
|
||||
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
||||
availability.Amazon = true
|
||||
availability.AmazonURL = amazonLink.URL
|
||||
isrc := ""
|
||||
if links != nil {
|
||||
isrc = strings.TrimSpace(links.ISRC)
|
||||
}
|
||||
|
||||
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
||||
deezerURL := deezerLink.URL
|
||||
|
||||
deezerISRC, err := GetDeezerISRC(deezerURL)
|
||||
if err == nil && deezerISRC != "" {
|
||||
qobuzAvailable := checkQobuzAvailability(deezerISRC)
|
||||
availability.Qobuz = qobuzAvailable
|
||||
if isrc == "" && availability.DeezerURL != "" {
|
||||
if resolvedISRC, deezerErr := getDeezerISRC(availability.DeezerURL); deezerErr == nil {
|
||||
isrc = resolvedISRC
|
||||
}
|
||||
}
|
||||
|
||||
return availability, nil
|
||||
if isrc == "" {
|
||||
if fallbackISRC, fallbackErr := s.lookupSpotifyISRC(spotifyTrackID); fallbackErr == nil {
|
||||
isrc = fallbackISRC
|
||||
} else if err == nil {
|
||||
err = fallbackErr
|
||||
}
|
||||
}
|
||||
|
||||
if isrc != "" {
|
||||
availability.Qobuz = checkQobuzAvailability(isrc)
|
||||
}
|
||||
|
||||
if availability.Tidal || availability.Amazon || availability.Deezer || availability.Qobuz {
|
||||
return availability, nil
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return availability, err
|
||||
}
|
||||
|
||||
return availability, fmt.Errorf("no platforms found")
|
||||
}
|
||||
|
||||
func checkQobuzAvailability(isrc string) bool {
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
appID := "798273057"
|
||||
|
||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
|
||||
searchURL := fmt.Sprintf("%s%s&limit=1&app_id=%s", string(apiBase), isrc, appID)
|
||||
searchURL := fmt.Sprintf(
|
||||
"https://www.qobuz.com/api.json/0.2/track/search?query=%s&limit=1&app_id=%s",
|
||||
url.QueryEscape(strings.TrimSpace(isrc)),
|
||||
appID,
|
||||
)
|
||||
|
||||
resp, err := client.Get(searchURL)
|
||||
if err != nil {
|
||||
@@ -297,7 +144,7 @@ func checkQobuzAvailability(isrc string) bool {
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -314,109 +161,47 @@ func checkQobuzAvailability(isrc string) bool {
|
||||
}
|
||||
|
||||
func (s *SongLinkClient) GetDeezerURLFromSpotify(spotifyTrackID string) (string, error) {
|
||||
|
||||
now := time.Now()
|
||||
if now.Sub(s.apiCallResetTime) >= time.Minute {
|
||||
s.apiCallCount = 0
|
||||
s.apiCallResetTime = now
|
||||
links, err := s.resolveSpotifyTrackLinks(spotifyTrackID, "")
|
||||
if links != nil && links.DeezerURL != "" {
|
||||
deezerURL := normalizeDeezerTrackURL(links.DeezerURL)
|
||||
fmt.Printf("Found Deezer URL: %s\n", deezerURL)
|
||||
return deezerURL, nil
|
||||
}
|
||||
|
||||
if s.apiCallCount >= 9 {
|
||||
waitTime := time.Minute - now.Sub(s.apiCallResetTime)
|
||||
if waitTime > 0 {
|
||||
fmt.Printf("Rate limit reached, waiting %v...\n", waitTime.Round(time.Second))
|
||||
time.Sleep(waitTime)
|
||||
s.apiCallCount = 0
|
||||
s.apiCallResetTime = time.Now()
|
||||
isrc := ""
|
||||
if links != nil {
|
||||
isrc = strings.TrimSpace(links.ISRC)
|
||||
}
|
||||
if isrc == "" {
|
||||
fallbackISRC, lookupErr := s.lookupSpotifyISRC(spotifyTrackID)
|
||||
if lookupErr == nil {
|
||||
isrc = fallbackISRC
|
||||
} else if err == nil {
|
||||
err = lookupErr
|
||||
}
|
||||
}
|
||||
|
||||
if !s.lastAPICallTime.IsZero() {
|
||||
timeSinceLastCall := now.Sub(s.lastAPICallTime)
|
||||
minDelay := 7 * time.Second
|
||||
if timeSinceLastCall < minDelay {
|
||||
waitTime := minDelay - timeSinceLastCall
|
||||
fmt.Printf("Rate limiting: waiting %v...\n", waitTime.Round(time.Second))
|
||||
time.Sleep(waitTime)
|
||||
if isrc != "" {
|
||||
deezerURL, deezerErr := s.lookupDeezerTrackURLByISRC(isrc)
|
||||
if deezerErr == nil {
|
||||
fmt.Printf("Found Deezer URL: %s\n", deezerURL)
|
||||
return deezerURL, nil
|
||||
}
|
||||
if err == nil {
|
||||
err = deezerErr
|
||||
}
|
||||
}
|
||||
|
||||
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
|
||||
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID)
|
||||
|
||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==")
|
||||
apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(spotifyURL))
|
||||
|
||||
req, err := http.NewRequest("GET", apiURL, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
fmt.Println("Getting Deezer URL from song.link...")
|
||||
|
||||
maxRetries := 3
|
||||
var resp *http.Response
|
||||
for i := 0; i < maxRetries; i++ {
|
||||
resp, err = s.client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get Deezer URL: %w", err)
|
||||
}
|
||||
|
||||
s.lastAPICallTime = time.Now()
|
||||
s.apiCallCount++
|
||||
|
||||
if resp.StatusCode == 429 {
|
||||
resp.Body.Close()
|
||||
if i < maxRetries-1 {
|
||||
waitTime := 15 * time.Second
|
||||
fmt.Printf("Rate limited by API, waiting %v before retry...\n", waitTime)
|
||||
time.Sleep(waitTime)
|
||||
continue
|
||||
}
|
||||
return "", fmt.Errorf("API rate limit exceeded after %d retries", maxRetries)
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
resp.Body.Close()
|
||||
return "", fmt.Errorf("API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var songLinkResp struct {
|
||||
LinksByPlatform map[string]struct {
|
||||
URL string `json:"url"`
|
||||
} `json:"linksByPlatform"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&songLinkResp); err != nil {
|
||||
return "", fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]
|
||||
if !ok || deezerLink.URL == "" {
|
||||
return "", fmt.Errorf("deezer link not found")
|
||||
}
|
||||
|
||||
deezerURL := deezerLink.URL
|
||||
fmt.Printf("Found Deezer URL: %s\n", deezerURL)
|
||||
return deezerURL, nil
|
||||
return "", fmt.Errorf("deezer link not found")
|
||||
}
|
||||
|
||||
func GetDeezerISRC(deezerURL string) (string, error) {
|
||||
|
||||
var trackID string
|
||||
if strings.Contains(deezerURL, "/track/") {
|
||||
parts := strings.Split(deezerURL, "/track/")
|
||||
if len(parts) > 1 {
|
||||
trackID = strings.Split(parts[1], "?")[0]
|
||||
trackID = strings.TrimSpace(trackID)
|
||||
}
|
||||
}
|
||||
|
||||
if trackID == "" {
|
||||
return "", fmt.Errorf("could not extract track ID from Deezer URL: %s", deezerURL)
|
||||
func getDeezerISRC(deezerURL string) (string, error) {
|
||||
trackID, err := extractDeezerTrackID(deezerURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
apiURL := fmt.Sprintf("https://api.deezer.com/track/%s", trackID)
|
||||
@@ -428,7 +213,7 @@ func GetDeezerISRC(deezerURL string) (string, error) {
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("Deezer API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
@@ -446,5 +231,211 @@ func GetDeezerISRC(deezerURL string) (string, error) {
|
||||
}
|
||||
|
||||
fmt.Printf("Found ISRC from Deezer: %s (track: %s)\n", deezerTrack.ISRC, deezerTrack.Title)
|
||||
return deezerTrack.ISRC, nil
|
||||
return strings.ToUpper(strings.TrimSpace(deezerTrack.ISRC)), nil
|
||||
}
|
||||
|
||||
func (s *SongLinkClient) GetISRC(spotifyID string) (string, error) {
|
||||
links, err := s.resolveSpotifyTrackLinks(spotifyID, "")
|
||||
if links != nil && links.ISRC != "" {
|
||||
return links.ISRC, nil
|
||||
}
|
||||
|
||||
if links != nil && links.DeezerURL != "" {
|
||||
if isrc, deezerErr := getDeezerISRC(links.DeezerURL); deezerErr == nil {
|
||||
return isrc, nil
|
||||
}
|
||||
}
|
||||
|
||||
isrc, lookupErr := s.lookupSpotifyISRC(spotifyID)
|
||||
if lookupErr == nil && isrc != "" {
|
||||
return isrc, nil
|
||||
}
|
||||
|
||||
if err != nil && lookupErr != nil {
|
||||
return "", fmt.Errorf("%v | %v", err, lookupErr)
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if lookupErr != nil {
|
||||
return "", lookupErr
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("ISRC not found")
|
||||
}
|
||||
|
||||
func (s *SongLinkClient) GetISRCDirect(spotifyID string) (string, error) {
|
||||
return s.lookupSpotifyISRC(spotifyID)
|
||||
}
|
||||
|
||||
func (s *SongLinkClient) fetchSongLinkLinksByURL(rawURL string, region string) (*songLinkAPIResponse, error) {
|
||||
apiURL := fmt.Sprintf("https://api.song.link/v1-alpha.1/links?url=%s", url.QueryEscape(rawURL))
|
||||
if region != "" {
|
||||
apiURL += fmt.Sprintf("&userCountry=%s", url.QueryEscape(region))
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, apiURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", songLinkUserAgent)
|
||||
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to call song.link: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyPreview, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
|
||||
return nil, fmt.Errorf("song.link returned status %d (%s)", resp.StatusCode, strings.TrimSpace(string(bodyPreview)))
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read song.link response: %w", err)
|
||||
}
|
||||
if len(body) == 0 {
|
||||
return nil, fmt.Errorf("song.link returned empty response")
|
||||
}
|
||||
|
||||
var parsed songLinkAPIResponse
|
||||
if err := json.Unmarshal(body, &parsed); err != nil {
|
||||
bodyStr := string(body)
|
||||
if len(bodyStr) > 200 {
|
||||
bodyStr = bodyStr[:200] + "..."
|
||||
}
|
||||
return nil, fmt.Errorf("failed to decode song.link response: %w (response: %s)", err, bodyStr)
|
||||
}
|
||||
|
||||
return &parsed, nil
|
||||
}
|
||||
|
||||
func (s *SongLinkClient) lookupDeezerTrackURLByISRC(isrc string) (string, error) {
|
||||
apiURL := fmt.Sprintf("https://api.deezer.com/track/isrc:%s", strings.ToUpper(strings.TrimSpace(isrc)))
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, apiURL, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", songLinkUserAgent)
|
||||
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to call Deezer ISRC API: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("Deezer ISRC API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
ID int64 `json:"id"`
|
||||
ISRC string `json:"isrc"`
|
||||
Link string `json:"link"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||
return "", fmt.Errorf("failed to decode Deezer ISRC response: %w", err)
|
||||
}
|
||||
|
||||
if payload.Link != "" {
|
||||
return normalizeDeezerTrackURL(payload.Link), nil
|
||||
}
|
||||
if payload.ID > 0 {
|
||||
return normalizeDeezerTrackURL(fmt.Sprintf("https://www.deezer.com/track/%d", payload.ID)), nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("deezer track link not found for ISRC %s", isrc)
|
||||
}
|
||||
|
||||
func mergeSongLinkResponse(links *resolvedTrackLinks, resp *songLinkAPIResponse) {
|
||||
if resp == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if link, ok := resp.LinksByPlatform["tidal"]; ok && link.URL != "" && links.TidalURL == "" {
|
||||
links.TidalURL = strings.TrimSpace(link.URL)
|
||||
fmt.Println("✓ Tidal URL found")
|
||||
}
|
||||
|
||||
if link, ok := resp.LinksByPlatform["amazonMusic"]; ok && link.URL != "" && links.AmazonURL == "" {
|
||||
links.AmazonURL = normalizeAmazonMusicURL(link.URL)
|
||||
fmt.Println("✓ Amazon URL found")
|
||||
}
|
||||
|
||||
if link, ok := resp.LinksByPlatform["deezer"]; ok && link.URL != "" && links.DeezerURL == "" {
|
||||
links.DeezerURL = normalizeDeezerTrackURL(link.URL)
|
||||
fmt.Println("✓ Deezer URL found")
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeAmazonMusicURL(rawURL string) string {
|
||||
amazonURL := strings.TrimSpace(rawURL)
|
||||
if amazonURL == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
if strings.Contains(amazonURL, "trackAsin=") {
|
||||
parts := strings.Split(amazonURL, "trackAsin=")
|
||||
if len(parts) > 1 {
|
||||
trackAsin := strings.Split(parts[1], "&")[0]
|
||||
if trackAsin != "" {
|
||||
return fmt.Sprintf("https://music.amazon.com/tracks/%s?musicTerritory=US", trackAsin)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if match := amazonAlbumTrackPath.FindStringSubmatch(amazonURL); len(match) > 1 {
|
||||
return fmt.Sprintf("https://music.amazon.com/tracks/%s?musicTerritory=US", match[1])
|
||||
}
|
||||
|
||||
if match := amazonTrackPath.FindStringSubmatch(amazonURL); len(match) > 1 {
|
||||
return fmt.Sprintf("https://music.amazon.com/tracks/%s?musicTerritory=US", match[1])
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func normalizeDeezerTrackURL(rawURL string) string {
|
||||
trackID, err := extractDeezerTrackID(rawURL)
|
||||
if err != nil {
|
||||
return strings.TrimSpace(rawURL)
|
||||
}
|
||||
return fmt.Sprintf("https://www.deezer.com/track/%s", trackID)
|
||||
}
|
||||
|
||||
func extractDeezerTrackID(rawURL string) (string, error) {
|
||||
cleanURL := strings.TrimSpace(rawURL)
|
||||
if cleanURL == "" {
|
||||
return "", fmt.Errorf("empty Deezer URL")
|
||||
}
|
||||
|
||||
parts := strings.Split(cleanURL, "/track/")
|
||||
if len(parts) < 2 {
|
||||
return "", fmt.Errorf("could not extract track ID from Deezer URL: %s", rawURL)
|
||||
}
|
||||
|
||||
trackID := strings.Split(parts[1], "?")[0]
|
||||
trackID = strings.Trim(trackID, "/ ")
|
||||
if trackID == "" {
|
||||
return "", fmt.Errorf("could not extract track ID from Deezer URL: %s", rawURL)
|
||||
}
|
||||
|
||||
return trackID, nil
|
||||
}
|
||||
|
||||
func hasAnySongLinkData(links *resolvedTrackLinks) bool {
|
||||
if links == nil {
|
||||
return false
|
||||
}
|
||||
return links.TidalURL != "" || links.AmazonURL != "" || links.DeezerURL != ""
|
||||
}
|
||||
|
||||
func firstISRCMatch(body string) string {
|
||||
match := isrcPattern.FindStringSubmatch(strings.ToUpper(body))
|
||||
if len(match) < 2 {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(match[1])
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var songstatsScriptPattern = regexp.MustCompile(`(?is)<script[^>]+type=["']application/ld\+json["'][^>]*>(.*?)</script>`)
|
||||
|
||||
func (s *SongLinkClient) populateLinksFromSongstats(links *resolvedTrackLinks, isrc string) error {
|
||||
pageURL := fmt.Sprintf("https://songstats.com/%s?ref=ISRCFinder", strings.ToUpper(strings.TrimSpace(isrc)))
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, pageURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", songLinkUserAgent)
|
||||
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch Songstats page: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("Songstats returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read Songstats response: %w", err)
|
||||
}
|
||||
|
||||
matches := songstatsScriptPattern.FindAllStringSubmatch(string(body), -1)
|
||||
if len(matches) == 0 {
|
||||
return fmt.Errorf("Songstats JSON-LD not found")
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, match := range matches {
|
||||
if len(match) < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
scriptBody := strings.TrimSpace(html.UnescapeString(match[1]))
|
||||
if scriptBody == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
var payload interface{}
|
||||
if err := json.Unmarshal([]byte(scriptBody), &payload); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
before := *links
|
||||
collectSongstatsLinks(payload, links)
|
||||
if *links != before {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
|
||||
if !found && !hasAnySongLinkData(links) {
|
||||
return fmt.Errorf("no platform links found in Songstats")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func collectSongstatsLinks(value interface{}, links *resolvedTrackLinks) {
|
||||
switch typed := value.(type) {
|
||||
case map[string]interface{}:
|
||||
if sameAs, ok := typed["sameAs"]; ok {
|
||||
applySongstatsSameAs(sameAs, links)
|
||||
}
|
||||
for _, nested := range typed {
|
||||
collectSongstatsLinks(nested, links)
|
||||
}
|
||||
case []interface{}:
|
||||
for _, nested := range typed {
|
||||
collectSongstatsLinks(nested, links)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func applySongstatsSameAs(value interface{}, links *resolvedTrackLinks) {
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
assignSongstatsLink(typed, links)
|
||||
case []interface{}:
|
||||
for _, item := range typed {
|
||||
if link, ok := item.(string); ok {
|
||||
assignSongstatsLink(link, links)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assignSongstatsLink(rawLink string, links *resolvedTrackLinks) {
|
||||
link := strings.TrimSpace(rawLink)
|
||||
if link == "" {
|
||||
return
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.Contains(link, "listen.tidal.com/track"):
|
||||
if links.TidalURL == "" {
|
||||
links.TidalURL = link
|
||||
fmt.Println("✓ Tidal URL found via Songstats")
|
||||
}
|
||||
case strings.Contains(link, "music.amazon.com"):
|
||||
if links.AmazonURL == "" {
|
||||
if normalized := normalizeAmazonMusicURL(link); normalized != "" {
|
||||
links.AmazonURL = normalized
|
||||
fmt.Println("✓ Amazon URL found via Songstats")
|
||||
}
|
||||
}
|
||||
case strings.Contains(link, "deezer.com"):
|
||||
if links.DeezerURL == "" {
|
||||
links.DeezerURL = normalizeDeezerTrackURL(link)
|
||||
fmt.Println("✓ Deezer URL found via Songstats")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
soundplateSpotifyAPIURL = "https://phpstack-822472-6184058.cloudwaysapps.com/api/spotify.php"
|
||||
soundplateRefererURL = "https://phpstack-822472-6184058.cloudwaysapps.com/?"
|
||||
soundplateUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"
|
||||
)
|
||||
|
||||
type soundplateSpotifyResponse struct {
|
||||
Name string `json:"name"`
|
||||
Artist string `json:"artist"`
|
||||
Album string `json:"album"`
|
||||
AlbumType string `json:"album_type"`
|
||||
ArtworkURL string `json:"artwork_url"`
|
||||
ISRC string `json:"isrc"`
|
||||
Year string `json:"year"`
|
||||
SpotifyURL string `json:"spotify_url"`
|
||||
}
|
||||
|
||||
func (s *SongLinkClient) lookupSpotifyISRCViaSoundplate(spotifyTrackID string) (string, string, error) {
|
||||
normalizedTrackID, err := extractSpotifyTrackID(spotifyTrackID)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
spotifyTrackURL := fmt.Sprintf("https://open.spotify.com/track/%s", normalizedTrackID)
|
||||
query := url.Values{}
|
||||
query.Set("q", spotifyTrackURL)
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, soundplateSpotifyAPIURL+"?"+query.Encode(), nil)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to create Soundplate ISRC request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", soundplateUserAgent)
|
||||
req.Header.Set("Accept", "*/*")
|
||||
req.Header.Set("Referer", soundplateRefererURL)
|
||||
req.Header.Set("Accept-Language", "en-US,en;q=0.9,id;q=0.8")
|
||||
req.Header.Set("Sec-CH-UA", "\"Chromium\";v=\"146\", \"Not-A.Brand\";v=\"24\", \"Google Chrome\";v=\"146\"")
|
||||
req.Header.Set("Sec-CH-UA-Mobile", "?0")
|
||||
req.Header.Set("Sec-CH-UA-Platform", "\"Windows\"")
|
||||
req.Header.Set("Sec-Fetch-Dest", "empty")
|
||||
req.Header.Set("Sec-Fetch-Mode", "cors")
|
||||
req.Header.Set("Sec-Fetch-Site", "same-origin")
|
||||
req.Header.Set("Priority", "u=1, i")
|
||||
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("Soundplate ISRC request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to read Soundplate ISRC response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyPreview := strings.TrimSpace(string(body))
|
||||
if len(bodyPreview) > 256 {
|
||||
bodyPreview = bodyPreview[:256]
|
||||
}
|
||||
return "", "", fmt.Errorf("Soundplate ISRC returned status %d (%s)", resp.StatusCode, bodyPreview)
|
||||
}
|
||||
|
||||
var payload soundplateSpotifyResponse
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
return "", "", fmt.Errorf("failed to decode Soundplate ISRC response: %w", err)
|
||||
}
|
||||
|
||||
isrc := firstISRCMatch(payload.ISRC)
|
||||
if isrc == "" {
|
||||
isrc = firstISRCMatch(string(body))
|
||||
}
|
||||
if isrc == "" {
|
||||
return "", "", fmt.Errorf("ISRC missing in Soundplate response")
|
||||
}
|
||||
|
||||
resolvedTrackID := ""
|
||||
if payload.SpotifyURL != "" {
|
||||
if trackID, err := extractSpotifyTrackID(payload.SpotifyURL); err == nil {
|
||||
resolvedTrackID = trackID
|
||||
}
|
||||
}
|
||||
|
||||
return isrc, resolvedTrackID, nil
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"math/cmplx"
|
||||
|
||||
"github.com/mewkiz/flac"
|
||||
)
|
||||
|
||||
type SpectrumData struct {
|
||||
TimeSlices []TimeSlice `json:"time_slices"`
|
||||
SampleRate int `json:"sample_rate"`
|
||||
FreqBins int `json:"freq_bins"`
|
||||
Duration float64 `json:"duration"`
|
||||
MaxFreq float64 `json:"max_freq"`
|
||||
}
|
||||
|
||||
type TimeSlice struct {
|
||||
Time float64 `json:"time"`
|
||||
Magnitudes []float64 `json:"magnitudes"`
|
||||
}
|
||||
|
||||
func AnalyzeSpectrum(filepath string) (*SpectrumData, error) {
|
||||
|
||||
stream, err := flac.ParseFile(filepath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse FLAC: %w", err)
|
||||
}
|
||||
defer stream.Close()
|
||||
|
||||
info := stream.Info
|
||||
sampleRate := int(info.SampleRate)
|
||||
channels := int(info.NChannels)
|
||||
|
||||
samples, err := readSamples(stream, channels)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read samples: %w", err)
|
||||
}
|
||||
|
||||
if len(samples) == 0 {
|
||||
return nil, fmt.Errorf("no audio samples found")
|
||||
}
|
||||
|
||||
return calculateSpectrum(samples, sampleRate), nil
|
||||
}
|
||||
|
||||
func readSamples(stream *flac.Stream, channels int) ([]float64, error) {
|
||||
var allSamples []float64
|
||||
maxSamples := 10 * 1024 * 1024
|
||||
|
||||
for {
|
||||
frame, err := stream.ParseNext()
|
||||
if err != nil {
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
for i := 0; i < frame.Subframes[0].NSamples; i++ {
|
||||
var sample float64
|
||||
|
||||
for ch := 0; ch < channels; ch++ {
|
||||
sample += float64(frame.Subframes[ch].Samples[i])
|
||||
}
|
||||
sample /= float64(channels)
|
||||
|
||||
allSamples = append(allSamples, sample)
|
||||
|
||||
if len(allSamples) >= maxSamples {
|
||||
return allSamples, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return allSamples, nil
|
||||
}
|
||||
|
||||
func calculateSpectrum(samples []float64, sampleRate int) *SpectrumData {
|
||||
fftSize := 8192
|
||||
numTimeSlices := 300
|
||||
|
||||
duration := float64(len(samples)) / float64(sampleRate)
|
||||
|
||||
samplesPerSlice := len(samples) / numTimeSlices
|
||||
if samplesPerSlice < fftSize {
|
||||
samplesPerSlice = fftSize
|
||||
numTimeSlices = len(samples) / fftSize
|
||||
}
|
||||
|
||||
timeSlices := make([]TimeSlice, 0, numTimeSlices)
|
||||
freqBins := fftSize / 2
|
||||
maxFreq := float64(sampleRate) / 2.0
|
||||
|
||||
for i := 0; i < numTimeSlices; i++ {
|
||||
startIdx := i * samplesPerSlice
|
||||
if startIdx+fftSize > len(samples) {
|
||||
break
|
||||
}
|
||||
|
||||
window := samples[startIdx : startIdx+fftSize]
|
||||
|
||||
windowedSamples := applyHannWindow(window)
|
||||
|
||||
spectrum := fft(windowedSamples)
|
||||
|
||||
magnitudes := make([]float64, freqBins)
|
||||
for j := 0; j < freqBins; j++ {
|
||||
magnitude := cmplx.Abs(spectrum[j])
|
||||
|
||||
if magnitude < 1e-10 {
|
||||
magnitude = 1e-10
|
||||
}
|
||||
magnitudes[j] = 20 * math.Log10(magnitude)
|
||||
}
|
||||
|
||||
timeSlice := TimeSlice{
|
||||
Time: float64(startIdx) / float64(sampleRate),
|
||||
Magnitudes: magnitudes,
|
||||
}
|
||||
timeSlices = append(timeSlices, timeSlice)
|
||||
}
|
||||
|
||||
return &SpectrumData{
|
||||
TimeSlices: timeSlices,
|
||||
SampleRate: sampleRate,
|
||||
FreqBins: freqBins,
|
||||
Duration: duration,
|
||||
MaxFreq: maxFreq,
|
||||
}
|
||||
}
|
||||
|
||||
func applyHannWindow(samples []float64) []float64 {
|
||||
n := len(samples)
|
||||
windowed := make([]float64, n)
|
||||
|
||||
for i := 0; i < n; i++ {
|
||||
window := 0.5 * (1.0 - math.Cos(2.0*math.Pi*float64(i)/float64(n-1)))
|
||||
windowed[i] = samples[i] * window
|
||||
}
|
||||
|
||||
return windowed
|
||||
}
|
||||
|
||||
func fft(samples []float64) []complex128 {
|
||||
n := len(samples)
|
||||
|
||||
x := make([]complex128, n)
|
||||
for i := 0; i < n; i++ {
|
||||
x[i] = complex(samples[i], 0)
|
||||
}
|
||||
|
||||
return fftRecursive(x)
|
||||
}
|
||||
|
||||
func fftRecursive(x []complex128) []complex128 {
|
||||
n := len(x)
|
||||
|
||||
if n <= 1 {
|
||||
return x
|
||||
}
|
||||
|
||||
even := make([]complex128, n/2)
|
||||
odd := make([]complex128, n/2)
|
||||
|
||||
for i := 0; i < n/2; i++ {
|
||||
even[i] = x[2*i]
|
||||
odd[i] = x[2*i+1]
|
||||
}
|
||||
|
||||
evenFFT := fftRecursive(even)
|
||||
oddFFT := fftRecursive(odd)
|
||||
|
||||
result := make([]complex128, n)
|
||||
for k := 0; k < n/2; k++ {
|
||||
t := cmplx.Exp(complex(0, -2*math.Pi*float64(k)/float64(n))) * oddFFT[k]
|
||||
result[k] = evenFFT[k] + t
|
||||
result[k+n/2] = evenFFT[k] - t
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -2,9 +2,7 @@ package backend
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base32"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -41,39 +39,10 @@ func NewSpotifyClient() *SpotifyClient {
|
||||
}
|
||||
}
|
||||
|
||||
func (c *SpotifyClient) getTOTPSecret() (int, []byte) {
|
||||
secrets := map[int][]byte{
|
||||
59: {123, 105, 79, 70, 110, 59, 52, 125, 60, 49, 80, 70, 89, 75, 80, 86, 63, 53, 123, 37, 117, 49, 52, 93, 77, 62, 47, 86, 48, 104, 68, 72},
|
||||
60: {79, 109, 69, 123, 90, 65, 46, 74, 94, 34, 58, 48, 70, 71, 92, 85, 122, 63, 91, 64, 87, 87},
|
||||
61: {44, 55, 47, 42, 70, 40, 34, 114, 76, 74, 50, 111, 120, 97, 75, 76, 94, 102, 43, 69, 49, 120, 118, 80, 64, 78},
|
||||
}
|
||||
|
||||
version := 61
|
||||
secretList := secrets[version]
|
||||
return version, secretList
|
||||
}
|
||||
|
||||
func (c *SpotifyClient) generateTOTP() (string, int, error) {
|
||||
version, secretList := c.getTOTPSecret()
|
||||
|
||||
transformed := make([]byte, len(secretList))
|
||||
for i, b := range secretList {
|
||||
transformed[i] = b ^ byte((i%33)+9)
|
||||
}
|
||||
|
||||
var joined strings.Builder
|
||||
for _, b := range transformed {
|
||||
joined.WriteString(strconv.Itoa(int(b)))
|
||||
}
|
||||
|
||||
hexStr := hex.EncodeToString([]byte(joined.String()))
|
||||
hexBytes, err := hex.DecodeString(hexStr)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
|
||||
secret := base32Encode(hexBytes)
|
||||
secret = strings.TrimRight(secret, "=")
|
||||
secret := "GM3TMMJTGYZTQNZVGM4DINJZHA4TGOBYGMZTCMRTGEYDSMJRHE4TEOBUG4YTCMRUGQ4DQOJUGQYTAMRRGA2TCMJSHE3TCMBY"
|
||||
version := 61
|
||||
|
||||
key, err := otp.NewKeyFromURL(fmt.Sprintf("otpauth://totp/secret?secret=%s", secret))
|
||||
if err != nil {
|
||||
@@ -88,11 +57,6 @@ func (c *SpotifyClient) generateTOTP() (string, int, error) {
|
||||
return totpCode, version, nil
|
||||
}
|
||||
|
||||
func base32Encode(data []byte) string {
|
||||
b32 := base32.StdEncoding.WithPadding(base32.NoPadding)
|
||||
return b32.EncodeToString(data)
|
||||
}
|
||||
|
||||
func (c *SpotifyClient) getAccessToken() error {
|
||||
totpCode, version, err := c.generateTOTP()
|
||||
if err != nil {
|
||||
@@ -112,7 +76,7 @@ func (c *SpotifyClient) getAccessToken() error {
|
||||
q.Add("totpServer", totpCode)
|
||||
req.URL.RawQuery = q.Encode()
|
||||
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36")
|
||||
req.Header.Set("Content-Type", "application/json;charset=UTF-8")
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
@@ -149,7 +113,7 @@ func (c *SpotifyClient) getSessionInfo() error {
|
||||
return err
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36")
|
||||
|
||||
for name, value := range c.cookies {
|
||||
req.AddCookie(&http.Cookie{Name: name, Value: value})
|
||||
@@ -230,7 +194,7 @@ func (c *SpotifyClient) getClientToken() error {
|
||||
req.Header.Set("Authority", "clienttoken.spotify.com")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36")
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
@@ -288,7 +252,7 @@ func (c *SpotifyClient) Query(payload map[string]interface{}) (map[string]interf
|
||||
req.Header.Set("Client-Token", c.clientToken)
|
||||
req.Header.Set("Spotify-App-Version", c.clientVersion)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36")
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
@@ -364,9 +328,6 @@ func getBool(m map[string]interface{}, key string) bool {
|
||||
|
||||
func extractArtists(artistsData map[string]interface{}) []map[string]interface{} {
|
||||
items := getSlice(artistsData, "items")
|
||||
if items == nil {
|
||||
return []map[string]interface{}{}
|
||||
}
|
||||
|
||||
artists := []map[string]interface{}{}
|
||||
for _, item := range items {
|
||||
@@ -384,7 +345,7 @@ func extractArtists(artistsData map[string]interface{}) []map[string]interface{}
|
||||
}
|
||||
|
||||
func extractCoverImage(coverData map[string]interface{}) map[string]interface{} {
|
||||
if coverData == nil || len(coverData) == 0 {
|
||||
if len(coverData) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -401,7 +362,7 @@ func extractCoverImage(coverData map[string]interface{}) map[string]interface{}
|
||||
}
|
||||
}
|
||||
|
||||
if sources == nil || len(sources) == 0 {
|
||||
if len(sources) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -524,7 +485,7 @@ func extractDuration(ms float64) map[string]interface{} {
|
||||
}
|
||||
}
|
||||
|
||||
func FilterTrack(data map[string]interface{}, albumFetchData ...map[string]interface{}) map[string]interface{} {
|
||||
func FilterTrack(data map[string]interface{}, separator string, albumFetchData ...map[string]interface{}) map[string]interface{} {
|
||||
dataMap := getMap(data, "data")
|
||||
trackData := getMap(dataMap, "trackUnion")
|
||||
if len(trackData) == 0 {
|
||||
@@ -532,7 +493,7 @@ func FilterTrack(data map[string]interface{}, albumFetchData ...map[string]inter
|
||||
}
|
||||
|
||||
var albumFetchDataMap map[string]interface{}
|
||||
if len(albumFetchData) > 0 && albumFetchData[0] != nil {
|
||||
if len(albumFetchData) > 0 {
|
||||
albumFetchDataMap = albumFetchData[0]
|
||||
}
|
||||
|
||||
@@ -541,39 +502,35 @@ func FilterTrack(data map[string]interface{}, albumFetchData ...map[string]inter
|
||||
if len(artists) == 0 {
|
||||
artists = []map[string]interface{}{}
|
||||
firstArtistItems := getSlice(getMap(trackData, "firstArtist"), "items")
|
||||
if firstArtistItems != nil {
|
||||
for _, item := range firstArtistItems {
|
||||
itemMap, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if profile, exists := itemMap["profile"]; exists {
|
||||
profileMap, ok := profile.(map[string]interface{})
|
||||
if ok {
|
||||
artistInfo := map[string]interface{}{
|
||||
"name": getString(profileMap, "name"),
|
||||
}
|
||||
artists = append(artists, artistInfo)
|
||||
for _, item := range firstArtistItems {
|
||||
itemMap, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if profile, exists := itemMap["profile"]; exists {
|
||||
profileMap, ok := profile.(map[string]interface{})
|
||||
if ok {
|
||||
artistInfo := map[string]interface{}{
|
||||
"name": getString(profileMap, "name"),
|
||||
}
|
||||
artists = append(artists, artistInfo)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
otherArtistItems := getSlice(getMap(trackData, "otherArtists"), "items")
|
||||
if otherArtistItems != nil {
|
||||
for _, item := range otherArtistItems {
|
||||
itemMap, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if profile, exists := itemMap["profile"]; exists {
|
||||
profileMap, ok := profile.(map[string]interface{})
|
||||
if ok {
|
||||
artistInfo := map[string]interface{}{
|
||||
"name": getString(profileMap, "name"),
|
||||
}
|
||||
artists = append(artists, artistInfo)
|
||||
for _, item := range otherArtistItems {
|
||||
itemMap, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if profile, exists := itemMap["profile"]; exists {
|
||||
profileMap, ok := profile.(map[string]interface{})
|
||||
if ok {
|
||||
artistInfo := map[string]interface{}{
|
||||
"name": getString(profileMap, "name"),
|
||||
}
|
||||
artists = append(artists, artistInfo)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -598,7 +555,7 @@ func FilterTrack(data map[string]interface{}, albumFetchData ...map[string]inter
|
||||
copyrightData := getMap(albumData, "copyright")
|
||||
if len(copyrightData) > 0 {
|
||||
copyrightItems := getSlice(copyrightData, "items")
|
||||
if copyrightItems != nil {
|
||||
if len(copyrightItems) > 0 {
|
||||
for _, item := range copyrightItems {
|
||||
itemMap, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
@@ -617,7 +574,7 @@ func FilterTrack(data map[string]interface{}, albumFetchData ...map[string]inter
|
||||
if len(tracksData) > 0 {
|
||||
discNumbers := make(map[int]bool)
|
||||
trackItems := getSlice(tracksData, "items")
|
||||
if trackItems != nil {
|
||||
if len(trackItems) > 0 {
|
||||
for _, item := range trackItems {
|
||||
itemMap, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
@@ -699,7 +656,7 @@ func FilterTrack(data map[string]interface{}, albumFetchData ...map[string]inter
|
||||
|
||||
albumArtistsString := ""
|
||||
albumLabel := ""
|
||||
if albumFetchDataMap != nil && len(albumFetchDataMap) > 0 {
|
||||
if len(albumFetchDataMap) > 0 {
|
||||
albumUnionData := getMap(getMap(albumFetchDataMap, "data"), "albumUnion")
|
||||
if len(albumUnionData) > 0 {
|
||||
albumArtists := extractArtists(getMap(albumUnionData, "artists"))
|
||||
@@ -708,12 +665,26 @@ func FilterTrack(data map[string]interface{}, albumFetchData ...map[string]inter
|
||||
for _, artist := range albumArtists {
|
||||
albumArtistNames = append(albumArtistNames, getString(artist, "name"))
|
||||
}
|
||||
albumArtistsString = strings.Join(albumArtistNames, ", ")
|
||||
albumArtistsString = strings.Join(albumArtistNames, separator)
|
||||
}
|
||||
if albumArtistsString == "" {
|
||||
albumArtistsString = getString(albumUnionData, "artists")
|
||||
}
|
||||
albumLabel = getString(albumUnionData, "label")
|
||||
}
|
||||
}
|
||||
|
||||
if albumArtistsString == "" {
|
||||
albumArtists := extractArtists(getMap(albumData, "artists"))
|
||||
if len(albumArtists) > 0 {
|
||||
albumArtistNames := []string{}
|
||||
for _, artist := range albumArtists {
|
||||
albumArtistNames = append(albumArtistNames, getString(artist, "name"))
|
||||
}
|
||||
albumArtistsString = strings.Join(albumArtistNames, separator)
|
||||
}
|
||||
}
|
||||
|
||||
albumInfo = map[string]interface{}{
|
||||
"id": albumID,
|
||||
"name": getString(albumData, "name"),
|
||||
@@ -744,41 +715,94 @@ func FilterTrack(data map[string]interface{}, albumFetchData ...map[string]inter
|
||||
for _, artist := range artists {
|
||||
artistNames = append(artistNames, getString(artist, "name"))
|
||||
}
|
||||
artistsString := strings.Join(artistNames, ", ")
|
||||
artistsString := strings.Join(artistNames, separator)
|
||||
|
||||
copyrightTexts := []string{}
|
||||
for _, item := range copyrightInfo {
|
||||
copyrightTexts = append(copyrightTexts, getString(item, "text"))
|
||||
}
|
||||
copyrightString := strings.Join(copyrightTexts, ", ")
|
||||
copyrightString := strings.Join(copyrightTexts, GetSeparator())
|
||||
|
||||
discNumber := int(getFloat64(trackData, "discNumber"))
|
||||
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)
|
||||
}
|
||||
|
||||
contentRating := getMap(trackData, "contentRating")
|
||||
isExplicit := getString(contentRating, "label") == "EXPLICIT"
|
||||
|
||||
filtered := map[string]interface{}{
|
||||
"id": getString(trackData, "id"),
|
||||
"name": getString(trackData, "name"),
|
||||
"artists": artistsString,
|
||||
"album": albumInfo,
|
||||
"duration": durationString,
|
||||
"track": int(getFloat64(trackData, "trackNumber")),
|
||||
"disc": discNumber,
|
||||
"discs": totalDiscs,
|
||||
"copyright": copyrightString,
|
||||
"plays": getString(trackData, "playcount"),
|
||||
"cover": cover,
|
||||
"id": getString(trackData, "id"),
|
||||
"name": getString(trackData, "name"),
|
||||
"artists": artistsString,
|
||||
"album": albumInfo,
|
||||
"duration": durationString,
|
||||
"track": int(getFloat64(trackData, "trackNumber")),
|
||||
"disc": discNumber,
|
||||
"discs": totalDiscs,
|
||||
"copyright": copyrightString,
|
||||
"plays": getString(trackData, "playcount"),
|
||||
"cover": cover,
|
||||
"is_explicit": isExplicit,
|
||||
}
|
||||
|
||||
return filtered
|
||||
}
|
||||
|
||||
func FilterAlbum(data map[string]interface{}) map[string]interface{} {
|
||||
func FilterAlbum(data map[string]interface{}, separator string) map[string]interface{} {
|
||||
dataMap := getMap(data, "data")
|
||||
albumData := getMap(dataMap, "albumUnion")
|
||||
if len(albumData) == 0 {
|
||||
@@ -790,7 +814,7 @@ func FilterAlbum(data map[string]interface{}) map[string]interface{} {
|
||||
for _, artist := range artists {
|
||||
artistNames = append(artistNames, getString(artist, "name"))
|
||||
}
|
||||
albumArtistsString := strings.Join(artistNames, ", ")
|
||||
albumArtistsString := strings.Join(artistNames, separator)
|
||||
|
||||
coverObj := extractCoverImage(getMap(albumData, "coverArt"))
|
||||
var cover interface{}
|
||||
@@ -851,7 +875,7 @@ func FilterAlbum(data map[string]interface{}) map[string]interface{} {
|
||||
for _, artist := range trackArtists {
|
||||
trackArtistNames = append(trackArtistNames, getString(artist, "name"))
|
||||
}
|
||||
trackArtistsString := strings.Join(trackArtistNames, ", ")
|
||||
trackArtistsString := strings.Join(trackArtistNames, separator)
|
||||
|
||||
trackURI := getString(track, "uri")
|
||||
trackID := ""
|
||||
@@ -860,13 +884,23 @@ func FilterAlbum(data map[string]interface{}) map[string]interface{} {
|
||||
trackID = parts[len(parts)-1]
|
||||
}
|
||||
|
||||
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"),
|
||||
"artists": trackArtistsString,
|
||||
"artistIds": artistIDs,
|
||||
"duration": durationString,
|
||||
"plays": getString(track, "playcount"),
|
||||
"id": trackID,
|
||||
"name": getString(track, "name"),
|
||||
"artists": trackArtistsString,
|
||||
"artistIds": artistIDs,
|
||||
"duration": durationString,
|
||||
"plays": getString(track, "playcount"),
|
||||
"is_explicit": isExplicit,
|
||||
"disc_number": discNumber,
|
||||
}
|
||||
tracks = append(tracks, trackInfo)
|
||||
}
|
||||
@@ -886,6 +920,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"),
|
||||
@@ -894,12 +934,16 @@ func FilterAlbum(data map[string]interface{}) map[string]interface{} {
|
||||
"releaseDate": releaseDate,
|
||||
"count": len(tracks),
|
||||
"tracks": tracks,
|
||||
"discs": map[string]interface{}{
|
||||
"totalCount": totalDiscs,
|
||||
},
|
||||
"label": getString(albumData, "label"),
|
||||
}
|
||||
|
||||
return filtered
|
||||
}
|
||||
|
||||
func FilterPlaylist(data map[string]interface{}) map[string]interface{} {
|
||||
func FilterPlaylist(data map[string]interface{}, separator string) map[string]interface{} {
|
||||
dataMap := getMap(data, "data")
|
||||
playlistData := getMap(dataMap, "playlistV2")
|
||||
if len(playlistData) == 0 {
|
||||
@@ -913,21 +957,9 @@ func FilterPlaylist(data map[string]interface{}) map[string]interface{} {
|
||||
avatarData := getMap(ownerData, "avatar")
|
||||
if len(avatarData) > 0 {
|
||||
sources := getSlice(avatarData, "sources")
|
||||
if sources != nil {
|
||||
for _, source := range sources {
|
||||
sourceMap, ok := source.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if getFloat64(sourceMap, "width") == 300 {
|
||||
avatarURL = getString(sourceMap, "url")
|
||||
break
|
||||
}
|
||||
}
|
||||
if avatarURL == nil && len(sources) > 0 {
|
||||
if firstSource, ok := sources[0].(map[string]interface{}); ok {
|
||||
avatarURL = getString(firstSource, "url")
|
||||
}
|
||||
if len(sources) > 0 {
|
||||
if firstSource, ok := sources[0].(map[string]interface{}); ok {
|
||||
avatarURL = getString(firstSource, "url")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1031,7 +1063,7 @@ func FilterPlaylist(data map[string]interface{}) map[string]interface{} {
|
||||
for _, artist := range trackArtists {
|
||||
trackArtistNames = append(trackArtistNames, getString(artist, "name"))
|
||||
}
|
||||
artistsString := strings.Join(trackArtistNames, ", ")
|
||||
artistsString := strings.Join(trackArtistNames, separator)
|
||||
|
||||
trackDurationMs := getFloat64(getMap(trackData, "trackDuration"), "totalMilliseconds")
|
||||
durationObj := extractDuration(trackDurationMs)
|
||||
@@ -1049,6 +1081,7 @@ func FilterPlaylist(data map[string]interface{}) map[string]interface{} {
|
||||
albumData := getMap(trackData, "albumOfTrack")
|
||||
albumName := ""
|
||||
albumID := ""
|
||||
albumArtistsString := ""
|
||||
var trackCover interface{}
|
||||
|
||||
if len(albumData) > 0 {
|
||||
@@ -1069,19 +1102,39 @@ func FilterPlaylist(data map[string]interface{}) map[string]interface{} {
|
||||
trackCover = getString(coverObj, "large")
|
||||
}
|
||||
}
|
||||
|
||||
albumArtists := extractArtists(getMap(albumData, "artists"))
|
||||
if len(albumArtists) > 0 {
|
||||
albumArtistNames := []string{}
|
||||
for _, artist := range albumArtists {
|
||||
albumArtistNames = append(albumArtistNames, getString(artist, "name"))
|
||||
}
|
||||
albumArtistsString = strings.Join(albumArtistNames, separator)
|
||||
}
|
||||
}
|
||||
|
||||
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"),
|
||||
"artist": artistsString,
|
||||
"artistIds": artistIDs,
|
||||
"plays": rank,
|
||||
"status": status,
|
||||
"album": albumName,
|
||||
"albumId": albumID,
|
||||
"duration": durationString,
|
||||
"id": trackID,
|
||||
"cover": trackCover,
|
||||
"title": trackName,
|
||||
"artist": artistsString,
|
||||
"artistIds": artistIDs,
|
||||
"plays": rank,
|
||||
"status": status,
|
||||
"album": albumName,
|
||||
"albumArtist": albumArtistsString,
|
||||
"albumId": albumID,
|
||||
"duration": durationString,
|
||||
"is_explicit": isExplicit,
|
||||
"disc_number": int(getFloat64(trackData, "discNumber")),
|
||||
}
|
||||
tracks = append(tracks, trackInfo)
|
||||
}
|
||||
@@ -1175,12 +1228,20 @@ func extractRelease(release map[string]interface{}) map[string]interface{} {
|
||||
year = yearVal
|
||||
}
|
||||
|
||||
var totalTracks int
|
||||
tracksInfo := getMap(release, "tracks")
|
||||
if tracksInfo != nil {
|
||||
totalTracks = int(getFloat64(tracksInfo, "totalCount"))
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"id": releaseID,
|
||||
"name": getString(release, "name"),
|
||||
"cover": cover,
|
||||
"date": releaseDate,
|
||||
"year": year,
|
||||
"id": releaseID,
|
||||
"name": getString(release, "name"),
|
||||
"cover": cover,
|
||||
"date": releaseDate,
|
||||
"year": year,
|
||||
"total_tracks": totalTracks,
|
||||
"type": getString(release, "type"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1217,7 +1278,12 @@ func extractDiscographyItems(itemsData map[string]interface{}) []map[string]inte
|
||||
return items
|
||||
}
|
||||
|
||||
func FilterArtist(data map[string]interface{}) map[string]interface{} {
|
||||
func stripHTMLTags(s string) string {
|
||||
re := regexp.MustCompile(`(?s)<[^>]*>`)
|
||||
return re.ReplaceAllString(s, "")
|
||||
}
|
||||
|
||||
func FilterArtist(data map[string]interface{}, separator string) map[string]interface{} {
|
||||
dataMap := getMap(data, "data")
|
||||
artistData := getMap(dataMap, "artistUnion")
|
||||
if len(artistData) == 0 {
|
||||
@@ -1232,7 +1298,7 @@ func FilterArtist(data map[string]interface{}) map[string]interface{} {
|
||||
if ok {
|
||||
biographyText := getString(biographyMap, "text")
|
||||
if biographyText != "" {
|
||||
profile["biography"] = html.UnescapeString(biographyText)
|
||||
profile["biography"] = html.UnescapeString(stripHTMLTags(biographyText))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1346,7 +1412,7 @@ func FilterArtist(data map[string]interface{}) map[string]interface{} {
|
||||
return filtered
|
||||
}
|
||||
|
||||
func FilterSearch(data map[string]interface{}) map[string]interface{} {
|
||||
func FilterSearch(data map[string]interface{}, separator string) map[string]interface{} {
|
||||
dataMap := getMap(data, "data")
|
||||
searchData := getMap(dataMap, "searchV2")
|
||||
if len(searchData) == 0 {
|
||||
@@ -1436,7 +1502,7 @@ func FilterSearch(data map[string]interface{}) map[string]interface{} {
|
||||
for _, artist := range trackArtists {
|
||||
trackArtistNames = append(trackArtistNames, getString(artist, "name"))
|
||||
}
|
||||
trackArtistsString := strings.Join(trackArtistNames, ", ")
|
||||
trackArtistsString := strings.Join(trackArtistNames, separator)
|
||||
|
||||
durationString := getString(trackDuration, "formatted")
|
||||
|
||||
@@ -1445,14 +1511,18 @@ func FilterSearch(data map[string]interface{}) map[string]interface{} {
|
||||
albumName = getString(albumInfo, "name")
|
||||
}
|
||||
|
||||
contentRating := getMap(track, "contentRating")
|
||||
isExplicit := getString(contentRating, "label") == "EXPLICIT"
|
||||
|
||||
trackResults := results["tracks"].([]map[string]interface{})
|
||||
trackResults = append(trackResults, map[string]interface{}{
|
||||
"id": trackID,
|
||||
"name": trackName,
|
||||
"artists": trackArtistsString,
|
||||
"album": albumName,
|
||||
"duration": durationString,
|
||||
"cover": cover,
|
||||
"id": trackID,
|
||||
"name": trackName,
|
||||
"artists": trackArtistsString,
|
||||
"album": albumName,
|
||||
"duration": durationString,
|
||||
"cover": cover,
|
||||
"is_explicit": isExplicit,
|
||||
})
|
||||
results["tracks"] = trackResults
|
||||
}
|
||||
@@ -1504,7 +1574,7 @@ func FilterSearch(data map[string]interface{}) map[string]interface{} {
|
||||
for _, artist := range albumArtists {
|
||||
albumArtistNames = append(albumArtistNames, getString(artist, "name"))
|
||||
}
|
||||
albumArtistsString := strings.Join(albumArtistNames, ", ")
|
||||
albumArtistsString := strings.Join(albumArtistNames, separator)
|
||||
|
||||
dateInfo := getMap(album, "date")
|
||||
var year interface{}
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func streamTrackListChunks(ctx context.Context, tracks []AlbumTrackMetadata, callback MetadataCallback) error {
|
||||
if callback == nil || len(tracks) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
const chunkSize = 25
|
||||
for start := 0; start < len(tracks); start += chunkSize {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
end := start + chunkSize
|
||||
if end > len(tracks) {
|
||||
end = len(tracks)
|
||||
}
|
||||
|
||||
callback(tracks[start:end])
|
||||
|
||||
if end < len(tracks) {
|
||||
time.Sleep(15 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetSpotifyDataWithAPI(ctx context.Context, spotifyURL string, useAPI bool, apiBaseURL string, batch bool, delay time.Duration, separator string, callback MetadataCallback) (interface{}, error) {
|
||||
if !useAPI || apiBaseURL == "" {
|
||||
return GetFilteredSpotifyData(ctx, spotifyURL, batch, delay, separator, callback)
|
||||
}
|
||||
|
||||
spotifyType, id := parseSpotifyURLToTypeAndID(spotifyURL)
|
||||
if spotifyType == "" || id == "" {
|
||||
return nil, fmt.Errorf("invalid Spotify URL: %s", spotifyURL)
|
||||
}
|
||||
|
||||
if spotifyType == "artist" {
|
||||
return GetFilteredSpotifyData(ctx, spotifyURL, batch, delay, separator, callback)
|
||||
}
|
||||
|
||||
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
|
||||
if callback != nil {
|
||||
callback(&AlbumResponsePayload{
|
||||
AlbumInfo: albumResp.AlbumInfo,
|
||||
TrackList: []AlbumTrackMetadata{},
|
||||
})
|
||||
if err := streamTrackListChunks(ctx, albumResp.TrackList, callback); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
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
|
||||
if callback != nil {
|
||||
callback(PlaylistResponsePayload{
|
||||
PlaylistInfo: playlistResp.PlaylistInfo,
|
||||
TrackList: []AlbumTrackMetadata{},
|
||||
})
|
||||
if err := streamTrackListChunks(ctx, playlistResp.TrackList, callback); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
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
|
||||
if callback != nil {
|
||||
callback(&ArtistDiscographyPayload{
|
||||
ArtistInfo: artistResp.ArtistInfo,
|
||||
AlbumList: artistResp.AlbumList,
|
||||
TrackList: []AlbumTrackMetadata{},
|
||||
})
|
||||
if err := streamTrackListChunks(ctx, artistResp.TrackList, callback); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported Spotify type: %s", spotifyType)
|
||||
}
|
||||
|
||||
if callback != nil {
|
||||
switch payload := data.(type) {
|
||||
case TrackResponse:
|
||||
t := payload.Track
|
||||
callback([]AlbumTrackMetadata{{
|
||||
SpotifyID: t.SpotifyID,
|
||||
Artists: t.Artists,
|
||||
Name: t.Name,
|
||||
AlbumName: t.AlbumName,
|
||||
AlbumArtist: t.AlbumArtist,
|
||||
DurationMS: t.DurationMS,
|
||||
Images: t.Images,
|
||||
ReleaseDate: t.ReleaseDate,
|
||||
TrackNumber: t.TrackNumber,
|
||||
TotalTracks: t.TotalTracks,
|
||||
DiscNumber: t.DiscNumber,
|
||||
TotalDiscs: t.TotalDiscs,
|
||||
ExternalURL: t.ExternalURL,
|
||||
Plays: t.Plays,
|
||||
PreviewURL: t.PreviewURL,
|
||||
IsExplicit: t.IsExplicit,
|
||||
}})
|
||||
}
|
||||
}
|
||||
|
||||
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 "", ""
|
||||
}
|
||||
@@ -5,8 +5,10 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -16,13 +18,17 @@ var (
|
||||
errInvalidSpotifyURL = errors.New("invalid or unsupported Spotify URL")
|
||||
)
|
||||
|
||||
type MetadataCallback func(data interface{})
|
||||
|
||||
type SpotifyMetadataClient struct {
|
||||
httpClient *http.Client
|
||||
Separator string
|
||||
}
|
||||
|
||||
func NewSpotifyMetadataClient() *SpotifyMetadataClient {
|
||||
return &SpotifyMetadataClient{
|
||||
httpClient: &http.Client{Timeout: 30 * time.Second},
|
||||
Separator: ", ",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,10 +46,11 @@ type TrackMetadata struct {
|
||||
DiscNumber int `json:"disc_number,omitempty"`
|
||||
TotalDiscs int `json:"total_discs,omitempty"`
|
||||
ExternalURL string `json:"external_urls"`
|
||||
ISRC string `json:"isrc"`
|
||||
Copyright string `json:"copyright,omitempty"`
|
||||
Publisher string `json:"publisher,omitempty"`
|
||||
Plays string `json:"plays,omitempty"`
|
||||
PreviewURL string `json:"preview_url,omitempty"`
|
||||
IsExplicit bool `json:"is_explicit,omitempty"`
|
||||
}
|
||||
|
||||
type ArtistSimple struct {
|
||||
@@ -66,7 +73,6 @@ type AlbumTrackMetadata struct {
|
||||
DiscNumber int `json:"disc_number,omitempty"`
|
||||
TotalDiscs int `json:"total_discs,omitempty"`
|
||||
ExternalURL string `json:"external_urls"`
|
||||
ISRC string `json:"isrc"`
|
||||
AlbumType string `json:"album_type,omitempty"`
|
||||
AlbumID string `json:"album_id,omitempty"`
|
||||
AlbumURL string `json:"album_url,omitempty"`
|
||||
@@ -75,6 +81,8 @@ type AlbumTrackMetadata struct {
|
||||
ArtistsData []ArtistSimple `json:"artists_data,omitempty"`
|
||||
Plays string `json:"plays,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
PreviewURL string `json:"preview_url,omitempty"`
|
||||
IsExplicit bool `json:"is_explicit,omitempty"`
|
||||
}
|
||||
|
||||
type TrackResponse struct {
|
||||
@@ -194,6 +202,7 @@ type apiTrackResponse struct {
|
||||
Medium string `json:"medium"`
|
||||
Large string `json:"large"`
|
||||
} `json:"cover"`
|
||||
IsExplicit bool `json:"is_explicit"`
|
||||
}
|
||||
|
||||
type apiAlbumResponse struct {
|
||||
@@ -203,13 +212,19 @@ type apiAlbumResponse struct {
|
||||
Cover string `json:"cover"`
|
||||
ReleaseDate string `json:"releaseDate"`
|
||||
Count int `json:"count"`
|
||||
Tracks []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Artists string `json:"artists"`
|
||||
ArtistIds []string `json:"artistIds"`
|
||||
Duration string `json:"duration"`
|
||||
Plays string `json:"plays"`
|
||||
Label string `json:"label"`
|
||||
Discs struct {
|
||||
TotalCount int `json:"totalCount"`
|
||||
} `json:"discs"`
|
||||
Tracks []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Artists string `json:"artists"`
|
||||
ArtistIds []string `json:"artistIds"`
|
||||
Duration string `json:"duration"`
|
||||
Plays string `json:"plays"`
|
||||
IsExplicit bool `json:"is_explicit"`
|
||||
DiscNumber int `json:"disc_number"`
|
||||
} `json:"tracks"`
|
||||
}
|
||||
|
||||
@@ -225,16 +240,19 @@ type apiPlaylistResponse struct {
|
||||
Count int `json:"count"`
|
||||
Followers int `json:"followers"`
|
||||
Tracks []struct {
|
||||
ID string `json:"id"`
|
||||
Cover string `json:"cover"`
|
||||
Title string `json:"title"`
|
||||
Artist string `json:"artist"`
|
||||
ArtistIds []string `json:"artistIds"`
|
||||
Plays string `json:"plays"`
|
||||
Status string `json:"status"`
|
||||
Album string `json:"album"`
|
||||
AlbumID string `json:"albumId"`
|
||||
Duration string `json:"duration"`
|
||||
ID string `json:"id"`
|
||||
Cover string `json:"cover"`
|
||||
Title string `json:"title"`
|
||||
Artist string `json:"artist"`
|
||||
ArtistIds []string `json:"artistIds"`
|
||||
Plays string `json:"plays"`
|
||||
Status string `json:"status"`
|
||||
Album string `json:"album"`
|
||||
AlbumArtist string `json:"albumArtist"`
|
||||
AlbumID string `json:"albumId"`
|
||||
Duration string `json:"duration"`
|
||||
IsExplicit bool `json:"is_explicit"`
|
||||
DiscNumber int `json:"disc_number"`
|
||||
} `json:"tracks"`
|
||||
}
|
||||
|
||||
@@ -256,11 +274,13 @@ type apiArtistResponse struct {
|
||||
Gallery []string `json:"gallery"`
|
||||
Discography struct {
|
||||
All []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Cover string `json:"cover"`
|
||||
Date string `json:"date"`
|
||||
Year int `json:"year"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Cover string `json:"cover"`
|
||||
Date string `json:"date"`
|
||||
Year int `json:"year"`
|
||||
TotalTracks int `json:"total_tracks"`
|
||||
Type string `json:"type"`
|
||||
} `json:"all"`
|
||||
Total int `json:"total"`
|
||||
} `json:"discography"`
|
||||
@@ -269,12 +289,13 @@ type apiArtistResponse struct {
|
||||
type apiSearchResponse struct {
|
||||
Results struct {
|
||||
Tracks []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Artists string `json:"artists"`
|
||||
Album string `json:"album"`
|
||||
Duration string `json:"duration"`
|
||||
Cover string `json:"cover"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Artists string `json:"artists"`
|
||||
Album string `json:"album"`
|
||||
Duration string `json:"duration"`
|
||||
Cover string `json:"cover"`
|
||||
IsExplicit bool `json:"is_explicit"`
|
||||
} `json:"tracks"`
|
||||
Albums []struct {
|
||||
ID string `json:"id"`
|
||||
@@ -315,6 +336,7 @@ type SearchResult struct {
|
||||
Duration int `json:"duration_ms,omitempty"`
|
||||
TotalTracks int `json:"total_tracks,omitempty"`
|
||||
Owner string `json:"owner,omitempty"`
|
||||
IsExplicit bool `json:"is_explicit,omitempty"`
|
||||
}
|
||||
|
||||
type SearchResponse struct {
|
||||
@@ -324,54 +346,57 @@ type SearchResponse struct {
|
||||
Playlists []SearchResult `json:"playlists"`
|
||||
}
|
||||
|
||||
func GetFilteredSpotifyData(ctx context.Context, spotifyURL string, batch bool, delay time.Duration) (interface{}, error) {
|
||||
func GetFilteredSpotifyData(ctx context.Context, spotifyURL string, batch bool, delay time.Duration, separator string, callback MetadataCallback) (interface{}, error) {
|
||||
client := NewSpotifyMetadataClient()
|
||||
return client.GetFilteredData(ctx, spotifyURL, batch, delay)
|
||||
if separator != "" {
|
||||
client.Separator = separator
|
||||
}
|
||||
return client.GetFilteredData(ctx, spotifyURL, batch, delay, callback)
|
||||
}
|
||||
|
||||
func (c *SpotifyMetadataClient) GetFilteredData(ctx context.Context, spotifyURL string, batch bool, delay time.Duration) (interface{}, error) {
|
||||
func (c *SpotifyMetadataClient) GetFilteredData(ctx context.Context, spotifyURL string, batch bool, delay time.Duration, callback MetadataCallback) (interface{}, error) {
|
||||
parsed, err := parseSpotifyURI(spotifyURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
raw, err := c.getRawSpotifyData(ctx, parsed, batch, delay)
|
||||
raw, err := c.getRawSpotifyData(ctx, parsed, batch, delay, callback)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return c.processSpotifyData(ctx, raw)
|
||||
return c.processSpotifyData(ctx, raw, callback)
|
||||
}
|
||||
|
||||
func (c *SpotifyMetadataClient) getRawSpotifyData(ctx context.Context, parsed spotifyURI, batch bool, delay time.Duration) (interface{}, error) {
|
||||
func (c *SpotifyMetadataClient) getRawSpotifyData(ctx context.Context, parsed spotifyURI, batch bool, delay time.Duration, callback MetadataCallback) (interface{}, error) {
|
||||
switch parsed.Type {
|
||||
case "playlist":
|
||||
return c.fetchPlaylist(ctx, parsed.ID)
|
||||
return c.fetchPlaylist(ctx, parsed.ID, callback)
|
||||
case "album":
|
||||
return c.fetchAlbum(ctx, parsed.ID)
|
||||
return c.fetchAlbum(ctx, parsed.ID, callback)
|
||||
case "track":
|
||||
return c.fetchTrack(ctx, parsed.ID)
|
||||
case "artist_discography":
|
||||
return c.fetchArtistDiscography(ctx, parsed)
|
||||
return c.fetchArtistDiscography(ctx, parsed, callback)
|
||||
case "artist":
|
||||
|
||||
discographyParsed := spotifyURI{Type: "artist_discography", ID: parsed.ID, DiscographyGroup: "all"}
|
||||
return c.fetchArtistDiscography(ctx, discographyParsed)
|
||||
return c.fetchArtistDiscography(ctx, discographyParsed, callback)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported Spotify type: %s", parsed.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *SpotifyMetadataClient) processSpotifyData(ctx context.Context, raw interface{}) (interface{}, error) {
|
||||
func (c *SpotifyMetadataClient) processSpotifyData(ctx context.Context, raw interface{}, callback MetadataCallback) (interface{}, error) {
|
||||
switch payload := raw.(type) {
|
||||
case *apiPlaylistResponse:
|
||||
return c.formatPlaylistData(payload), nil
|
||||
return c.formatPlaylistData(payload, callback), nil
|
||||
case *apiAlbumResponse:
|
||||
return c.formatAlbumData(payload)
|
||||
return c.formatAlbumData(payload, callback)
|
||||
case *apiTrackResponse:
|
||||
return c.formatTrackData(payload), nil
|
||||
case *apiArtistResponse:
|
||||
return c.formatArtistDiscographyData(ctx, payload)
|
||||
return c.formatArtistDiscographyData(ctx, payload, callback)
|
||||
default:
|
||||
return nil, errors.New("unknown raw payload type")
|
||||
}
|
||||
@@ -418,28 +443,53 @@ 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, nil)
|
||||
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,
|
||||
},
|
||||
"artists": albumResponse.Artists,
|
||||
"label": albumResponse.Label,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
albumFetchData, _ = client.Query(albumPayload)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
filteredData := FilterTrack(data, albumFetchData)
|
||||
filteredData := FilterTrack(data, c.Separator, albumFetchData)
|
||||
|
||||
jsonData, err := json.Marshal(filteredData)
|
||||
if err != nil {
|
||||
@@ -454,11 +504,15 @@ func (c *SpotifyMetadataClient) fetchTrack(ctx context.Context, trackID string)
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID string) (*apiAlbumResponse, error) {
|
||||
func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID string, callback MetadataCallback) (*apiAlbumResponse, error) {
|
||||
client := NewSpotifyClient()
|
||||
if err := client.Initialize(); err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize spotify client: %w", err)
|
||||
}
|
||||
return c.fetchAlbumWithClient(ctx, client, albumID, callback)
|
||||
}
|
||||
|
||||
func (c *SpotifyMetadataClient) fetchAlbumWithClient(ctx context.Context, client *SpotifyClient, albumID string, callback MetadataCallback) (*apiAlbumResponse, error) {
|
||||
|
||||
allItems := []interface{}{}
|
||||
offset := 0
|
||||
@@ -490,6 +544,15 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID string)
|
||||
|
||||
if data == nil {
|
||||
data = response
|
||||
if callback != nil {
|
||||
filtered := FilterAlbum(data, c.Separator)
|
||||
jsonData, _ := json.Marshal(filtered)
|
||||
var result apiAlbumResponse
|
||||
if json.Unmarshal(jsonData, &result) == nil {
|
||||
formatted, _ := c.formatAlbumData(&result, nil)
|
||||
callback(formatted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
albumData := getMap(getMap(response, "data"), "albumUnion")
|
||||
@@ -532,7 +595,7 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID string)
|
||||
tracksV2["totalCount"] = len(allItems)
|
||||
}
|
||||
|
||||
filteredData := FilterAlbum(data)
|
||||
filteredData := FilterAlbum(data, c.Separator)
|
||||
|
||||
jsonData, err := json.Marshal(filteredData)
|
||||
if err != nil {
|
||||
@@ -547,7 +610,7 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID string)
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID string) (*apiPlaylistResponse, error) {
|
||||
func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID string, callback MetadataCallback) (*apiPlaylistResponse, error) {
|
||||
client := NewSpotifyClient()
|
||||
if err := client.Initialize(); err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize spotify client: %w", err)
|
||||
@@ -583,6 +646,15 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID st
|
||||
|
||||
if data == nil {
|
||||
data = response
|
||||
if callback != nil {
|
||||
filtered := FilterPlaylist(data, c.Separator)
|
||||
jsonData, _ := json.Marshal(filtered)
|
||||
var result apiPlaylistResponse
|
||||
if json.Unmarshal(jsonData, &result) == nil {
|
||||
formatted := c.formatPlaylistData(&result, nil)
|
||||
callback(formatted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
playlistData := getMap(getMap(response, "data"), "playlistV2")
|
||||
@@ -625,7 +697,7 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID st
|
||||
content["totalCount"] = len(allItems)
|
||||
}
|
||||
|
||||
filteredData := FilterPlaylist(data)
|
||||
filteredData := FilterPlaylist(data, c.Separator)
|
||||
|
||||
jsonData, err := json.Marshal(filteredData)
|
||||
if err != nil {
|
||||
@@ -640,7 +712,7 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID st
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (c *SpotifyMetadataClient) fetchArtistDiscography(ctx context.Context, parsed spotifyURI) (*apiArtistResponse, error) {
|
||||
func (c *SpotifyMetadataClient) fetchArtistDiscography(ctx context.Context, parsed spotifyURI, callback MetadataCallback) (*apiArtistResponse, error) {
|
||||
client := NewSpotifyClient()
|
||||
if err := client.Initialize(); err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize spotify client: %w", err)
|
||||
@@ -665,6 +737,16 @@ func (c *SpotifyMetadataClient) fetchArtistDiscography(ctx context.Context, pars
|
||||
return nil, fmt.Errorf("failed to query artist overview: %w", err)
|
||||
}
|
||||
|
||||
if callback != nil {
|
||||
filtered := FilterArtist(data, c.Separator)
|
||||
jsonData, _ := json.Marshal(filtered)
|
||||
var result apiArtistResponse
|
||||
if json.Unmarshal(jsonData, &result) == nil {
|
||||
formatted, _ := c.formatArtistDiscographyData(ctx, &result, nil)
|
||||
callback(formatted)
|
||||
}
|
||||
}
|
||||
|
||||
allDiscographyItems := []interface{}{}
|
||||
offset := 0
|
||||
limit := 50
|
||||
@@ -722,6 +804,12 @@ func (c *SpotifyMetadataClient) fetchArtistDiscography(ctx context.Context, pars
|
||||
}
|
||||
|
||||
offset += limit
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
albumsItems := []interface{}{}
|
||||
@@ -788,7 +876,7 @@ func (c *SpotifyMetadataClient) fetchArtistDiscography(ctx context.Context, pars
|
||||
}
|
||||
}
|
||||
|
||||
filteredData := FilterArtist(data)
|
||||
filteredData := FilterArtist(data, c.Separator)
|
||||
|
||||
jsonData, err := json.Marshal(filteredData)
|
||||
if err != nil {
|
||||
@@ -834,10 +922,10 @@ func (c *SpotifyMetadataClient) formatTrackData(raw *apiTrackResponse) TrackResp
|
||||
DiscNumber: raw.Disc,
|
||||
TotalDiscs: raw.Discs,
|
||||
ExternalURL: externalURL,
|
||||
ISRC: "",
|
||||
Copyright: raw.Copyright,
|
||||
Publisher: raw.Album.Label,
|
||||
Plays: raw.Plays,
|
||||
IsExplicit: raw.IsExplicit,
|
||||
}
|
||||
|
||||
return TrackResponse{
|
||||
@@ -845,7 +933,7 @@ func (c *SpotifyMetadataClient) formatTrackData(raw *apiTrackResponse) TrackResp
|
||||
}
|
||||
}
|
||||
|
||||
func (c *SpotifyMetadataClient) formatAlbumData(raw *apiAlbumResponse) (*AlbumResponsePayload, error) {
|
||||
func (c *SpotifyMetadataClient) formatAlbumData(raw *apiAlbumResponse, callback MetadataCallback) (*AlbumResponsePayload, error) {
|
||||
var artistID, artistURL string
|
||||
|
||||
info := AlbumInfoMetadata{
|
||||
@@ -858,6 +946,13 @@ func (c *SpotifyMetadataClient) formatAlbumData(raw *apiAlbumResponse) (*AlbumRe
|
||||
ArtistURL: artistURL,
|
||||
}
|
||||
|
||||
if callback != nil {
|
||||
callback(AlbumResponsePayload{
|
||||
AlbumInfo: info,
|
||||
TrackList: []AlbumTrackMetadata{},
|
||||
})
|
||||
}
|
||||
|
||||
tracks := make([]AlbumTrackMetadata, 0, len(raw.Tracks))
|
||||
for idx, item := range raw.Tracks {
|
||||
durationMS := parseDuration(item.Duration)
|
||||
@@ -889,26 +984,30 @@ 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: "",
|
||||
AlbumID: raw.ID,
|
||||
AlbumURL: fmt.Sprintf("https://open.spotify.com/album/%s", raw.ID),
|
||||
ArtistID: artistID,
|
||||
ArtistURL: artistURL,
|
||||
ArtistsData: artistsData,
|
||||
Plays: item.Plays,
|
||||
IsExplicit: item.IsExplicit,
|
||||
})
|
||||
}
|
||||
|
||||
if callback != nil {
|
||||
callback(tracks)
|
||||
}
|
||||
|
||||
return &AlbumResponsePayload{
|
||||
AlbumInfo: info,
|
||||
TrackList: tracks,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *SpotifyMetadataClient) formatPlaylistData(raw *apiPlaylistResponse) PlaylistResponsePayload {
|
||||
func (c *SpotifyMetadataClient) formatPlaylistData(raw *apiPlaylistResponse, callback MetadataCallback) PlaylistResponsePayload {
|
||||
var info PlaylistInfoMetadata
|
||||
info.Tracks.Total = raw.Count
|
||||
info.Followers.Total = raw.Followers
|
||||
@@ -918,6 +1017,13 @@ func (c *SpotifyMetadataClient) formatPlaylistData(raw *apiPlaylistResponse) Pla
|
||||
info.Cover = raw.Cover
|
||||
info.Description = raw.Description
|
||||
|
||||
if callback != nil {
|
||||
callback(PlaylistResponsePayload{
|
||||
PlaylistInfo: info,
|
||||
TrackList: []AlbumTrackMetadata{},
|
||||
})
|
||||
}
|
||||
|
||||
tracks := make([]AlbumTrackMetadata, 0, len(raw.Tracks))
|
||||
for _, item := range raw.Tracks {
|
||||
durationMS := parseDuration(item.Duration)
|
||||
@@ -942,16 +1048,15 @@ func (c *SpotifyMetadataClient) formatPlaylistData(raw *apiPlaylistResponse) Pla
|
||||
Artists: item.Artist,
|
||||
Name: item.Title,
|
||||
AlbumName: item.Album,
|
||||
AlbumArtist: item.Artist,
|
||||
AlbumArtist: item.AlbumArtist,
|
||||
DurationMS: durationMS,
|
||||
Images: item.Cover,
|
||||
ReleaseDate: "",
|
||||
TrackNumber: 0,
|
||||
TotalTracks: 0,
|
||||
DiscNumber: 1,
|
||||
DiscNumber: item.DiscNumber,
|
||||
TotalDiscs: 0,
|
||||
ExternalURL: fmt.Sprintf("https://open.spotify.com/track/%s", item.ID),
|
||||
ISRC: "",
|
||||
AlbumID: item.AlbumID,
|
||||
AlbumURL: fmt.Sprintf("https://open.spotify.com/album/%s", item.AlbumID),
|
||||
ArtistID: artistID,
|
||||
@@ -959,16 +1064,21 @@ func (c *SpotifyMetadataClient) formatPlaylistData(raw *apiPlaylistResponse) Pla
|
||||
ArtistsData: artistsData,
|
||||
Plays: item.Plays,
|
||||
Status: item.Status,
|
||||
IsExplicit: item.IsExplicit,
|
||||
})
|
||||
}
|
||||
|
||||
if callback != nil {
|
||||
callback(tracks)
|
||||
}
|
||||
|
||||
return PlaylistResponsePayload{
|
||||
PlaylistInfo: info,
|
||||
TrackList: tracks,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *SpotifyMetadataClient) formatArtistDiscographyData(ctx context.Context, raw *apiArtistResponse) (*ArtistDiscographyPayload, error) {
|
||||
func (c *SpotifyMetadataClient) formatArtistDiscographyData(ctx context.Context, raw *apiArtistResponse, callback MetadataCallback) (*ArtistDiscographyPayload, error) {
|
||||
discType := "all"
|
||||
|
||||
info := ArtistInfoMetadata{
|
||||
@@ -990,79 +1100,117 @@ func (c *SpotifyMetadataClient) formatArtistDiscographyData(ctx context.Context,
|
||||
albumList := make([]DiscographyAlbumMetadata, 0, len(raw.Discography.All))
|
||||
allTracks := make([]AlbumTrackMetadata, 0)
|
||||
|
||||
type fetchResult struct {
|
||||
tracks []AlbumTrackMetadata
|
||||
err error
|
||||
}
|
||||
|
||||
resultsChan := make(chan fetchResult, len(raw.Discography.All))
|
||||
sem := make(chan struct{}, 5)
|
||||
|
||||
sharedClient := NewSpotifyClient()
|
||||
if err := sharedClient.Initialize(); err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize shared spotify client: %w", err)
|
||||
}
|
||||
|
||||
for _, alb := range raw.Discography.All {
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
|
||||
return &ArtistDiscographyPayload{
|
||||
ArtistInfo: info,
|
||||
AlbumList: albumList,
|
||||
TrackList: allTracks,
|
||||
}, ctx.Err()
|
||||
default:
|
||||
|
||||
}
|
||||
|
||||
albumList = append(albumList, DiscographyAlbumMetadata{
|
||||
ID: alb.ID,
|
||||
Name: alb.Name,
|
||||
AlbumType: "album",
|
||||
AlbumType: alb.Type,
|
||||
ReleaseDate: alb.Date,
|
||||
TotalTracks: 0,
|
||||
TotalTracks: alb.TotalTracks,
|
||||
Artists: raw.Name,
|
||||
Images: alb.Cover,
|
||||
ExternalURL: fmt.Sprintf("https://open.spotify.com/album/%s", alb.ID),
|
||||
})
|
||||
}
|
||||
|
||||
albumData, err := c.fetchAlbum(ctx, alb.ID)
|
||||
if err != nil {
|
||||
fmt.Printf("Error getting tracks for album %s: %v\n", alb.Name, err)
|
||||
continue
|
||||
}
|
||||
if callback != nil {
|
||||
callback(ArtistDiscographyPayload{
|
||||
ArtistInfo: info,
|
||||
AlbumList: albumList,
|
||||
TrackList: []AlbumTrackMetadata{},
|
||||
})
|
||||
}
|
||||
|
||||
for idx, tr := range albumData.Tracks {
|
||||
durationMS := parseDuration(tr.Duration)
|
||||
trackNumber := idx + 1
|
||||
for _, alb := range raw.Discography.All {
|
||||
go func(albumID string, albumName string) {
|
||||
sem <- struct{}{}
|
||||
|
||||
var artistID, artistURL string
|
||||
if len(tr.ArtistIds) > 0 {
|
||||
artistID = tr.ArtistIds[0]
|
||||
artistURL = fmt.Sprintf("https://open.spotify.com/artist/%s", artistID)
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
defer func() { <-sem }()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
resultsChan <- fetchResult{err: ctx.Err()}
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
artistsData := make([]ArtistSimple, 0, len(tr.ArtistIds))
|
||||
for _, id := range tr.ArtistIds {
|
||||
artistsData = append(artistsData, ArtistSimple{
|
||||
ID: id,
|
||||
Name: "",
|
||||
ExternalURL: fmt.Sprintf("https://open.spotify.com/artist/%s", id),
|
||||
albumData, err := c.fetchAlbumWithClient(ctx, sharedClient, albumID, nil)
|
||||
if err != nil {
|
||||
fmt.Printf("Error getting tracks for album %s: %v\n", albumName, err)
|
||||
resultsChan <- fetchResult{tracks: []AlbumTrackMetadata{}}
|
||||
return
|
||||
}
|
||||
|
||||
tracks := make([]AlbumTrackMetadata, 0, len(albumData.Tracks))
|
||||
for idx, tr := range albumData.Tracks {
|
||||
durationMS := parseDuration(tr.Duration)
|
||||
trackNumber := idx + 1
|
||||
|
||||
var artistID, artistURL string
|
||||
if len(tr.ArtistIds) > 0 {
|
||||
artistID = tr.ArtistIds[0]
|
||||
artistURL = fmt.Sprintf("https://open.spotify.com/artist/%s", artistID)
|
||||
}
|
||||
|
||||
artistsData := make([]ArtistSimple, 0, len(tr.ArtistIds))
|
||||
for _, id := range tr.ArtistIds {
|
||||
artistsData = append(artistsData, ArtistSimple{
|
||||
ID: id,
|
||||
Name: "",
|
||||
ExternalURL: fmt.Sprintf("https://open.spotify.com/artist/%s", id),
|
||||
})
|
||||
}
|
||||
|
||||
tracks = append(tracks, AlbumTrackMetadata{
|
||||
SpotifyID: tr.ID,
|
||||
Artists: tr.Artists,
|
||||
Name: tr.Name,
|
||||
AlbumName: albumData.Name,
|
||||
AlbumArtist: raw.Name,
|
||||
AlbumType: "album",
|
||||
DurationMS: durationMS,
|
||||
Images: albumData.Cover,
|
||||
ReleaseDate: albumData.ReleaseDate,
|
||||
TrackNumber: trackNumber,
|
||||
TotalTracks: albumData.Count,
|
||||
DiscNumber: tr.DiscNumber,
|
||||
ExternalURL: fmt.Sprintf("https://open.spotify.com/track/%s", tr.ID),
|
||||
AlbumID: albumID,
|
||||
AlbumURL: fmt.Sprintf("https://open.spotify.com/album/%s", albumID),
|
||||
ArtistID: artistID,
|
||||
ArtistURL: artistURL,
|
||||
ArtistsData: artistsData,
|
||||
Plays: tr.Plays,
|
||||
IsExplicit: tr.IsExplicit,
|
||||
})
|
||||
}
|
||||
if callback != nil {
|
||||
callback(tracks)
|
||||
}
|
||||
resultsChan <- fetchResult{tracks: tracks}
|
||||
}(alb.ID, alb.Name)
|
||||
}
|
||||
|
||||
allTracks = append(allTracks, AlbumTrackMetadata{
|
||||
SpotifyID: tr.ID,
|
||||
Artists: tr.Artists,
|
||||
Name: tr.Name,
|
||||
AlbumName: albumData.Name,
|
||||
AlbumArtist: albumData.Artists,
|
||||
AlbumType: "album",
|
||||
DurationMS: durationMS,
|
||||
Images: albumData.Cover,
|
||||
ReleaseDate: albumData.ReleaseDate,
|
||||
TrackNumber: trackNumber,
|
||||
TotalTracks: albumData.Count,
|
||||
DiscNumber: 1,
|
||||
ExternalURL: fmt.Sprintf("https://open.spotify.com/track/%s", tr.ID),
|
||||
ISRC: tr.ID,
|
||||
AlbumID: alb.ID,
|
||||
AlbumURL: fmt.Sprintf("https://open.spotify.com/album/%s", alb.ID),
|
||||
ArtistID: artistID,
|
||||
ArtistURL: artistURL,
|
||||
ArtistsData: artistsData,
|
||||
Plays: tr.Plays,
|
||||
})
|
||||
for i := 0; i < len(raw.Discography.All); i++ {
|
||||
res := <-resultsChan
|
||||
if res.err != nil {
|
||||
return nil, res.err
|
||||
}
|
||||
allTracks = append(allTracks, res.tracks...)
|
||||
}
|
||||
|
||||
return &ArtistDiscographyPayload{
|
||||
@@ -1212,7 +1360,7 @@ func (c *SpotifyMetadataClient) Search(ctx context.Context, query string, limit
|
||||
return nil, fmt.Errorf("failed to query search: %w", err)
|
||||
}
|
||||
|
||||
filteredData := FilterSearch(data)
|
||||
filteredData := FilterSearch(data, c.Separator)
|
||||
|
||||
jsonData, err := json.Marshal(filteredData)
|
||||
if err != nil {
|
||||
@@ -1241,6 +1389,7 @@ func (c *SpotifyMetadataClient) Search(ctx context.Context, query string, limit
|
||||
Images: item.Cover,
|
||||
ExternalURL: fmt.Sprintf("https://open.spotify.com/track/%s", item.ID),
|
||||
Duration: parseDuration(item.Duration),
|
||||
IsExplicit: item.IsExplicit,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1328,7 +1477,7 @@ func (c *SpotifyMetadataClient) SearchByType(ctx context.Context, query string,
|
||||
return nil, fmt.Errorf("failed to query search: %w", err)
|
||||
}
|
||||
|
||||
filteredData := FilterSearch(data)
|
||||
filteredData := FilterSearch(data, c.Separator)
|
||||
|
||||
jsonData, err := json.Marshal(filteredData)
|
||||
if err != nil {
|
||||
@@ -1354,6 +1503,7 @@ func (c *SpotifyMetadataClient) SearchByType(ctx context.Context, query string,
|
||||
Images: item.Cover,
|
||||
ExternalURL: fmt.Sprintf("https://open.spotify.com/track/%s", item.ID),
|
||||
Duration: parseDuration(item.Duration),
|
||||
IsExplicit: item.IsExplicit,
|
||||
})
|
||||
}
|
||||
case "album":
|
||||
@@ -1400,3 +1550,37 @@ func SearchSpotifyByType(ctx context.Context, query string, searchType string, l
|
||||
client := NewSpotifyMetadataClient()
|
||||
return client.SearchByType(ctx, query, searchType, limit, offset)
|
||||
}
|
||||
|
||||
func GetPreviewURL(trackID string) (string, error) {
|
||||
if trackID == "" {
|
||||
return "", errors.New("track ID cannot be empty")
|
||||
}
|
||||
|
||||
embedURL := fmt.Sprintf("https://open.spotify.com/embed/track/%s", trackID)
|
||||
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
resp, err := client.Get(embedURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to fetch embed page: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return "", fmt.Errorf("embed page returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
|
||||
html := string(body)
|
||||
re := regexp.MustCompile(`https://p\.scdn\.co/mp3-preview/[a-zA-Z0-9]+`)
|
||||
match := re.FindString(html)
|
||||
|
||||
if match == "" {
|
||||
return "", errors.New("preview URL not found")
|
||||
}
|
||||
|
||||
return match, nil
|
||||
}
|
||||
|
||||
@@ -1,23 +1,22 @@
|
||||
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'
|
||||
|
||||
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,
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
]);
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@300..800&family=Figtree:wght@300..900&family=Geist:wght@100..900&family=Google+Sans+Code:ital,wght@0,300..800;1,300..800&family=Google+Sans:ital,opsz,wght@0,17..18,400..700;1,17..18,400..700&family=Inter:wght@300..800&family=JetBrains+Mono:ital,wght@0,100..800;1,100..800&family=Manrope:wght@300..800&family=Noto+Sans:wght@100..900&family=Nunito+Sans:opsz,wght@6..12,200..1000&family=Outfit:wght@100..900&family=Plus+Jakarta+Sans:wght@300..800&family=Poppins:wght@300;400;500;600;700;800&family=Public+Sans:ital,wght@0,100..900;1,100..900&family=Raleway:wght@100..900&family=Roboto:wght@300;400;500;700;900&family=Space+Grotesk:wght@300..700&display=swap" rel="stylesheet">
|
||||
<title>SpotiFLAC</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,200..800&family=DM+Sans:wght@300..800&family=Figtree:wght@300..900&family=Geist:wght@100..900&family=Google+Sans+Code:ital,wght@0,300..800;1,300..800&family=Google+Sans:ital,opsz,wght@0,17..18,400..700;1,17..18,400..700&family=Inter:wght@300..800&family=JetBrains+Mono:ital,wght@0,100..800;1,100..800&family=Manrope:wght@300..800&family=Noto+Sans:wght@100..900&family=Nunito+Sans:opsz,wght@6..12,200..1000&family=Outfit:wght@100..900&family=Plus+Jakarta+Sans:wght@300..800&family=Poppins:wght@300;400;500;600;700;800&family=Public+Sans:ital,wght@0,100..900;1,100..900&family=Raleway:wght@100..900&family=Roboto:wght@300;400;500;700;900&family=Space+Grotesk:wght@300..700&display=swap"
|
||||
rel="stylesheet">
|
||||
<title>SpotiFLAC</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -16,39 +16,43 @@
|
||||
"@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",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toggle": "^1.1.10",
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.562.0",
|
||||
"motion": "^12.25.0",
|
||||
"lucide-react": "^0.575.0",
|
||||
"motion": "^12.34.3",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss": "^4.1.18"
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@types/node": "^25.0.6",
|
||||
"@types/react": "^19.2.8",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/node": "^25.3.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.2",
|
||||
"eslint": "^9.39.2",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"eslint": "^10.0.2",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.26",
|
||||
"globals": "^17.0.0",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.3.0",
|
||||
"sharp": "^0.34.5",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.52.0",
|
||||
"typescript-eslint": "^8.56.1",
|
||||
"vite": "^7.3.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
6f2a6dc27f7d8d215283f6d07b4eaa54
|
||||
867c45db7982e126a7249d80210f23be
|
||||
@@ -2,32 +2,23 @@ import sharp from 'sharp';
|
||||
import { readFileSync, mkdirSync } from 'fs';
|
||||
import { dirname, join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const rootDir = join(__dirname, '..', '..');
|
||||
|
||||
const svgPath = join(rootDir, 'frontend', 'public', 'icon.svg');
|
||||
const outputPath = join(rootDir, 'build', 'appicon.png');
|
||||
|
||||
async function generateIcon() {
|
||||
try {
|
||||
// Ensure build directory exists
|
||||
mkdirSync(join(rootDir, 'build'), { recursive: true });
|
||||
|
||||
// Read SVG
|
||||
const svgBuffer = readFileSync(svgPath);
|
||||
|
||||
// Convert SVG to PNG (1024x1024 for Wails)
|
||||
await sharp(svgBuffer)
|
||||
.resize(1024, 1024)
|
||||
.png()
|
||||
.toFile(outputPath);
|
||||
|
||||
console.log('✓ Icon generated:', outputPath);
|
||||
} catch (error) {
|
||||
console.error('✗ Failed to generate icon:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
try {
|
||||
mkdirSync(join(rootDir, 'build'), { recursive: true });
|
||||
const svgBuffer = readFileSync(svgPath);
|
||||
await sharp(svgBuffer)
|
||||
.resize(1024, 1024)
|
||||
.png()
|
||||
.toFile(outputPath);
|
||||
console.log('✓ Icon generated:', outputPath);
|
||||
}
|
||||
catch (error) {
|
||||
console.error('✗ Failed to generate icon:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
generateIcon();
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect, useCallback, useLayoutEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Search, X, ArrowUp } from "lucide-react";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { getSettings, getSettingsWithDefaults, saveSettings, applyThemeMode, applyFont } from "@/lib/settings";
|
||||
import { getSettings, getSettingsWithDefaults, loadSettings, saveSettings, applyThemeMode, applyFont, updateSettings } from "@/lib/settings";
|
||||
import { applyTheme } from "@/lib/themes";
|
||||
import { OpenFolder } from "../wailsjs/go/main/App";
|
||||
import { OpenFolder, CheckFFmpegInstalled, DownloadFFmpeg, GetBrewPath, InstallFFmpegWithBrew } from "../wailsjs/go/main/App";
|
||||
import { EventsOn, EventsOff, Quit } from "../wailsjs/runtime/runtime";
|
||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||
import { TitleBar } from "@/components/TitleBar";
|
||||
import { Sidebar, type PageType } from "@/components/Sidebar";
|
||||
@@ -21,18 +20,89 @@ import { DownloadQueue } from "@/components/DownloadQueue";
|
||||
import { DownloadProgressToast } from "@/components/DownloadProgressToast";
|
||||
import { AudioAnalysisPage } from "@/components/AudioAnalysisPage";
|
||||
import { AudioConverterPage } from "@/components/AudioConverterPage";
|
||||
import { AudioResamplerPage } from "@/components/AudioResamplerPage";
|
||||
import { FileManagerPage } from "@/components/FileManagerPage";
|
||||
import { SettingsPage } from "@/components/SettingsPage";
|
||||
import { DebugLoggerPage } from "@/components/DebugLoggerPage";
|
||||
import { AboutPage } from "@/components/AboutPage";
|
||||
import { HistoryPage } from "@/components/HistoryPage";
|
||||
import type { HistoryItem } from "@/components/FetchHistory";
|
||||
import { useDownload } from "@/hooks/useDownload";
|
||||
import { useMetadata } from "@/hooks/useMetadata";
|
||||
import { useLyrics } from "@/hooks/useLyrics";
|
||||
import { useCover } from "@/hooks/useCover";
|
||||
import { useAvailability } from "@/hooks/useAvailability";
|
||||
import { ensureApiStatusCheckStarted } from "@/lib/api-status";
|
||||
import { useDownloadQueueDialog } from "@/hooks/useDownloadQueueDialog";
|
||||
import { useDownloadProgress } from "@/hooks/useDownloadProgress";
|
||||
const HISTORY_KEY = "spotiflac_fetch_history";
|
||||
const MAX_HISTORY = 5;
|
||||
function extractSpotifyEntityFromURL(url: string): {
|
||||
type: string;
|
||||
id: string;
|
||||
} | null {
|
||||
const trimmed = url.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
const spotifyUriMatch = trimmed.match(/^spotify:(track|album|playlist|artist):([A-Za-z0-9]+)$/i);
|
||||
if (spotifyUriMatch) {
|
||||
return {
|
||||
type: spotifyUriMatch[1].toLowerCase(),
|
||||
id: spotifyUriMatch[2],
|
||||
};
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
const segments = parsed.pathname.split("/").filter(Boolean);
|
||||
const supportedTypes = new Set(["track", "album", "playlist", "artist"]);
|
||||
for (let i = 0; i < segments.length - 1; i++) {
|
||||
const segment = segments[i].toLowerCase();
|
||||
if (!supportedTypes.has(segment)) {
|
||||
continue;
|
||||
}
|
||||
const id = segments[i + 1];
|
||||
if (id) {
|
||||
return { type: segment, id };
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function normalizeHistoryURL(url: string): string {
|
||||
const trimmed = url.trim();
|
||||
if (!trimmed)
|
||||
return trimmed;
|
||||
const withoutQuery = trimmed.split("?")[0].replace(/\/+$/, "");
|
||||
const spotifyEntity = extractSpotifyEntityFromURL(withoutQuery);
|
||||
if (spotifyEntity) {
|
||||
return `https://open.spotify.com/${spotifyEntity.type}/${spotifyEntity.id}`;
|
||||
}
|
||||
return withoutQuery.replace(/(\/artist\/[A-Za-z0-9]+)\/discography\/all$/i, "$1");
|
||||
}
|
||||
function getHistoryIdentityKey(type: HistoryItem["type"], url: string): string {
|
||||
const normalizedUrl = normalizeHistoryURL(url);
|
||||
const spotifyEntity = extractSpotifyEntityFromURL(normalizedUrl);
|
||||
if (spotifyEntity) {
|
||||
return `${type}:${spotifyEntity.id}`;
|
||||
}
|
||||
return `${type}:${normalizedUrl}`;
|
||||
}
|
||||
function dedupeHistoryItems(items: HistoryItem[]): HistoryItem[] {
|
||||
const seen = new Set<string>();
|
||||
const deduped: HistoryItem[] = [];
|
||||
for (const item of items) {
|
||||
const normalizedUrl = normalizeHistoryURL(item.url);
|
||||
const key = getHistoryIdentityKey(item.type, normalizedUrl);
|
||||
if (seen.has(key))
|
||||
continue;
|
||||
seen.add(key);
|
||||
deduped.push({ ...item, url: normalizedUrl });
|
||||
}
|
||||
return deduped;
|
||||
}
|
||||
function App() {
|
||||
const [currentPage, setCurrentPage] = useState<PageType>("main");
|
||||
const [spotifyUrl, setSpotifyUrl] = useState("");
|
||||
@@ -44,31 +114,62 @@ function App() {
|
||||
const [releaseDate, setReleaseDate] = useState<string | null>(null);
|
||||
const [fetchHistory, setFetchHistory] = useState<HistoryItem[]>([]);
|
||||
const [isSearchMode, setIsSearchMode] = useState(false);
|
||||
const [region, setRegion] = useState(() => localStorage.getItem("spotiflac_region") || "US");
|
||||
useEffect(() => {
|
||||
localStorage.setItem("spotiflac_region", region);
|
||||
}, [region]);
|
||||
const [showScrollTop, setShowScrollTop] = useState(false);
|
||||
const [hasUnsavedSettings, setHasUnsavedSettings] = useState(false);
|
||||
const [pendingPageChange, setPendingPageChange] = useState<PageType | null>(null);
|
||||
const [showUnsavedChangesDialog, setShowUnsavedChangesDialog] = useState(false);
|
||||
const [resetSettingsFn, setResetSettingsFn] = useState<(() => void) | null>(null);
|
||||
const ITEMS_PER_PAGE = 50;
|
||||
const CURRENT_VERSION = "7.0.3";
|
||||
const download = useDownload();
|
||||
const CURRENT_VERSION = __APP_VERSION__;
|
||||
const download = useDownload(region);
|
||||
const metadata = useMetadata();
|
||||
const lyrics = useLyrics();
|
||||
const cover = useCover();
|
||||
const availability = useAvailability();
|
||||
const downloadQueue = useDownloadQueueDialog();
|
||||
const downloadProgress = useDownloadProgress();
|
||||
const [isFFmpegInstalled, setIsFFmpegInstalled] = useState<boolean | null>(null);
|
||||
const [brewPath, setBrewPath] = useState<string>("");
|
||||
const [isInstallingFFmpeg, setIsInstallingFFmpeg] = useState(false);
|
||||
const [ffmpegInstallProgress, setFfmpegInstallProgress] = useState(0);
|
||||
const [ffmpegInstallStatus, setFfmpegInstallStatus] = useState("");
|
||||
useLayoutEffect(() => {
|
||||
const savedSettings = getSettings();
|
||||
if (savedSettings) {
|
||||
applyThemeMode(savedSettings.themeMode);
|
||||
applyTheme(savedSettings.theme);
|
||||
applyFont(savedSettings.fontFamily);
|
||||
}
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const initSettings = async () => {
|
||||
const settings = getSettings();
|
||||
const settings = await loadSettings();
|
||||
applyThemeMode(settings.themeMode);
|
||||
applyTheme(settings.theme);
|
||||
applyFont(settings.fontFamily);
|
||||
if (!settings.downloadPath) {
|
||||
const settingsWithDefaults = await getSettingsWithDefaults();
|
||||
saveSettings(settingsWithDefaults);
|
||||
await saveSettings(settingsWithDefaults);
|
||||
}
|
||||
};
|
||||
initSettings();
|
||||
const checkFFmpeg = async () => {
|
||||
try {
|
||||
const installed = await CheckFFmpegInstalled();
|
||||
setIsFFmpegInstalled(installed);
|
||||
const brew = await GetBrewPath();
|
||||
setBrewPath(brew);
|
||||
}
|
||||
catch (err) {
|
||||
console.error("Failed to check FFmpeg:", err);
|
||||
setIsFFmpegInstalled(false);
|
||||
}
|
||||
};
|
||||
checkFFmpeg();
|
||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const handleChange = () => {
|
||||
const currentSettings = getSettings();
|
||||
@@ -79,6 +180,7 @@ function App() {
|
||||
};
|
||||
mediaQuery.addEventListener("change", handleChange);
|
||||
checkForUpdates();
|
||||
ensureApiStatusCheckStarted();
|
||||
loadHistory();
|
||||
const handleScroll = () => {
|
||||
setShowScrollTop(window.scrollY > 300);
|
||||
@@ -89,6 +191,17 @@ function App() {
|
||||
window.removeEventListener("scroll", handleScroll);
|
||||
};
|
||||
}, []);
|
||||
const handleEnableSpotFetchApi = async () => {
|
||||
try {
|
||||
await updateSettings({ useSpotFetchAPI: true });
|
||||
metadata.setShowApiModal(false);
|
||||
toast.success("SpotFetch API enabled! You can now try fetching again.");
|
||||
}
|
||||
catch (err) {
|
||||
console.error("Failed to enable SpotFetch API:", err);
|
||||
toast.error("Failed to update settings");
|
||||
}
|
||||
};
|
||||
const scrollToTop = useCallback(() => {
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}, []);
|
||||
@@ -122,13 +235,53 @@ function App() {
|
||||
try {
|
||||
const saved = localStorage.getItem(HISTORY_KEY);
|
||||
if (saved) {
|
||||
setFetchHistory(JSON.parse(saved));
|
||||
const deduped = dedupeHistoryItems(JSON.parse(saved));
|
||||
setFetchHistory(deduped);
|
||||
localStorage.setItem(HISTORY_KEY, JSON.stringify(deduped));
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
console.error("Failed to load history:", err);
|
||||
}
|
||||
};
|
||||
const handleInstallFFmpeg = async (useBrew: boolean = false) => {
|
||||
setIsInstallingFFmpeg(true);
|
||||
setFfmpegInstallProgress(0);
|
||||
setFfmpegInstallStatus("starting");
|
||||
try {
|
||||
EventsOn("ffmpeg:progress", (progress: number) => {
|
||||
setFfmpegInstallProgress(progress);
|
||||
if (progress >= 100) {
|
||||
setFfmpegInstallStatus("extracting");
|
||||
}
|
||||
else {
|
||||
setFfmpegInstallStatus("downloading");
|
||||
}
|
||||
});
|
||||
EventsOn("ffmpeg:status", (status: string) => {
|
||||
setFfmpegInstallStatus(status);
|
||||
});
|
||||
const response = useBrew ? await InstallFFmpegWithBrew() : await DownloadFFmpeg();
|
||||
EventsOff("ffmpeg:progress");
|
||||
EventsOff("ffmpeg:status");
|
||||
if (response.success) {
|
||||
toast.success(useBrew ? "FFmpeg installed successfully via Homebrew!" : "FFmpeg installed successfully!");
|
||||
setIsFFmpegInstalled(true);
|
||||
}
|
||||
else {
|
||||
toast.error(`Failed to install FFmpeg: ${response.error}`);
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error("Error installing FFmpeg:", error);
|
||||
toast.error(`Error during FFmpeg installation: ${error}`);
|
||||
}
|
||||
finally {
|
||||
setIsInstallingFFmpeg(false);
|
||||
setFfmpegInstallProgress(0);
|
||||
setFfmpegInstallStatus("");
|
||||
}
|
||||
};
|
||||
const saveHistory = (history: HistoryItem[]) => {
|
||||
try {
|
||||
localStorage.setItem(HISTORY_KEY, JSON.stringify(history));
|
||||
@@ -139,9 +292,12 @@ function App() {
|
||||
};
|
||||
const addToHistory = (item: Omit<HistoryItem, "id" | "timestamp">) => {
|
||||
setFetchHistory((prev) => {
|
||||
const filtered = prev.filter((h) => h.url !== item.url);
|
||||
const normalizedUrl = normalizeHistoryURL(item.url);
|
||||
const identityKey = getHistoryIdentityKey(item.type, normalizedUrl);
|
||||
const filtered = prev.filter((h) => getHistoryIdentityKey(h.type, h.url) !== identityKey);
|
||||
const newItem: HistoryItem = {
|
||||
...item,
|
||||
url: normalizedUrl,
|
||||
id: crypto.randomUUID(),
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
@@ -190,7 +346,7 @@ function App() {
|
||||
url: spotifyUrl,
|
||||
type: "album",
|
||||
name: album_info.name,
|
||||
artist: `${album_info.total_tracks} tracks`,
|
||||
artist: `${album_info.total_tracks.toLocaleString()} tracks`,
|
||||
image: album_info.images,
|
||||
};
|
||||
}
|
||||
@@ -200,7 +356,7 @@ function App() {
|
||||
url: spotifyUrl,
|
||||
type: "playlist",
|
||||
name: playlist_info.owner.name,
|
||||
artist: `${playlist_info.tracks.total} tracks`,
|
||||
artist: `${playlist_info.tracks.total.toLocaleString()} tracks`,
|
||||
image: playlist_info.cover || playlist_info.owner.images || "",
|
||||
};
|
||||
}
|
||||
@@ -210,7 +366,7 @@ function App() {
|
||||
url: spotifyUrl,
|
||||
type: "artist",
|
||||
name: artist_info.name,
|
||||
artist: `${artist_info.total_albums} albums`,
|
||||
artist: `${artist_info.total_albums.toLocaleString()} albums`,
|
||||
image: artist_info.images,
|
||||
};
|
||||
}
|
||||
@@ -222,16 +378,19 @@ function App() {
|
||||
setSearchQuery(value);
|
||||
setCurrentListPage(1);
|
||||
};
|
||||
const toggleTrackSelection = (isrc: string) => {
|
||||
setSelectedTracks((prev) => prev.includes(isrc) ? prev.filter((id) => id !== isrc) : [...prev, isrc]);
|
||||
const toggleTrackSelection = (id: string) => {
|
||||
setSelectedTracks((prev) => prev.includes(id) ? prev.filter((prevId) => prevId !== id) : [...prev, id]);
|
||||
};
|
||||
const toggleSelectAll = (tracks: any[]) => {
|
||||
const tracksWithIsrc = tracks.filter((track) => track.isrc).map((track) => track.isrc);
|
||||
if (selectedTracks.length === tracksWithIsrc.length) {
|
||||
setSelectedTracks([]);
|
||||
const tracksWithId = tracks.filter((track) => track.spotify_id).map((track) => track.spotify_id || "");
|
||||
if (tracksWithId.length === 0)
|
||||
return;
|
||||
const allSelected = tracksWithId.every(id => selectedTracks.includes(id));
|
||||
if (allSelected) {
|
||||
setSelectedTracks(prev => prev.filter(id => !tracksWithId.includes(id)));
|
||||
}
|
||||
else {
|
||||
setSelectedTracks(tracksWithIsrc);
|
||||
setSelectedTracks(prev => Array.from(new Set([...prev, ...tracksWithId])));
|
||||
}
|
||||
};
|
||||
const handleOpenFolder = async () => {
|
||||
@@ -253,11 +412,14 @@ function App() {
|
||||
return null;
|
||||
if ("track" in metadata.metadata) {
|
||||
const { track } = metadata.metadata;
|
||||
return (<TrackInfo track={track} isDownloading={download.isDownloading} downloadingTrack={download.downloadingTrack} isDownloaded={download.downloadedTracks.has(track.isrc)} isFailed={download.failedTracks.has(track.isrc)} isSkipped={download.skippedTracks.has(track.isrc)} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} downloadedLyrics={lyrics.downloadedLyrics.has(track.spotify_id || "")} failedLyrics={lyrics.failedLyrics.has(track.spotify_id || "")} skippedLyrics={lyrics.skippedLyrics.has(track.spotify_id || "")} checkingAvailability={availability.checkingTrackId === track.spotify_id} availability={availability.getAvailability(track.spotify_id || "")} downloadingCover={cover.downloadingCover} downloadedCover={cover.downloadedCovers.has(track.spotify_id || "")} failedCover={cover.failedCovers.has(track.spotify_id || "")} skippedCover={cover.skippedCovers.has(track.spotify_id || "")} onDownload={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, undefined, undefined, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onDownloadCover={(coverUrl, trackName, artistName, albumName, _playlistName, _position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, undefined, undefined, trackId, albumArtist, releaseDate, discNumber)} onOpenFolder={handleOpenFolder}/>);
|
||||
const trackId = track.spotify_id || "";
|
||||
return (<TrackInfo track={track} isDownloading={download.isDownloading} downloadingTrack={download.downloadingTrack} isDownloaded={download.downloadedTracks.has(trackId)} isFailed={download.failedTracks.has(trackId)} isSkipped={download.skippedTracks.has(trackId)} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} downloadedLyrics={lyrics.downloadedLyrics.has(track.spotify_id || "")} failedLyrics={lyrics.failedLyrics.has(track.spotify_id || "")} skippedLyrics={lyrics.skippedLyrics.has(track.spotify_id || "")} checkingAvailability={availability.checkingTrackId === track.spotify_id} availability={availability.availabilityMap.get(track.spotify_id || "")} downloadingCover={cover.downloadingCoverTrack === (track.spotify_id || `${track.name}-${track.artists}`)} downloadedCover={cover.downloadedCovers.has(track.spotify_id || `${track.name}-${track.artists}`)} failedCover={cover.failedCovers.has(track.spotify_id || `${track.name}-${track.artists}`)} skippedCover={cover.skippedCovers.has(track.spotify_id || `${track.name}-${track.artists}`)} onDownload={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, undefined, undefined, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _playlistName, _position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, undefined, undefined, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onOpenFolder={handleOpenFolder} onBack={metadata.resetMetadata}/>);
|
||||
}
|
||||
if ("album_info" in metadata.metadata) {
|
||||
const { album_info, track_list } = metadata.metadata;
|
||||
return (<AlbumInfo albumInfo={album_info} trackList={track_list} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} failedTracks={download.failedTracks} skippedTracks={download.skippedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} downloadProgress={download.downloadProgress} currentDownloadInfo={download.currentDownloadInfo} currentPage={currentListPage} itemsPerPage={ITEMS_PER_PAGE} downloadedLyrics={lyrics.downloadedLyrics} failedLyrics={lyrics.failedLyrics} skippedLyrics={lyrics.skippedLyrics} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} checkingAvailabilityTrack={availability.checkingTrackId} availabilityMap={availability.availabilityMap} downloadedCovers={cover.downloadedCovers} failedCovers={cover.failedCovers} skippedCovers={cover.skippedCovers} downloadingCoverTrack={cover.downloadingCoverTrack} isBulkDownloadingCovers={cover.isBulkDownloadingCovers} isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} onToggleSelectAll={toggleSelectAll} onDownloadTrack={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, album_info.name, position, albumArtist, releaseDate, discNumber, true)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, album_info.name, position, trackId, albumArtist, releaseDate, discNumber, true)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, album_info.name, undefined, true)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, album_info.name, true)} onDownloadAll={() => download.handleDownloadAll(track_list, undefined, true)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, undefined, true)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onPageChange={setCurrentListPage} onArtistClick={async (artist) => {
|
||||
return (<AlbumInfo albumInfo={album_info} trackList={track_list} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} failedTracks={download.failedTracks} skippedTracks={download.skippedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} downloadProgress={download.downloadProgress} currentDownloadInfo={download.currentDownloadInfo} currentPage={currentListPage} itemsPerPage={ITEMS_PER_PAGE} downloadedLyrics={lyrics.downloadedLyrics} failedLyrics={lyrics.failedLyrics} skippedLyrics={lyrics.skippedLyrics} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} checkingAvailabilityTrack={availability.checkingTrackId} availabilityMap={availability.availabilityMap} downloadedCovers={cover.downloadedCovers} failedCovers={cover.failedCovers} skippedCovers={cover.skippedCovers} downloadingCoverTrack={cover.downloadingCoverTrack} isBulkDownloadingCovers={cover.isBulkDownloadingCovers} isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} onToggleSelectAll={toggleSelectAll} onDownloadTrack={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, album_info.name, position, albumArtist, releaseDate, discNumber, true)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, album_info.name, position, trackId, albumArtist, releaseDate, discNumber, true)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, album_info.name, undefined, true)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, album_info.name, true)} onDownloadAll={() => download.handleDownloadAll(track_list, album_info.name, true)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, album_info.name, true)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onPageChange={setCurrentListPage} onBack={metadata.resetMetadata} onArtistClick={async (artist) => {
|
||||
const pendingArtistUrl = artist.external_urls.replace(/\/$/, "") + "/discography/all";
|
||||
setSpotifyUrl(pendingArtistUrl);
|
||||
const artistUrl = await metadata.handleArtistClick(artist);
|
||||
if (artistUrl) {
|
||||
setSpotifyUrl(artistUrl);
|
||||
@@ -271,7 +433,9 @@ function App() {
|
||||
}
|
||||
if ("playlist_info" in metadata.metadata) {
|
||||
const { playlist_info, track_list } = metadata.metadata;
|
||||
return (<PlaylistInfo playlistInfo={playlist_info} trackList={track_list} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} failedTracks={download.failedTracks} skippedTracks={download.skippedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} downloadProgress={download.downloadProgress} currentDownloadInfo={download.currentDownloadInfo} currentPage={currentListPage} itemsPerPage={ITEMS_PER_PAGE} downloadedLyrics={lyrics.downloadedLyrics} failedLyrics={lyrics.failedLyrics} skippedLyrics={lyrics.skippedLyrics} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} checkingAvailabilityTrack={availability.checkingTrackId} availabilityMap={availability.availabilityMap} downloadedCovers={cover.downloadedCovers} failedCovers={cover.failedCovers} skippedCovers={cover.skippedCovers} downloadingCoverTrack={cover.downloadingCoverTrack} isBulkDownloadingCovers={cover.isBulkDownloadingCovers} isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} onToggleSelectAll={toggleSelectAll} onDownloadTrack={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, playlist_info.owner.name, position, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, playlist_info.owner.name, position, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, playlist_info.owner.name)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, playlist_info.owner.name)} onDownloadAll={() => download.handleDownloadAll(track_list, playlist_info.owner.name)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, playlist_info.owner.name)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onPageChange={setCurrentListPage} onAlbumClick={metadata.handleAlbumClick} onArtistClick={async (artist) => {
|
||||
return (<PlaylistInfo playlistInfo={playlist_info} trackList={track_list} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} failedTracks={download.failedTracks} skippedTracks={download.skippedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} downloadProgress={download.downloadProgress} currentDownloadInfo={download.currentDownloadInfo} currentPage={currentListPage} itemsPerPage={ITEMS_PER_PAGE} downloadedLyrics={lyrics.downloadedLyrics} failedLyrics={lyrics.failedLyrics} skippedLyrics={lyrics.skippedLyrics} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} checkingAvailabilityTrack={availability.checkingTrackId} availabilityMap={availability.availabilityMap} downloadedCovers={cover.downloadedCovers} failedCovers={cover.failedCovers} skippedCovers={cover.skippedCovers} downloadingCoverTrack={cover.downloadingCoverTrack} isBulkDownloadingCovers={cover.isBulkDownloadingCovers} isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} onToggleSelectAll={toggleSelectAll} onDownloadTrack={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, playlist_info.owner.name, position, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, playlist_info.owner.name, position, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, playlist_info.owner.name)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, playlist_info.owner.name)} onDownloadAll={() => download.handleDownloadAll(track_list, playlist_info.owner.name)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, playlist_info.owner.name)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onPageChange={setCurrentListPage} onBack={metadata.resetMetadata} onAlbumClick={metadata.handleAlbumClick} onArtistClick={async (artist) => {
|
||||
const pendingArtistUrl = artist.external_urls.replace(/\/$/, "") + "/discography/all";
|
||||
setSpotifyUrl(pendingArtistUrl);
|
||||
const artistUrl = await metadata.handleArtistClick(artist);
|
||||
if (artistUrl) {
|
||||
setSpotifyUrl(artistUrl);
|
||||
@@ -285,7 +449,9 @@ function App() {
|
||||
}
|
||||
if ("artist_info" in metadata.metadata) {
|
||||
const { artist_info, album_list, track_list } = metadata.metadata;
|
||||
return (<ArtistInfo artistInfo={artist_info} albumList={album_list} trackList={track_list} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} failedTracks={download.failedTracks} skippedTracks={download.skippedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} downloadProgress={download.downloadProgress} currentDownloadInfo={download.currentDownloadInfo} currentPage={currentListPage} itemsPerPage={ITEMS_PER_PAGE} downloadedLyrics={lyrics.downloadedLyrics} failedLyrics={lyrics.failedLyrics} skippedLyrics={lyrics.skippedLyrics} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} checkingAvailabilityTrack={availability.checkingTrackId} availabilityMap={availability.availabilityMap} downloadedCovers={cover.downloadedCovers} failedCovers={cover.failedCovers} skippedCovers={cover.skippedCovers} downloadingCoverTrack={cover.downloadingCoverTrack} isBulkDownloadingCovers={cover.isBulkDownloadingCovers} isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} onToggleSelectAll={toggleSelectAll} onDownloadTrack={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, artist_info.name, position, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, artist_info.name, position, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, artist_info.name)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, artist_info.name)} onDownloadAll={() => download.handleDownloadAll(track_list, artist_info.name)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, artist_info.name)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onAlbumClick={metadata.handleAlbumClick} onArtistClick={async (artist) => {
|
||||
return (<ArtistInfo artistInfo={artist_info} albumList={album_list} trackList={track_list} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} failedTracks={download.failedTracks} skippedTracks={download.skippedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} downloadProgress={download.downloadProgress} currentDownloadInfo={download.currentDownloadInfo} currentPage={currentListPage} itemsPerPage={ITEMS_PER_PAGE} downloadedLyrics={lyrics.downloadedLyrics} failedLyrics={lyrics.failedLyrics} skippedLyrics={lyrics.skippedLyrics} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} checkingAvailabilityTrack={availability.checkingTrackId} availabilityMap={availability.availabilityMap} downloadedCovers={cover.downloadedCovers} failedCovers={cover.failedCovers} skippedCovers={cover.skippedCovers} downloadingCoverTrack={cover.downloadingCoverTrack} isBulkDownloadingCovers={cover.isBulkDownloadingCovers} isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} onToggleSelectAll={toggleSelectAll} onDownloadTrack={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, artist_info.name, position, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, artist_info.name, position, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, artist_info.name)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, artist_info.name)} onDownloadAll={() => download.handleDownloadAll(track_list, artist_info.name)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, artist_info.name)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onAlbumClick={metadata.handleAlbumClick} onBack={metadata.resetMetadata} onArtistClick={async (artist) => {
|
||||
const pendingArtistUrl = artist.external_urls.replace(/\/$/, "") + "/discography/all";
|
||||
setSpotifyUrl(pendingArtistUrl);
|
||||
const artistUrl = await metadata.handleArtistClick(artist);
|
||||
if (artistUrl) {
|
||||
setSpotifyUrl(artistUrl);
|
||||
@@ -331,141 +497,192 @@ function App() {
|
||||
return <SettingsPage onUnsavedChangesChange={setHasUnsavedSettings} onResetRequest={setResetSettingsFn}/>;
|
||||
case "debug":
|
||||
return <DebugLoggerPage />;
|
||||
case "about":
|
||||
return <AboutPage />;
|
||||
case "history":
|
||||
return <HistoryPage onHistorySelect={(cachedData) => {
|
||||
metadata.loadFromCache(cachedData);
|
||||
setCurrentPage("main");
|
||||
}}/>;
|
||||
case "audio-analysis":
|
||||
return <AudioAnalysisPage />;
|
||||
case "audio-converter":
|
||||
return <AudioConverterPage />;
|
||||
case "audio-resampler":
|
||||
return <AudioResamplerPage />;
|
||||
case "file-manager":
|
||||
return <FileManagerPage />;
|
||||
default:
|
||||
return (<>
|
||||
<Header version={CURRENT_VERSION} hasUpdate={hasUpdate} releaseDate={releaseDate}/>
|
||||
<Header version={CURRENT_VERSION} hasUpdate={hasUpdate} releaseDate={releaseDate}/>
|
||||
|
||||
|
||||
<Dialog open={metadata.showTimeoutDialog} onOpenChange={metadata.setShowTimeoutDialog}>
|
||||
<DialogContent className="sm:max-w-[425px] p-6 [&>button]:hidden">
|
||||
<div className="absolute right-4 top-4">
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6 opacity-70 hover:opacity-100" onClick={() => metadata.setShowTimeoutDialog(false)}>
|
||||
<X className="h-4 w-4"/>
|
||||
</Button>
|
||||
</div>
|
||||
<DialogTitle className="text-sm font-medium">Fetch Artist</DialogTitle>
|
||||
<DialogDescription>
|
||||
Set timeout for fetching metadata. Longer timeout is recommended for artists
|
||||
with large discography.
|
||||
</DialogDescription>
|
||||
{metadata.pendingArtistName && (<div className="py-2">
|
||||
<p className="font-medium bg-muted/50 rounded-md px-3 py-2">{metadata.pendingArtistName}</p>
|
||||
</div>)}
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="timeout">Timeout (seconds)</Label>
|
||||
<Input id="timeout" type="number" min="10" max="600" value={metadata.timeoutValue} onChange={(e) => metadata.setTimeoutValue(Number(e.target.value))}/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Default: 60 seconds. For large discographies, try 300-600 seconds (5-10
|
||||
minutes).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => metadata.setShowTimeoutDialog(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={metadata.handleConfirmFetch}>
|
||||
<Search className="h-4 w-4"/>
|
||||
Fetch
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
|
||||
<Dialog open={metadata.showAlbumDialog} onOpenChange={metadata.setShowAlbumDialog}>
|
||||
<DialogContent className="sm:max-w-[425px] p-6 [&>button]:hidden">
|
||||
<div className="absolute right-4 top-4">
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6 opacity-70 hover:opacity-100" onClick={() => metadata.setShowAlbumDialog(false)}>
|
||||
<X className="h-4 w-4"/>
|
||||
</Button>
|
||||
</div>
|
||||
<DialogTitle className="text-sm font-medium">Fetch Album</DialogTitle>
|
||||
<DialogDescription>
|
||||
Do you want to fetch metadata for this album?
|
||||
</DialogDescription>
|
||||
{metadata.selectedAlbum && (<div className="py-2">
|
||||
<p className="font-medium bg-muted/50 rounded-md px-3 py-2">{metadata.selectedAlbum.name}</p>
|
||||
</div>)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => metadata.setShowAlbumDialog(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={async () => {
|
||||
|
||||
|
||||
<Dialog open={metadata.showAlbumDialog} onOpenChange={metadata.setShowAlbumDialog}>
|
||||
<DialogContent className="sm:max-w-[425px] p-6 [&>button]:hidden">
|
||||
<div className="absolute right-4 top-4">
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6 opacity-70 hover:opacity-100" onClick={() => metadata.setShowAlbumDialog(false)}>
|
||||
<X className="h-4 w-4"/>
|
||||
</Button>
|
||||
</div>
|
||||
<DialogTitle className="text-sm font-medium">Fetch Album</DialogTitle>
|
||||
<DialogDescription>
|
||||
Do you want to fetch metadata for this album?
|
||||
</DialogDescription>
|
||||
{metadata.selectedAlbum && (<div className="py-2">
|
||||
<p className="font-medium bg-muted/50 rounded-md px-3 py-2">{metadata.selectedAlbum.name}</p>
|
||||
</div>)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => metadata.setShowAlbumDialog(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={async () => {
|
||||
const pendingAlbumUrl = metadata.selectedAlbum?.external_urls;
|
||||
if (pendingAlbumUrl) {
|
||||
setSpotifyUrl(pendingAlbumUrl);
|
||||
}
|
||||
const albumUrl = await metadata.handleConfirmAlbumFetch();
|
||||
if (albumUrl) {
|
||||
setSpotifyUrl(albumUrl);
|
||||
}
|
||||
}}>
|
||||
<Search className="h-4 w-4"/>
|
||||
Fetch Album
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Search className="h-4 w-4"/>
|
||||
Fetch Album
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<SearchBar url={spotifyUrl} loading={metadata.loading} onUrlChange={setSpotifyUrl} onFetch={handleFetchMetadata} onFetchUrl={async (url) => {
|
||||
<SearchBar url={spotifyUrl} loading={metadata.loading} onUrlChange={setSpotifyUrl} onFetch={handleFetchMetadata} onFetchUrl={async (url) => {
|
||||
setSpotifyUrl(url);
|
||||
const updatedUrl = await metadata.handleFetchMetadata(url);
|
||||
if (updatedUrl) {
|
||||
setSpotifyUrl(updatedUrl);
|
||||
}
|
||||
}} history={fetchHistory} onHistorySelect={handleHistorySelect} onHistoryRemove={removeFromHistory} hasResult={!!metadata.metadata} searchMode={isSearchMode} onSearchModeChange={setIsSearchMode}/>
|
||||
}} history={fetchHistory} onHistorySelect={handleHistorySelect} onHistoryRemove={removeFromHistory} hasResult={!!metadata.metadata} searchMode={isSearchMode} onSearchModeChange={setIsSearchMode} region={region} onRegionChange={setRegion}/>
|
||||
|
||||
{!isSearchMode && metadata.metadata && renderMetadata()}
|
||||
</>);
|
||||
{!isSearchMode && metadata.metadata && renderMetadata()}
|
||||
</>);
|
||||
}
|
||||
};
|
||||
return (<TooltipProvider>
|
||||
<div className="min-h-screen bg-background flex flex-col">
|
||||
<TitleBar />
|
||||
<Sidebar currentPage={currentPage} onPageChange={handlePageChange}/>
|
||||
<div className="min-h-screen bg-background flex flex-col">
|
||||
<TitleBar />
|
||||
<Sidebar currentPage={currentPage} onPageChange={handlePageChange}/>
|
||||
|
||||
|
||||
<div className="flex-1 ml-14 mt-10 p-4 md:p-8">
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
{renderPage()}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 ml-14 mt-10 p-4 md:p-8">
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
{renderPage()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<DownloadProgressToast onClick={downloadQueue.openQueue}/>
|
||||
|
||||
|
||||
<DownloadQueue isOpen={downloadQueue.isOpen} onClose={downloadQueue.closeQueue}/>
|
||||
|
||||
|
||||
{showScrollTop && (<Button onClick={scrollToTop} className="fixed bottom-6 right-6 z-50 h-10 w-10 rounded-full shadow-lg" size="icon">
|
||||
<ArrowUp className="h-5 w-5"/>
|
||||
</Button>)}
|
||||
|
||||
|
||||
<Dialog open={showUnsavedChangesDialog} onOpenChange={setShowUnsavedChangesDialog}>
|
||||
<DialogContent className="sm:max-w-[425px] [&>button]:hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Unsaved Changes</DialogTitle>
|
||||
<DialogDescription>
|
||||
You have unsaved changes in Settings. Are you sure you want to leave? Your changes will be lost.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleCancelNavigation}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDiscardChanges}>
|
||||
Discard Changes
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
|
||||
<Dialog open={isFFmpegInstalled === false} onOpenChange={() => { }}>
|
||||
<DialogContent className="max-w-[450px] [&>button]:hidden p-6 gap-5">
|
||||
<DialogHeader className="space-y-2">
|
||||
<DialogTitle className="text-lg font-bold tracking-tight">
|
||||
FFmpeg Required
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-sm text-foreground/70 leading-relaxed font-normal">
|
||||
{brewPath ? (<>
|
||||
FFmpeg is essential for SpotiFLAC to function properly.
|
||||
Homebrew detected. Recommended: <span className="text-foreground font-semibold">brew install ffmpeg</span>
|
||||
</>) : (<>
|
||||
FFmpeg is essential for SpotiFLAC to function properly.
|
||||
This setup will download about <span className="text-foreground font-semibold">100-200MB</span> of data.
|
||||
</>)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{isInstallingFFmpeg && (<div className="space-y-4">
|
||||
{ffmpegInstallStatus === "extracting" ? (<div className="flex flex-col items-center justify-center py-2 animate-in fade-in duration-500">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-4 w-4 border-2 border-primary border-t-transparent rounded-full animate-spin"/>
|
||||
<span className="text-sm font-bold tracking-tight">Extracting...</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground uppercase tracking-[0.2em] font-bold mt-2">Finalizing setup</span>
|
||||
</div>) : (<div className="space-y-3">
|
||||
<div className="flex justify-between text-[11px] font-bold">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-muted-foreground uppercase tracking-wider">Downloading...</span>
|
||||
{downloadProgress.is_downloading && downloadProgress.mb_downloaded > 0 && (<span className="text-primary font-mono tabular-nums">
|
||||
{downloadProgress.mb_downloaded.toFixed(1)}MB
|
||||
{downloadProgress.speed_mbps > 0 && ` @ ${downloadProgress.speed_mbps.toFixed(1)}MB/s`}
|
||||
</span>)}
|
||||
</div>
|
||||
<span className="text-xl font-bold tracking-tighter text-primary">{ffmpegInstallProgress}%</span>
|
||||
</div>
|
||||
<div className="h-1.5 w-full bg-secondary/30 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-primary transition-all duration-300 shadow-[0_0_10px_rgba(var(--primary),0.3)]" style={{ width: `${ffmpegInstallProgress}%` }}/>
|
||||
</div>
|
||||
</div>)}
|
||||
</div>)}
|
||||
|
||||
<DialogFooter className={`flex-row gap-3 pt-2 ${brewPath ? 'flex-col' : ''}`}>
|
||||
{!isInstallingFFmpeg && (<Button variant="outline" className="flex-1 h-11 text-sm font-bold transition-colors" onClick={() => Quit()}>
|
||||
Exit
|
||||
</Button>)}
|
||||
{brewPath ? (<Button className="flex-1 h-11 text-sm font-bold shadow-lg shadow-primary/10" onClick={() => handleInstallFFmpeg(true)} disabled={isInstallingFFmpeg}>
|
||||
{isInstallingFFmpeg ? "Installing..." : "Install via Homebrew"}
|
||||
</Button>) : (<Button className={`${isInstallingFFmpeg ? 'w-full' : 'flex-1'} h-11 text-sm font-bold shadow-lg shadow-primary/10`} onClick={() => handleInstallFFmpeg(false)} disabled={isInstallingFFmpeg}>
|
||||
{isInstallingFFmpeg ? "Installing..." : "Install now"}
|
||||
</Button>)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={metadata.showApiModal} onOpenChange={metadata.setShowApiModal}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>SpotFetch API Recommended</DialogTitle>
|
||||
<DialogDescription>
|
||||
Direct fetch failed. This usually happens when your <span className="text-foreground font-bold">country is blocked</span> by Spotify or your IP is restricted. Would you like to enable the <span className="text-foreground font-bold">SpotFetch API</span> to bypass this?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => metadata.setShowApiModal(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleEnableSpotFetchApi}>
|
||||
Enable SpotFetch API
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
|
||||
<DownloadProgressToast onClick={downloadQueue.openQueue}/>
|
||||
|
||||
|
||||
<DownloadQueue isOpen={downloadQueue.isOpen} onClose={downloadQueue.closeQueue}/>
|
||||
|
||||
|
||||
{showScrollTop && (<Button onClick={scrollToTop} className="fixed bottom-6 right-6 z-50 h-10 w-10 rounded-full shadow-lg" size="icon">
|
||||
<ArrowUp className="h-5 w-5"/>
|
||||
</Button>)}
|
||||
|
||||
|
||||
<Dialog open={showUnsavedChangesDialog} onOpenChange={setShowUnsavedChangesDialog}>
|
||||
<DialogContent className="sm:max-w-[425px] [&>button]:hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Unsaved Changes</DialogTitle>
|
||||
<DialogDescription>
|
||||
You have unsaved changes in Settings. Are you sure you want to leave? Your changes will be lost.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleCancelNavigation}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDiscardChanges}>
|
||||
Discard Changes
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</TooltipProvider>);
|
||||
}
|
||||
export default App;
|
||||
|
||||
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
@@ -0,0 +1,18 @@
|
||||
export const langColors: Record<string, string> = {
|
||||
"TypeScript": "#2b7489",
|
||||
"Go": "#375eab",
|
||||
"Python": "#3572A5",
|
||||
"CSS": "#563d7c",
|
||||
"HTML": "#e44b23",
|
||||
"JavaScript": "#f1e05a",
|
||||
"Java": "#b07219",
|
||||
"C": "#555555",
|
||||
"C Sharp": "#178600",
|
||||
"cpp": "#f34b7d",
|
||||
"Ruby": "#701516",
|
||||
"PHP": "#4F5D95",
|
||||
"Swift": "#ffac45",
|
||||
"Kotlin": "#F18E33",
|
||||
"Rust": "#dea584",
|
||||
"Shell": "#89e051"
|
||||
};
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:sketch="http://www.bohemiancoding.com/sketch/ns" viewBox="0 0 512 512">
|
||||
<!-- Generator: Adobe Illustrator 29.8.3, SVG Export Plug-In . SVG Version: 2.1.1 Build 3) -->
|
||||
<defs>
|
||||
<style>
|
||||
.st0 {
|
||||
fill: #733e0a;
|
||||
}
|
||||
|
||||
.st1 {
|
||||
fill: #fdc700;
|
||||
}
|
||||
|
||||
.st2 {
|
||||
fill: #1ed760;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path class="st2" d="M384.2,203.1c-46.4-23.4-101.2-37.1-159.1-37.1s-64.9,4.4-95.2,12.7l2.6-.6c-1.8.6-3.8.9-6,.9-11,0-19.9-8.9-19.9-19.9s5.8-16.4,13.8-19h.2c77.1-22.9,204-18.5,284.4,29.3,6.1,3.8,10.2,10.4,10.2,18.1s-1,7.3-2.7,10.3h0c-4.3,5-10.4,8-17.5,8s-7.6-1-10.8-2.8h0v.1ZM381.9,263.9c-2.9,4.9-8.1,7.9-14.1,7.9s-6.2-.9-8.8-2.6h0c-39.7-22.6-87.2-35.9-137.8-35.9s-54.8,4.1-80.2,11.6l2-.5c-1.5.4-3.2.8-5,.8-9.1,0-16.5-7.3-16.5-16.5s4.9-13.6,11.4-15.7h0c26.1-7.7,56.1-12.2,87.2-12.2,57.7,0,111.9,15.5,158.6,42.4l-1.5-.9c4.4,2.8,7.1,7.5,7.1,13s-.9,6.1-2.6,8.5h.3-.1v.1ZM355.9,323.6c-2.3,3.9-6.4,6.5-11.3,6.5s-5.2-.9-7.3-2.2h0c-34.7-19.5-76.1-30.9-120.1-30.9s-49.7,3.8-72.7,10.8l1.8-.4c-.9.3-2.1.4-3.2.4-7.3,0-13.4-6.1-13.4-13.4s4.4-11.5,10.1-13h0c22.9-6.7,49.2-10.7,76.4-10.7,49.3,0,95.5,12.8,135.6,35.4l-1.5-.8c4.4,2.2,7.3,6.6,7.3,11.7s-.7,4.8-1.9,6.6h.2,0ZM256,10h0c-119.9,0-217.1,97.2-217.1,217.1s97.2,217.1,217.1,217.1,217.1-97.2,217.1-217.1h0c-.3-119.7-97.3-216.7-217.1-217.1h0Z"/>
|
||||
<path class="st1" d="M53.9,351h398.8c11.2,0,20.4,9,20.4,20.1v110.8c0,11.1-9.1,20.1-20.4,20.1H53.9c-11.2,0-20.4-9-20.4-20.1v-110.8c0-11.1,9.1-20.1,20.4-20.1h0Z"/>
|
||||
<g>
|
||||
<path class="st0" d="M70.76,465.35v-77.71h23.4l50.61,59.75-5.66,1.31v-61.06h18.83v77.71h-23.51l-49.52-58.45,4.57-1.74v60.19h-18.72Z"/>
|
||||
<path class="st0" d="M171.65,465.35v-77.71h76.51v15.78h-55.73v15.24h51.48v15.13h-51.48v15.78h55.73v15.78h-76.51Z"/>
|
||||
<path class="st0" d="M254.8,465.35l41.47-45.17-2.39,9.25-37.33-41.79h26.34l28.08,32.65-13.17-.44,29.17-32.22h23.51l-39.07,42.01.65-8.82,39.72,44.51h-26.23l-29.82-34.72,14.26-.65-31.56,35.37h-23.62Z"/>
|
||||
<path class="st0" d="M387.8,465.35v-62.04h-32.76v-15.67h86.2v15.67h-32.65v62.04h-20.79Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:sketch="http://www.bohemiancoding.com/sketch/ns" viewBox="0 0 512 512">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #2dc261;
|
||||
fill-rule: evenodd;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<!-- Generator: Adobe Illustrator 28.7.2, SVG Export Plug-In . SVG Version: 1.2.0 Build 154) -->
|
||||
<g>
|
||||
<g id="Layer_1">
|
||||
<g id="SVGRepo_iconCarrier">
|
||||
<g id="Page-1" sketch:type="MSPage">
|
||||
<g id="Icon-Set-Filled" sketch:type="MSLayerGroup">
|
||||
<path id="arrow-right-circle" class="cls-1" d="M350.1,268.7l-81.5,81.5c-5.6,5.6-14.7,5.6-20.4,0-5.6-5.6-5.6-14.8,0-20.5l59.4-59.3h-152.5c-8,0-14.4-6.5-14.4-14.4s6.4-14.4,14.4-14.4h152.5l-59.4-59.3c-5.6-5.6-5.6-14.7,0-20.5,5.6-5.6,14.7-5.6,20.4,0l81.5,81.5c3.5,3.5,4.5,8.2,3.7,12.7.8,4.5-.3,9.2-3.7,12.7h0ZM256,25.6c-127.3,0-230.4,103.1-230.4,230.4s103.2,230.4,230.4,230.4,230.4-103.1,230.4-230.4S383.3,25.6,256,25.6h0Z" sketch:type="MSShapeGroup"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,39 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:sketch="http://www.bohemiancoding.com/sketch/ns" viewBox="0 0 512 512">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #733e0a;
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: #fdc700;
|
||||
}
|
||||
|
||||
.cls-3 {
|
||||
fill: #1ed760;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<!-- Generator: Adobe Illustrator 28.7.2, SVG Export Plug-In . SVG Version: 1.2.0 Build 154) -->
|
||||
<g>
|
||||
<g id="Layer_1">
|
||||
<g>
|
||||
<g id="_1818452274576">
|
||||
<g id="SVGRepo_iconCarrier">
|
||||
<path class="cls-3" d="M384.2,203.1c-46.4-23.4-101.2-37.1-159.1-37.1s-64.9,4.4-95.2,12.7l2.6-.6c-1.8.6-3.8.9-6,.9-11,0-19.9-8.9-19.9-19.9s5.8-16.4,13.8-19h.2c77.1-22.9,204-18.5,284.4,29.3,6.1,3.8,10.2,10.4,10.2,18.1s-1,7.3-2.7,10.3h0c-4.3,5-10.4,8-17.5,8s-7.6-1-10.8-2.8h0ZM381.9,263.9c-2.9,4.9-8.1,7.9-14.1,7.9s-6.2-.9-8.8-2.6h0c-39.7-22.6-87.2-35.9-137.8-35.9s-54.8,4.1-80.2,11.6l2-.5c-1.5.4-3.2.8-5,.8-9.1,0-16.5-7.3-16.5-16.5s4.9-13.6,11.4-15.7h0c26.1-7.7,56.1-12.2,87.2-12.2,57.7,0,111.9,15.5,158.6,42.4l-1.5-.9c4.4,2.8,7.1,7.5,7.1,13s-.9,6.1-2.6,8.5h.3-.1ZM355.9,323.6c-2.3,3.9-6.4,6.5-11.3,6.5s-5.2-.9-7.3-2.2h0c-34.7-19.5-76.1-30.9-120.1-30.9s-49.7,3.8-72.7,10.8l1.8-.4c-.9.3-2.1.4-3.2.4-7.3,0-13.4-6.1-13.4-13.4s4.4-11.5,10.1-13h0c22.9-6.7,49.2-10.7,76.4-10.7,49.3,0,95.5,12.8,135.6,35.4l-1.5-.8c4.4,2.2,7.3,6.6,7.3,11.7s-.7,4.8-1.9,6.6h.2,0ZM256,10h0c-119.9,0-217.1,97.2-217.1,217.1s97.2,217.1,217.1,217.1,217.1-97.2,217.1-217.1h0c-.3-119.7-97.3-216.7-217.1-217.1h0Z"/>
|
||||
</g>
|
||||
</g>
|
||||
<path class="cls-2" d="M53.9,351h398.8c11.2,0,20.4,9,20.4,20.1v110.8c0,11.1-9.1,20.1-20.4,20.1H53.9c-11.2,0-20.4-9-20.4-20.1v-110.8c0-11.1,9.1-20.1,20.4-20.1Z"/>
|
||||
<g>
|
||||
<path class="cls-1" d="M113.6,479.3c-2.4,0-4.4-.8-5.9-2.3-1.5-1.5-2.3-3.5-2.3-5.8v-89.3c0-2.4.8-4.3,2.3-5.9,1.5-1.5,3.5-2.3,5.9-2.3s4.3.8,5.8,2.3c1.5,1.5,2.3,3.5,2.3,5.9v35h17.5v-35c0-2.4.8-4.3,2.3-5.9,1.5-1.5,3.5-2.3,5.9-2.3s4.3.8,5.8,2.3c1.5,1.5,2.3,3.5,2.3,5.9v89.3c0,2.4-.7,4.4-2.2,5.9-1.5,1.5-3.5,2.2-5.9,2.2s-4.4-.8-5.9-2.3c-1.5-1.5-2.3-3.5-2.3-5.8v-39.5h-17.5v39.5c0,2.4-.8,4.4-2.3,5.9-1.5,1.5-3.5,2.2-5.8,2.2Z"/>
|
||||
<path class="cls-1" d="M175.9,479.3c-2.4,0-4.4-.8-5.9-2.3-1.5-1.5-2.3-3.5-2.3-5.8v-89.3c0-2.4.8-4.3,2.3-5.9,1.5-1.5,3.5-2.3,5.9-2.3s4.3.8,5.8,2.3c1.5,1.5,2.3,3.5,2.3,5.9v89.3c0,2.4-.8,4.4-2.3,5.9-1.5,1.5-3.5,2.2-5.8,2.2Z"/>
|
||||
<path class="cls-1" d="M200.4,434c-2,0-3.7-.7-5.2-2.2-1.5-1.4-2.2-3.2-2.2-5.3s.7-3.8,2.2-5.2c1.4-1.4,3.2-2.2,5.2-2.2h19.5c2,0,3.8.7,5.2,2.2s2.2,3.2,2.2,5.2-.7,3.8-2.2,5.3-3.2,2.2-5.2,2.2h-19.5Z"/>
|
||||
<path class="cls-1" d="M250.3,477.2c-1.4,1.4-3.4,2.1-6,2.1s-4.6-.7-6-2.1c-1.4-1.4-2.1-3.4-2.1-6v-88.4c0-2.6.7-4.6,2.1-6,1.4-1.4,3.4-2.1,6-2.1h16c8.4,0,14.5,2,18.4,5.9,3.9,3.9,5.8,9.9,5.8,18v6.4c0,10.7-3.6,17.6-10.7,20.5v.3c3.9,1.2,6.7,3.6,8.4,7.2s2.5,8.5,2.5,14.7v16.1c0,2.5.2,4.5.6,6.1.4,1.5.6,2.8.6,4.1,0,3.6-2.6,5.4-7.7,5.4s-5.9-1-7.5-2.9c-1.6-2-2.4-5.2-2.4-9.8v-19.8c0-4.8-.8-8.1-2.3-10-1.5-1.9-4.2-2.8-7.9-2.8h-5.6v37.2c0,2.6-.7,4.6-2.1,6ZM252.4,419.1h5.9c3.2,0,5.7-.8,7.3-2.5s2.5-4.5,2.5-8.5v-8c0-3.7-.7-6.4-2-8.1s-3.4-2.6-6.3-2.6h-7.5v29.7Z"/>
|
||||
<path class="cls-1" d="M304,478.4c-2.4,0-4.3-.8-5.9-2.3-1.5-1.5-2.3-3.5-2.3-5.9v-87.5c0-2.4.8-4.3,2.3-5.9s3.5-2.3,5.9-2.3h29.8c2.1,0,3.9.7,5.3,2.1,1.4,1.4,2.1,3.2,2.1,5.3s-.7,3.9-2.1,5.3c-1.4,1.4-3.2,2.1-5.3,2.1h-21.7v27.4h15.9c2.1,0,3.9.7,5.3,2.1,1.4,1.4,2.1,3.2,2.1,5.3s-.7,3.9-2.1,5.3c-1.4,1.4-3.2,2.1-5.3,2.1h-15.9v31.9h21.7c2.1,0,3.9.7,5.3,2.1,1.4,1.4,2.1,3.2,2.1,5.3s-.7,3.9-2.1,5.3c-1.4,1.4-3.2,2.1-5.3,2.1h-29.8Z"/>
|
||||
<path class="cls-1" d="M371.2,479.9c-7.9,0-13.8-2.2-17.9-6.6-4.1-4.4-6.1-10.6-6.1-18.6s.7-4.3,2-5.7c1.4-1.4,3.3-2.1,5.7-2.1s4.2.7,5.6,2c1.4,1.3,2.1,3.4,2.1,6.2,0,6.8,2.8,10.1,8.5,10.1s8.5-3.5,8.5-10.4-1-8.1-3-11.4c-2-3.3-5.6-7.3-11-12-6.8-5.9-11.5-11.3-14.1-16.1-2.7-4.8-4-10.2-4-16.2s2.1-14.6,6.3-19c4.2-4.5,10.2-6.7,18.1-6.7s13.5,2.2,17.6,6.6c4.1,4.4,6.2,10.1,6.2,17s-2.6,7.8-7.7,7.8-4.4-.7-5.7-2.2-2-3.3-2-5.6-.7-4.9-2.1-6.4c-1.4-1.5-3.4-2.3-6-2.3-5.5,0-8.2,3.3-8.2,9.9s1,7.3,3.1,10.5c2.1,3.2,5.7,7.2,11,11.9,6.8,6,11.4,11.4,14,16.3,2.6,4.9,3.9,10.5,3.9,17s-2.1,15-6.3,19.5c-4.2,4.6-10.3,6.8-18.3,6.8Z"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#00bc7d" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-music-down"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M3 17a3 3 0 1 0 6 0a3 3 0 0 0 -6 0" /><path d="M9 17v-13h10v8" /><path d="M9 8h10" /><path d="M19 16v6" /><path d="M22 19l-3 3l-3 -3" /></svg>
|
||||
|
After Width: | Height: | Size: 448 B |
|
After Width: | Height: | Size: 345 B |
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1"
|
||||
id="Layer_1" width="512px" height="512px" viewBox="0 0 24 24" style="enable-background:new 0 0 24 24;"
|
||||
xml:space="preserve">
|
||||
<g fill="#1da0f1">
|
||||
<polygon
|
||||
points="12.153992,10.729553 8.088684,5.041199 5.92041,5.041199 10.956299,12.087097 11.59021,12.97345 15.900635,19.009583 18.068909,19.009583 12.785217,11.615906 " />
|
||||
<path
|
||||
d="M21.15979,1H2.84021C1.823853,1,1,1.823853,1,2.84021v18.31958C1,22.176147,1.823853,23,2.84021,23h18.31958 C22.176147,23,23,22.176147,23,21.15979V2.84021C23,1.823853,22.176147,1,21.15979,1z M15.235352,20l-4.362549-6.213013 L5.411438,20H4l6.246887-7.104675L4,4h4.764648l4.130127,5.881958L18.06958,4h1.411377l-5.95697,6.775635L20,20H15.235352z" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 865 B |
|
After Width: | Height: | Size: 903 KiB |
@@ -0,0 +1,13 @@
|
||||
<svg width="241" height="194" viewBox="0 0 241 194" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<mask id="mask0_1_219" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="-1" y="0" width="242" height="194">
|
||||
<path d="M240.469 0.958984H-0.00585938V193.918H240.469V0.958984Z" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_1_219)">
|
||||
<path d="M96.1344 193.911C61.1312 193.911 32.6597 178.256 15.9721 149.829C1.19788 124.912 -0.00585938 97.9229 -0.00585938 67.7662C-0.00585938 49.8876 5.37293 34.3215 15.5413 22.7466C24.8861 12.1157 38.1271 5.22907 52.8317 3.35378C70.2858 1.14271 91.9848 0.958984 114.545 0.958984C151.259 0.958984 161.63 1.4088 176.075 2.85328C195.29 4.76026 211.458 11.932 222.824 23.5955C234.368 35.4428 240.469 51.2624 240.469 69.3627V72.9994C240.469 103.885 219.821 129.733 191.046 136.759C188.898 141.827 186.237 146.871 183.089 151.837L183.006 151.964C172.869 167.632 149.042 193.918 103.401 193.918H96.1281L96.1344 193.911Z" fill="white"/>
|
||||
<path d="M174.568 17.9772C160.927 16.6151 151.38 16.1589 114.552 16.1589C90.908 16.1589 70.9008 16.387 54.7644 18.4334C33.3949 21.164 15.2058 37.5285 15.2058 67.7674C15.2058 98.0066 16.796 121.422 29.0741 142.107C42.9425 165.751 66.1302 178.707 96.1412 178.707H103.414C140.242 178.707 160.25 159.156 170.253 143.698C174.574 136.874 177.754 130.058 179.801 123.234C205.947 120.96 225.27 99.3624 225.27 72.9941V69.3577C225.27 40.9432 206.631 21.164 174.574 17.9772H174.568Z" fill="white"/>
|
||||
<path d="M15.1975 67.7674C15.1975 37.5285 33.3866 21.164 54.7559 18.4334C70.8987 16.387 90.906 16.1589 114.544 16.1589C151.372 16.1589 160.919 16.6151 174.559 17.9772C206.617 21.1576 225.255 40.937 225.255 69.3577V72.9941C225.255 99.3687 205.932 120.966 179.786 123.234C177.74 130.058 174.559 136.874 170.238 143.698C160.235 159.156 140.228 178.707 103.4 178.707H96.1264C66.1155 178.707 42.9277 165.751 29.0595 142.107C16.7814 121.422 15.1912 98.4563 15.1912 67.7674" fill="#202020"/>
|
||||
<path d="M32.2469 67.9899C32.2469 97.3168 34.0654 116.184 43.6127 133.689C54.5225 153.924 74.3018 161.653 96.8117 161.653H103.857C133.411 161.653 147.736 147.329 155.693 134.829C159.558 128.462 162.966 121.417 164.784 112.547L166.147 106.864H174.332C192.521 106.864 208.208 92.09 208.208 73.2166V69.8082C208.208 48.6669 195.024 37.5228 172.058 34.7987C159.102 33.6646 151.372 33.2084 114.538 33.2084C89.7602 33.2084 72.0272 33.4364 58.6152 35.4828C39.7483 38.2134 32.2407 48.8951 32.2407 67.9899" fill="white"/>
|
||||
<path d="M166.158 83.6801C166.158 86.4107 168.204 88.4572 171.841 88.4572C183.435 88.4572 189.802 81.8619 189.802 70.9523C189.802 60.0427 183.435 53.2195 171.841 53.2195C168.204 53.2195 166.158 55.2657 166.158 57.9963V83.6866V83.6801Z" fill="#202020"/>
|
||||
<path d="M54.5321 82.3198C54.5321 95.732 62.0332 107.326 71.5807 116.424C77.9478 122.562 87.9515 128.93 94.7685 133.022C96.8147 134.157 98.8611 134.841 101.136 134.841C103.866 134.841 106.134 134.157 107.959 133.022C114.782 128.93 124.779 122.562 130.919 116.424C140.694 107.332 148.195 95.7383 148.195 82.3198C148.195 67.7673 137.286 54.8115 121.599 54.8115C112.28 54.8115 105.912 59.5882 101.136 66.1772C96.8147 59.582 90.2259 54.8115 80.9001 54.8115C64.9855 54.8115 54.5256 67.7673 54.5256 82.3198" fill="#FF5A16"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 18 KiB |
@@ -0,0 +1,446 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { openExternal } from "@/lib/utils";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@/components/ui/card";
|
||||
import { Star, GitFork, Clock, Download, Blocks, Heart, Copy, CircleCheck, Info } from "lucide-react";
|
||||
import AudioTTSProIcon from "@/assets/audiotts-pro.webp";
|
||||
import ChatGPTTTSIcon from "@/assets/chatgpt-tts.webp";
|
||||
import XIcon from "@/assets/x.webp";
|
||||
import XProIcon from "@/assets/x-pro.webp";
|
||||
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 KofiLogo from "@/assets/ko-fi.gif";
|
||||
import KofiSvg from "@/assets/kofi_symbol.svg";
|
||||
import UsdtBarcode from "@/assets/usdt.jpg";
|
||||
import { langColors } from "@/assets/github-lang-colors";
|
||||
const browserExtensionItems = [
|
||||
{ icon: AudioTTSProIcon, label: "AudioTTS Pro", alt: "AudioTTS Pro" },
|
||||
{ icon: ChatGPTTTSIcon, label: "ChatGPT TTS", alt: "ChatGPT TTS" },
|
||||
{ icon: XIcon, label: "Twitter/X Media Batch Downloader", alt: "Twitter/X Media Batch Downloader" },
|
||||
{ icon: XProIcon, label: "Twitter/X Media Batch Downloader Pro", alt: "Twitter/X Media Batch Downloader Pro" },
|
||||
];
|
||||
const projectCardClass = "cursor-pointer transition-colors hover:bg-muted/50 dark:hover:bg-accent/50";
|
||||
export function AboutPage() {
|
||||
const [activeTab, setActiveTab] = useState<"projects" | "support">("projects");
|
||||
const [repoStats, setRepoStats] = useState<Record<string, any>>({});
|
||||
const [copiedUsdt, setCopiedUsdt] = useState(false);
|
||||
useEffect(() => {
|
||||
const fetchRepoStats = async () => {
|
||||
const CACHE_KEY = "github_repo_stats_v4";
|
||||
const CACHE_DURATION = 1000 * 60 * 60;
|
||||
const cached = localStorage.getItem(CACHE_KEY);
|
||||
if (cached) {
|
||||
try {
|
||||
const { data, timestamp } = JSON.parse(cached);
|
||||
if (Date.now() - timestamp < CACHE_DURATION) {
|
||||
setRepoStats(data);
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
console.error("Failed to parse cache:", err);
|
||||
}
|
||||
}
|
||||
const repos = [
|
||||
{ name: "SpotiDownloader", owner: "afkarxyz" },
|
||||
{ name: "SpotiFLAC-Next", owner: "spotiverse" },
|
||||
{ name: "Twitter-X-Media-Batch-Downloader", owner: "afkarxyz" },
|
||||
];
|
||||
const stats: Record<string, any> = {};
|
||||
for (const repo of repos) {
|
||||
try {
|
||||
const [repoRes, releasesRes, langsRes] = await Promise.all([
|
||||
fetch(`https://api.github.com/repos/${repo.owner}/${repo.name}`),
|
||||
fetch(`https://api.github.com/repos/${repo.owner}/${repo.name}/releases`),
|
||||
fetch(`https://api.github.com/repos/${repo.owner}/${repo.name}/languages`),
|
||||
]);
|
||||
if (repoRes.status === 403) {
|
||||
if (cached) {
|
||||
const { data } = JSON.parse(cached);
|
||||
setRepoStats(data);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (repoRes.ok) {
|
||||
const repoData = await repoRes.json();
|
||||
const releases = releasesRes.ok ? await releasesRes.json() : [];
|
||||
const languages = langsRes.ok ? await langsRes.json() : {};
|
||||
let totalDownloads = 0;
|
||||
let latestDownloads = 0;
|
||||
let latestVersion = "";
|
||||
let latestReleaseAt = "";
|
||||
if (releases.length > 0) {
|
||||
latestVersion = releases[0].tag_name || "";
|
||||
latestReleaseAt = releases[0].published_at || releases[0].created_at || "";
|
||||
latestDownloads =
|
||||
releases[0].assets?.reduce((sum: number, asset: any) => sum + (asset.download_count || 0), 0) || 0;
|
||||
totalDownloads = releases.reduce((sum: number, release: any) => {
|
||||
return (sum +
|
||||
(release.assets?.reduce((s: number, a: any) => s + (a.download_count || 0), 0) || 0));
|
||||
}, 0);
|
||||
}
|
||||
const topLangs = Object.entries(languages)
|
||||
.sort(([, a]: any, [, b]: any) => b - a)
|
||||
.slice(0, 4)
|
||||
.map(([lang]) => lang);
|
||||
stats[repo.name] = {
|
||||
stars: repoData.stargazers_count,
|
||||
forks: repoData.forks_count,
|
||||
createdAt: repoData.created_at,
|
||||
description: repoData.description,
|
||||
totalDownloads,
|
||||
latestDownloads,
|
||||
latestVersion,
|
||||
latestReleaseAt,
|
||||
languages: topLangs,
|
||||
};
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
console.error(`Failed to fetch stats for ${repo.name}:`, err);
|
||||
if (cached) {
|
||||
const { data } = JSON.parse(cached);
|
||||
setRepoStats(data);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
setRepoStats(stats);
|
||||
localStorage.setItem(CACHE_KEY, JSON.stringify({ data: stats, timestamp: Date.now() }));
|
||||
};
|
||||
fetchRepoStats();
|
||||
}, []);
|
||||
const formatTimeAgo = (dateString: string): string => {
|
||||
const now = new Date();
|
||||
const updated = new Date(dateString);
|
||||
const diffMs = now.getTime() - updated.getTime();
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
const diffMonths = Math.floor(diffDays / 30);
|
||||
if (diffDays === 0)
|
||||
return "today";
|
||||
if (diffDays === 1)
|
||||
return "1d";
|
||||
if (diffDays < 30)
|
||||
return `${diffDays}d`;
|
||||
if (diffMonths === 1)
|
||||
return "1mo";
|
||||
if (diffMonths < 12)
|
||||
return `${diffMonths}mo`;
|
||||
const diffYears = Math.floor(diffMonths / 12);
|
||||
return `${diffYears}y`;
|
||||
};
|
||||
const formatReleaseTimeAgo = (dateString: string): string => {
|
||||
if (!dateString) {
|
||||
return "";
|
||||
}
|
||||
const now = Date.now();
|
||||
const releasedAt = new Date(dateString).getTime();
|
||||
if (Number.isNaN(releasedAt)) {
|
||||
return "";
|
||||
}
|
||||
const diffMs = Math.max(0, now - releasedAt);
|
||||
const totalMinutes = Math.floor(diffMs / (1000 * 60));
|
||||
const totalHours = Math.floor(totalMinutes / 60);
|
||||
const totalDays = Math.floor(totalHours / 24);
|
||||
const totalMonths = Math.floor(totalDays / 30);
|
||||
const totalYears = Math.floor(totalMonths / 12);
|
||||
if (totalYears > 0) {
|
||||
const remainingMonths = totalMonths % 12;
|
||||
return remainingMonths > 0 ? `${totalYears}y ${remainingMonths}m ago` : `${totalYears}y ago`;
|
||||
}
|
||||
if (totalMonths > 0) {
|
||||
const remainingDays = totalDays % 30;
|
||||
return remainingDays > 0 ? `${totalMonths}m ${remainingDays}d ago` : `${totalMonths}m ago`;
|
||||
}
|
||||
if (totalDays > 0) {
|
||||
const remainingHours = totalHours % 24;
|
||||
return remainingHours > 0 ? `${totalDays}d ${remainingHours}h ago` : `${totalDays}d ago`;
|
||||
}
|
||||
if (totalHours > 0) {
|
||||
const remainingMinutes = totalMinutes % 60;
|
||||
return `${totalHours}h ${remainingMinutes}m ago`;
|
||||
}
|
||||
return `${totalMinutes}m ago`;
|
||||
};
|
||||
const formatNumber = (num: number): string => {
|
||||
if (num >= 1000) {
|
||||
return num.toLocaleString();
|
||||
}
|
||||
return num.toString();
|
||||
};
|
||||
const getLangColor = (lang: string): string => {
|
||||
return langColors[lang] || "#858585";
|
||||
};
|
||||
const getRepoDescription = (repoName: string): string => {
|
||||
return repoStats[repoName]?.description || "";
|
||||
};
|
||||
return (<div className="flex flex-col space-y-4">
|
||||
<div className="flex items-center justify-between shrink-0">
|
||||
<h2 className="text-2xl font-bold tracking-tight">About</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 border-b shrink-0">
|
||||
<Button variant={activeTab === "projects" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("projects")} className="rounded-b-none">
|
||||
<Blocks className="h-4 w-4"/>
|
||||
Other Projects
|
||||
</Button>
|
||||
<Button variant={activeTab === "support" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("support")} className="rounded-b-none">
|
||||
<Heart className="h-4 w-4"/>
|
||||
Support Me
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0">
|
||||
|
||||
|
||||
{activeTab === "projects" && (<div className="p-1 pr-2">
|
||||
<div className="grid gap-2 grid-cols-4">
|
||||
<Card className={`gap-2 ${projectCardClass}`} onClick={() => openExternal("https://github.com/spotiverse/SpotiFLAC-Next")}>
|
||||
<CardHeader>
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<img src={SpotiFLACNextIcon} className="h-6 w-6 shrink-0" alt="SpotiFLAC Next"/>
|
||||
<div className="flex items-center gap-2">
|
||||
{repoStats["SpotiFLAC-Next"]?.latestReleaseAt && (<span className="text-[10px] text-muted-foreground whitespace-nowrap">
|
||||
{formatReleaseTimeAgo(repoStats["SpotiFLAC-Next"].latestReleaseAt)}
|
||||
</span>)}
|
||||
{repoStats["SpotiFLAC-Next"]?.latestVersion && (<span className="text-[10px] bg-primary text-primary-foreground px-1.5 py-0.5 rounded-sm font-mono font-semibold max-w-[80px] truncate">
|
||||
{repoStats["SpotiFLAC-Next"].latestVersion}
|
||||
</span>)}
|
||||
</div>
|
||||
</div>
|
||||
<CardTitle className="leading-tight">
|
||||
SpotiFLAC Next
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{getRepoDescription("SpotiFLAC-Next")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
{repoStats["SpotiFLAC-Next"] && (<CardContent className="space-y-2">
|
||||
{repoStats["SpotiFLAC-Next"].languages?.length > 0 && (<div className="flex flex-wrap gap-2 text-xs">
|
||||
{repoStats["SpotiFLAC-Next"].languages.map((lang: string) => (<span key={lang} className="px-2 py-0.5 rounded-full font-medium" style={{
|
||||
backgroundColor: getLangColor(lang) + "20",
|
||||
color: getLangColor(lang),
|
||||
}}>
|
||||
{lang}
|
||||
</span>))}
|
||||
</div>)}
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Star className="h-3.5 w-3.5 fill-amber-500 text-amber-500"/>{" "}
|
||||
{formatNumber(repoStats["SpotiFLAC-Next"].stars)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<GitFork className="h-3.5 w-3.5"/>{" "}
|
||||
{repoStats["SpotiFLAC-Next"].forks}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3.5 w-3.5"/>{" "}
|
||||
{formatTimeAgo(repoStats["SpotiFLAC-Next"].createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="rounded-md border border-sky-500/25 bg-sky-500/8 px-3 py-2">
|
||||
<div className="mb-1 flex items-center gap-1.5 text-xs font-semibold text-sky-700 dark:text-sky-300">
|
||||
<Info className="h-3.5 w-3.5"/>
|
||||
Note
|
||||
</div>
|
||||
<p className="text-xs leading-relaxed text-sky-700 dark:text-sky-300">
|
||||
This project was created as a thank-you to everyone who has supported SpotiFLAC on Ko-fi.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>)}
|
||||
</Card>
|
||||
<Card className={projectCardClass} onClick={() => openExternal("https://github.com/afkarxyz/SpotiDownloader")}>
|
||||
<CardHeader>
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<img src={SpotiDownloaderIcon} className="h-6 w-6 shrink-0" alt="SpotiDownloader"/>
|
||||
<div className="flex items-center gap-2">
|
||||
{repoStats["SpotiDownloader"]?.latestReleaseAt && (<span className="text-[10px] text-muted-foreground whitespace-nowrap">
|
||||
{formatReleaseTimeAgo(repoStats["SpotiDownloader"].latestReleaseAt)}
|
||||
</span>)}
|
||||
{repoStats["SpotiDownloader"]?.latestVersion && (<span className="text-[10px] bg-primary text-primary-foreground px-1.5 py-0.5 rounded-sm font-mono font-semibold max-w-[80px] truncate">
|
||||
{repoStats["SpotiDownloader"].latestVersion}
|
||||
</span>)}
|
||||
</div>
|
||||
</div>
|
||||
<CardTitle className="leading-tight">
|
||||
SpotiDownloader
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{getRepoDescription("SpotiDownloader")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
{repoStats["SpotiDownloader"] && (<CardContent className="space-y-3">
|
||||
<div className="flex flex-wrap gap-2 text-xs">
|
||||
{repoStats["SpotiDownloader"].languages?.map((lang: string) => (<span key={lang} className="px-2 py-0.5 rounded-full font-medium" style={{
|
||||
backgroundColor: getLangColor(lang) + "20",
|
||||
color: getLangColor(lang),
|
||||
}}>
|
||||
{lang}
|
||||
</span>))}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Star className="h-3.5 w-3.5 fill-amber-500 text-amber-500"/>{" "}
|
||||
{formatNumber(repoStats["SpotiDownloader"].stars)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<GitFork className="h-3.5 w-3.5"/>{" "}
|
||||
{repoStats["SpotiDownloader"].forks}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3.5 w-3.5"/>{" "}
|
||||
{formatTimeAgo(repoStats["SpotiDownloader"].createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 text-xs text-muted-foreground items-start">
|
||||
<span className="flex items-center gap-1">
|
||||
<Download className="h-3.5 w-3.5"/> TOTAL:{" "}
|
||||
{formatNumber(repoStats["SpotiDownloader"].totalDownloads)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-green-600 dark:text-green-400">
|
||||
<Download className="h-3.5 w-3.5"/> LATEST:{" "}
|
||||
{formatNumber(repoStats["SpotiDownloader"].latestDownloads)}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>)}
|
||||
</Card>
|
||||
<Card className={projectCardClass} onClick={() => openExternal("https://github.com/afkarxyz/Twitter-X-Media-Batch-Downloader")}>
|
||||
<CardHeader>
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<img src={XBatchDLIcon} className="h-6 w-6 shrink-0" alt="Twitter/X Media Batch Downloader"/>
|
||||
<div className="flex items-center gap-2">
|
||||
{repoStats["Twitter-X-Media-Batch-Downloader"]?.latestReleaseAt && (<span className="text-[10px] text-muted-foreground whitespace-nowrap">
|
||||
{formatReleaseTimeAgo(repoStats["Twitter-X-Media-Batch-Downloader"].latestReleaseAt)}
|
||||
</span>)}
|
||||
{repoStats["Twitter-X-Media-Batch-Downloader"]?.latestVersion && (<span className="text-[10px] bg-primary text-primary-foreground px-1.5 py-0.5 rounded-sm font-mono font-semibold max-w-[80px] truncate">
|
||||
{repoStats["Twitter-X-Media-Batch-Downloader"].latestVersion}
|
||||
</span>)}
|
||||
</div>
|
||||
</div>
|
||||
<CardTitle className="leading-tight">
|
||||
Twitter/X Media Batch Downloader
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{getRepoDescription("Twitter-X-Media-Batch-Downloader")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
{repoStats["Twitter-X-Media-Batch-Downloader"] && (<CardContent className="space-y-3">
|
||||
<div className="flex flex-wrap gap-2 text-xs">
|
||||
{repoStats["Twitter-X-Media-Batch-Downloader"].languages?.map((lang: string) => (<span key={lang} className="px-2 py-0.5 rounded-full font-medium" style={{
|
||||
backgroundColor: getLangColor(lang) + "20",
|
||||
color: getLangColor(lang),
|
||||
}}>
|
||||
{lang}
|
||||
</span>))}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Star className="h-3.5 w-3.5 fill-amber-500 text-amber-500"/>{" "}
|
||||
{formatNumber(repoStats["Twitter-X-Media-Batch-Downloader"].stars)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<GitFork className="h-3.5 w-3.5"/>{" "}
|
||||
{repoStats["Twitter-X-Media-Batch-Downloader"].forks}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3.5 w-3.5"/>{" "}
|
||||
{formatTimeAgo(repoStats["Twitter-X-Media-Batch-Downloader"]
|
||||
.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 text-xs text-muted-foreground items-start">
|
||||
<span className="flex items-center gap-1">
|
||||
<Download className="h-3.5 w-3.5"/> TOTAL:{" "}
|
||||
{formatNumber(repoStats["Twitter-X-Media-Batch-Downloader"]
|
||||
.totalDownloads)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-green-600 dark:text-green-400">
|
||||
<Download className="h-3.5 w-3.5"/> LATEST:{" "}
|
||||
{formatNumber(repoStats["Twitter-X-Media-Batch-Downloader"]
|
||||
.latestDownloads)}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>)}
|
||||
</Card>
|
||||
<div className="flex flex-col gap-2 h-full">
|
||||
<Card className={`${projectCardClass} flex-1`} onClick={() => openExternal("https://exyezed.qzz.io/")}>
|
||||
<CardHeader>
|
||||
<CardTitle>Browser Extensions & Scripts</CardTitle>
|
||||
<CardDescription className="flex flex-col gap-2 pt-2">
|
||||
{browserExtensionItems.map((item) => (<div key={item.alt} className="flex items-center gap-2">
|
||||
<img src={item.icon} className="h-[22px] w-[22px] rounded-sm shadow-sm" alt={item.alt}/>
|
||||
<span className="text-[11px] leading-tight text-muted-foreground">
|
||||
{item.label}
|
||||
</span>
|
||||
</div>))}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<Card className={`${projectCardClass} flex-1`} onClick={() => openExternal("https://spotubedl.com/")}>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<img src={SpotubeDLIcon} className="h-5 w-5" alt="SpotubeDL"/>{" "}
|
||||
SpotubeDL
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Download Spotify Tracks, Albums, Playlists as MP3/OGG/Opus
|
||||
with High Quality.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>)}
|
||||
|
||||
{activeTab === "support" && (<div className="flex flex-col items-center justify-center p-4 space-y-6">
|
||||
<div className="flex flex-col md:flex-row w-full max-w-3xl bg-card rounded-xl border shadow-sm">
|
||||
|
||||
<div className="flex-1 p-6 flex flex-col items-center justify-between border-b md:border-b-0 md:border-r space-y-6">
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<div className="h-32 flex items-center justify-center w-full relative">
|
||||
<img src={KofiLogo} className="w-72 absolute pointer-events-none" alt="Ko-fi"/>
|
||||
</div>
|
||||
<h4 className="font-semibold text-foreground">Support via Ko-fi</h4>
|
||||
<p className="text-sm text-muted-foreground text-center px-4">
|
||||
Enjoying the project? You can support ongoing development by buying me a coffee.
|
||||
</p>
|
||||
</div>
|
||||
<Button className="h-10 w-full text-sm font-semibold text-white gap-2 group bg-[#72a4f2] hover:bg-[#5f8cd6]" onClick={() => openExternal("https://ko-fi.com/afkarxyz")}>
|
||||
<img src={KofiSvg} className="w-5 h-5 shrink-0" alt="" aria-hidden="true"/>
|
||||
Support me on Ko-fi
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex-1 p-6 flex flex-col items-center justify-between space-y-6">
|
||||
<div className="flex flex-col items-center space-y-4 w-full">
|
||||
<div className="h-32 flex items-center justify-center">
|
||||
<div className="p-2 bg-white rounded-xl shadow-sm border">
|
||||
<img src={UsdtBarcode} className="w-24 h-24 object-contain" alt="USDT Barcode"/>
|
||||
</div>
|
||||
</div>
|
||||
<h4 className="font-semibold text-foreground">USDT (TRC20)</h4>
|
||||
<p className="text-sm text-muted-foreground text-center px-4">
|
||||
Crypto donations are also accepted. Scan the QR code or copy the address.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 bg-muted/50 pl-3 pr-1.5 py-1.5 rounded-lg border w-full justify-between h-10">
|
||||
<code className="text-xs font-mono text-muted-foreground truncate" title="THnzAAwZgp2Sq5CAXLP2njQDhTvgZG9EWs">
|
||||
THnzAAwZgp2Sq5CAXLP2njQDhTvgZG9EWs
|
||||
</code>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0 hover:bg-background" onClick={() => {
|
||||
navigator.clipboard.writeText("THnzAAwZgp2Sq5CAXLP2njQDhTvgZG9EWs");
|
||||
setCopiedUsdt(true);
|
||||
setTimeout(() => setCopiedUsdt(false), 500);
|
||||
}}>
|
||||
{copiedUsdt ? <CircleCheck className="h-3.5 w-3.5 text-green-500"/> : <Copy className="h-3.5 w-3.5"/>}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>)}
|
||||
</div>
|
||||
</div>);
|
||||
}
|
||||
@@ -1,11 +1,17 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Download, FolderOpen, ImageDown, FileText } from "lucide-react";
|
||||
import { Download, FolderOpen, ImageDown, FileText, XCircle } from "lucide-react";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { SearchAndSort } from "./SearchAndSort";
|
||||
import { TrackList } from "./TrackList";
|
||||
import { DownloadProgress } from "./DownloadProgress";
|
||||
import { getSettings } from "@/lib/settings";
|
||||
import { downloadCover } from "@/lib/api";
|
||||
import { useState } from "react";
|
||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||
import { joinPath, sanitizePath } from "@/lib/utils";
|
||||
import { parseTemplate, type TemplateData } from "@/lib/settings";
|
||||
import type { TrackMetadata, TrackAvailability } from "@/types/api";
|
||||
interface AlbumInfoProps {
|
||||
albumInfo: {
|
||||
@@ -48,9 +54,9 @@ interface AlbumInfoProps {
|
||||
isBulkDownloadingLyrics?: boolean;
|
||||
onSearchChange: (value: string) => void;
|
||||
onSortChange: (value: string) => void;
|
||||
onToggleTrack: (isrc: string) => void;
|
||||
onToggleTrack: (id: string) => void;
|
||||
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
|
||||
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => void;
|
||||
onDownloadTrack: (id: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => void;
|
||||
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
|
||||
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
|
||||
onCheckAvailability?: (spotifyId: string) => void;
|
||||
@@ -67,13 +73,90 @@ interface AlbumInfoProps {
|
||||
external_urls: string;
|
||||
}) => void;
|
||||
onTrackClick?: (track: TrackMetadata) => void;
|
||||
onBack?: () => void;
|
||||
}
|
||||
export function AlbumInfo({ albumInfo, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onPageChange, onArtistClick, onTrackClick, }: AlbumInfoProps) {
|
||||
export function AlbumInfo({ albumInfo, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onPageChange, onArtistClick, onTrackClick, onBack, }: AlbumInfoProps) {
|
||||
const settings = getSettings();
|
||||
const [downloadingAlbumCover, setDownloadingAlbumCover] = useState(false);
|
||||
const handleDownloadAlbumCover = async () => {
|
||||
if (!albumInfo.images)
|
||||
return;
|
||||
setDownloadingAlbumCover(true);
|
||||
try {
|
||||
const os = settings.operatingSystem;
|
||||
let outputDir = settings.downloadPath;
|
||||
const albumName = albumInfo.name;
|
||||
const artistName = albumInfo.artists;
|
||||
const placeholder = "__SLASH_PLACEHOLDER__";
|
||||
const templateData: TemplateData = {
|
||||
artist: artistName?.replace(/\//g, placeholder),
|
||||
album: albumName?.replace(/\//g, placeholder),
|
||||
album_artist: artistName?.replace(/\//g, placeholder),
|
||||
title: albumName?.replace(/\//g, placeholder),
|
||||
year: albumInfo.release_date?.substring(0, 4),
|
||||
date: albumInfo.release_date,
|
||||
};
|
||||
if (settings.folderTemplate) {
|
||||
const folderPath = parseTemplate(settings.folderTemplate, templateData);
|
||||
if (folderPath) {
|
||||
const parts = folderPath.split("/").filter((p: string) => p.trim());
|
||||
for (const part of parts) {
|
||||
outputDir = joinPath(os, outputDir, sanitizePath(part.replace(new RegExp(placeholder, "g"), " "), os));
|
||||
}
|
||||
}
|
||||
}
|
||||
const response = await downloadCover({
|
||||
cover_url: albumInfo.images,
|
||||
track_name: albumName,
|
||||
artist_name: "",
|
||||
album_name: "",
|
||||
album_artist: "",
|
||||
release_date: "",
|
||||
output_dir: outputDir,
|
||||
filename_format: "title",
|
||||
track_number: false,
|
||||
position: 0,
|
||||
disc_number: 0,
|
||||
});
|
||||
if (response.success) {
|
||||
if (response.already_exists)
|
||||
toast.info("Cover already exists");
|
||||
else
|
||||
toast.success("Separate album cover downloaded");
|
||||
}
|
||||
else {
|
||||
toast.error(response.error || "Failed to download cover");
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to download cover");
|
||||
}
|
||||
finally {
|
||||
setDownloadingAlbumCover(false);
|
||||
}
|
||||
};
|
||||
return (<div className="space-y-6">
|
||||
<Card>
|
||||
<Card className="relative">
|
||||
{onBack && (<div className="absolute top-4 right-4 z-10">
|
||||
<Button variant="ghost" size="icon" onClick={onBack}>
|
||||
<XCircle className="h-5 w-5"/>
|
||||
</Button>
|
||||
</div>)}
|
||||
<CardContent className="px-6">
|
||||
<div className="flex gap-6 items-start">
|
||||
{albumInfo.images && (<img src={albumInfo.images} alt={albumInfo.name} className="w-48 h-48 rounded-md shadow-lg object-cover"/>)}
|
||||
{albumInfo.images && (<div className="relative group shrink-0 w-48 h-48">
|
||||
<img src={albumInfo.images} alt={albumInfo.name} className="w-48 h-48 rounded-md shadow-lg object-cover"/>
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity rounded-md">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="secondary" size="icon" className="h-9 w-9 shadow-lg" onClick={handleDownloadAlbumCover} disabled={downloadingAlbumCover}>
|
||||
{downloadingAlbumCover ? <Spinner /> : <ImageDown className="h-4 w-4"/>}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent><p>Download Separate Album Cover</p></TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>)}
|
||||
<div className="flex-1 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">Album</p>
|
||||
@@ -90,7 +173,7 @@ export function AlbumInfo({ albumInfo, trackList, searchQuery, sortBy, selectedT
|
||||
<span>{albumInfo.release_date}</span>
|
||||
<span>•</span>
|
||||
<span>
|
||||
{albumInfo.total_tracks} {albumInfo.total_tracks === 1 ? "song" : "songs"}
|
||||
{albumInfo.total_tracks.toLocaleString()} {albumInfo.total_tracks === 1 ? "track" : "tracks"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -101,7 +184,7 @@ export function AlbumInfo({ albumInfo, trackList, searchQuery, sortBy, selectedT
|
||||
</Button>
|
||||
{selectedTracks.length > 0 && (<Button onClick={onDownloadSelected} variant="secondary" disabled={isDownloading}>
|
||||
{isDownloading && bulkDownloadType === "selected" ? (<Spinner />) : (<Download className="h-4 w-4"/>)}
|
||||
Download Selected ({selectedTracks.length})
|
||||
Download Selected ({selectedTracks.length.toLocaleString()})
|
||||
</Button>)}
|
||||
{onDownloadAllLyrics && (<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -120,13 +203,19 @@ export function AlbumInfo({ albumInfo, trackList, searchQuery, sortBy, selectedT
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Download All Covers</p>
|
||||
<p>Download All Separate Covers</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>)}
|
||||
{downloadedTracks.size > 0 && (<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button onClick={onOpenFolder} variant="outline" size="icon">
|
||||
<FolderOpen className="h-4 w-4"/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Open Folder</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>)}
|
||||
{downloadedTracks.size > 0 && (<Button onClick={onOpenFolder} variant="outline">
|
||||
<FolderOpen className="h-4 w-4"/>
|
||||
Open Folder
|
||||
</Button>)}
|
||||
</div>
|
||||
{isDownloading && (<DownloadProgress progress={downloadProgress} currentTrack={currentDownloadInfo} onStop={onStopDownload}/>)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RefreshCw, CheckCircle2, XCircle, Loader2 } from "lucide-react";
|
||||
import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons";
|
||||
import { useApiStatus } from "@/hooks/useApiStatus";
|
||||
export function ApiStatusTab() {
|
||||
const { sources, statuses, isCheckingAll, refreshAll } = useApiStatus();
|
||||
return (<div className="space-y-6">
|
||||
<div className="flex items-center justify-end">
|
||||
<Button variant="outline" onClick={() => void refreshAll()} disabled={isCheckingAll} className="gap-2">
|
||||
<RefreshCw className={`h-4 w-4 ${isCheckingAll ? "animate-spin" : ""}`}/>
|
||||
Refresh All
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{sources.map((source) => {
|
||||
const status = statuses[source.id] || "idle";
|
||||
return (<div key={source.id} className="flex items-center justify-between p-4 border rounded-lg bg-card text-card-foreground shadow-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
{source.type === "tidal" ? <TidalIcon className="w-5 h-5 shrink-0 text-muted-foreground"/> : source.type === "amazon" ? <AmazonIcon className="w-5 h-5 shrink-0 text-muted-foreground"/> : <QobuzIcon className="w-5 h-5 shrink-0 text-muted-foreground"/>}
|
||||
<p className="font-medium leading-none">{source.name}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
{status === "checking" && <Loader2 className="h-5 w-5 animate-spin text-muted-foreground"/>}
|
||||
{status === "online" && <CheckCircle2 className="h-5 w-5 text-emerald-500"/>}
|
||||
{status === "offline" && <XCircle className="h-5 w-5 text-destructive"/>}
|
||||
{status === "idle" && <div className="h-5 w-5 rounded-full bg-muted"/>}
|
||||
</div>
|
||||
</div>);
|
||||
})}
|
||||
</div>
|
||||
</div>);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Download, FolderOpen, ImageDown, FileText, BadgeCheck } from "lucide-react";
|
||||
import { Download, FolderOpen, ImageDown, FileText, BadgeCheck, XCircle, Filter } from "lucide-react";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { SearchAndSort } from "./SearchAndSort";
|
||||
@@ -10,7 +10,10 @@ import type { TrackMetadata, TrackAvailability } from "@/types/api";
|
||||
import { downloadHeader, downloadGalleryImage, downloadAvatar } from "@/lib/api";
|
||||
import { getSettings } from "@/lib/settings";
|
||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||
import { useState } from "react";
|
||||
import { useState, useMemo } from "react";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
interface ArtistInfoProps {
|
||||
artistInfo: {
|
||||
name: string;
|
||||
@@ -18,6 +21,7 @@ interface ArtistInfoProps {
|
||||
header?: string;
|
||||
gallery?: string[];
|
||||
followers: number;
|
||||
total_albums?: number;
|
||||
genres: string[];
|
||||
biography?: string;
|
||||
verified?: boolean;
|
||||
@@ -31,6 +35,7 @@ interface ArtistInfoProps {
|
||||
release_date: string;
|
||||
album_type: string;
|
||||
external_urls: string;
|
||||
total_tracks?: number;
|
||||
}>;
|
||||
trackList: TrackMetadata[];
|
||||
searchQuery: string;
|
||||
@@ -63,9 +68,9 @@ interface ArtistInfoProps {
|
||||
isBulkDownloadingLyrics?: boolean;
|
||||
onSearchChange: (value: string) => void;
|
||||
onSortChange: (value: string) => void;
|
||||
onToggleTrack: (isrc: string) => void;
|
||||
onToggleTrack: (id: string) => void;
|
||||
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
|
||||
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => void;
|
||||
onDownloadTrack: (id: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => void;
|
||||
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
|
||||
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
|
||||
onCheckAvailability?: (spotifyId: string) => void;
|
||||
@@ -87,12 +92,76 @@ interface ArtistInfoProps {
|
||||
}) => void;
|
||||
onPageChange: (page: number) => void;
|
||||
onTrackClick?: (track: TrackMetadata) => void;
|
||||
onBack?: () => void;
|
||||
}
|
||||
export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onAlbumClick, onArtistClick, onPageChange, onTrackClick, }: ArtistInfoProps) {
|
||||
export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onAlbumClick, onArtistClick, onPageChange, onTrackClick, onBack, }: ArtistInfoProps) {
|
||||
const [downloadingHeader, setDownloadingHeader] = useState(false);
|
||||
const [downloadingAvatar, setDownloadingAvatar] = useState(false);
|
||||
const [downloadingGalleryIndex, setDownloadingGalleryIndex] = useState<number | null>(null);
|
||||
const [downloadingAllGallery, setDownloadingAllGallery] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<"albums" | "tracks" | "gallery">("albums");
|
||||
const [activeAlbumFilter, setActiveAlbumFilter] = useState<string>("all");
|
||||
const displayedAlbumCount = artistInfo.total_albums || albumList.length;
|
||||
const albumFilterCounts = useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
counts.set("all", (albumList || []).length);
|
||||
for (const album of albumList || []) {
|
||||
const type = (album.album_type || "").trim().toLowerCase();
|
||||
if (!type)
|
||||
continue;
|
||||
counts.set(type, (counts.get(type) || 0) + 1);
|
||||
}
|
||||
return counts;
|
||||
}, [albumList]);
|
||||
const albumFilters = useMemo(() => {
|
||||
const uniqueTypes = Array.from(new Set((albumList || [])
|
||||
.map((album) => (album.album_type || "").trim().toLowerCase())
|
||||
.filter(Boolean)));
|
||||
return ["all", ...uniqueTypes];
|
||||
}, [albumList]);
|
||||
const filteredAlbums = useMemo(() => {
|
||||
if (activeAlbumFilter === "all") {
|
||||
return albumList || [];
|
||||
}
|
||||
return (albumList || []).filter((album) => (album.album_type || "").trim().toLowerCase() === activeAlbumFilter);
|
||||
}, [albumList, activeAlbumFilter]);
|
||||
const filteredAlbumGroups = useMemo(() => {
|
||||
const albumTypeMap = new Map(albumList.map(a => [a.name, a.album_type]));
|
||||
const albumGroups = trackList.reduce((acc, track) => {
|
||||
if (!track.album_name)
|
||||
return acc;
|
||||
if (!acc[track.album_name]) {
|
||||
acc[track.album_name] = {
|
||||
count: 0,
|
||||
tracks: [],
|
||||
type: albumTypeMap.get(track.album_name) || "unknown"
|
||||
};
|
||||
}
|
||||
acc[track.album_name].count++;
|
||||
acc[track.album_name].tracks.push(track);
|
||||
return acc;
|
||||
}, {} as Record<string, {
|
||||
count: number;
|
||||
tracks: TrackMetadata[];
|
||||
type: string;
|
||||
}>);
|
||||
return Object.entries(albumGroups).sort((a, b) => {
|
||||
const dateA = a[1].tracks[0]?.release_date || "";
|
||||
const dateB = b[1].tracks[0]?.release_date || "";
|
||||
return dateB.localeCompare(dateA);
|
||||
});
|
||||
}, [trackList, albumList]);
|
||||
const formatAlbumFilterLabel = (value: string) => {
|
||||
const count = albumFilterCounts.get(value) || 0;
|
||||
if (value === "all")
|
||||
return `All (${count})`;
|
||||
const label = value
|
||||
.split(/[_\s]+/)
|
||||
.filter(Boolean)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(" ");
|
||||
return `${label} (${count})`;
|
||||
};
|
||||
const handleDownloadHeader = async () => {
|
||||
if (!artistInfo.header)
|
||||
return;
|
||||
@@ -238,13 +307,19 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
|
||||
setDownloadingAllGallery(false);
|
||||
}
|
||||
};
|
||||
const hasGallery = artistInfo.gallery && artistInfo.gallery.length > 0;
|
||||
return (<div className="space-y-6">
|
||||
<Card className="overflow-hidden p-0">
|
||||
<Card className="overflow-hidden p-0 relative">
|
||||
{artistInfo.header ? (<>
|
||||
<div className="relative w-full h-64 bg-cover bg-center">
|
||||
<div className="absolute inset-0 bg-cover bg-center" style={{ backgroundImage: `url(${artistInfo.header})` }}/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black via-black/50 to-transparent"/>
|
||||
<div className="absolute top-4 right-4 z-10">
|
||||
{onBack && (<div className="absolute top-4 right-4 z-10">
|
||||
<Button variant="ghost" size="icon" onClick={onBack} className="text-white hover:bg-white/20 hover:text-white">
|
||||
<XCircle className="h-5 w-5"/>
|
||||
</Button>
|
||||
</div>)}
|
||||
<div className="absolute bottom-4 right-4 z-10">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button onClick={handleDownloadHeader} size="sm" variant="secondary" disabled={downloadingHeader} className="bg-white/10 hover:bg-white/20 text-white border-white/20">
|
||||
@@ -277,23 +352,24 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
|
||||
<p className="text-sm font-medium text-white/80">Artist</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-4xl font-bold text-white">{artistInfo.name}</h2>
|
||||
{artistInfo.verified && (<BadgeCheck className="h-6 w-6 text-blue-400 shrink-0"/>)}
|
||||
{artistInfo.verified && (<BadgeCheck className="h-6 w-6 text-white fill-blue-400 shrink-0"/>)}
|
||||
</div>
|
||||
{artistInfo.biography && (<p className="text-sm text-white/90">{artistInfo.biography}</p>)}
|
||||
{artistInfo.biography && (<p className="text-sm text-white/90 line-clamp-4">{artistInfo.biography}</p>)}
|
||||
<div className="flex items-center gap-2 text-sm flex-wrap text-white/90">
|
||||
<span>{artistInfo.followers.toLocaleString()} followers</span>
|
||||
{artistInfo.rank && (<>
|
||||
<span>#{artistInfo.rank} rank</span>
|
||||
<span>•</span>
|
||||
</>)}
|
||||
<span>{artistInfo.followers.toLocaleString()} {artistInfo.followers === 1 ? "follower" : "followers"}</span>
|
||||
{artistInfo.listeners && (<>
|
||||
<span>•</span>
|
||||
<span>{artistInfo.listeners.toLocaleString()} listeners</span>
|
||||
</>)}
|
||||
{artistInfo.rank && (<>
|
||||
<span>•</span>
|
||||
<span>#{artistInfo.rank} rank</span>
|
||||
<span>{artistInfo.listeners.toLocaleString()} {artistInfo.listeners === 1 ? "listener" : "listeners"}</span>
|
||||
</>)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm flex-wrap text-white/90">
|
||||
<span>{displayedAlbumCount.toLocaleString()} {displayedAlbumCount === 1 ? "album" : "albums"}</span>
|
||||
<span>•</span>
|
||||
<span>{albumList.length} {albumList.length === 1 ? "album" : "albums"}</span>
|
||||
<span>•</span>
|
||||
<span>{trackList.length} {trackList.length === 1 ? "track" : "tracks"}</span>
|
||||
<span>{trackList.length.toLocaleString()} {trackList.length === 1 ? "track" : "tracks"}</span>
|
||||
{artistInfo.genres.length > 0 && (<>
|
||||
<span>•</span>
|
||||
<span>{artistInfo.genres.join(", ")}</span>
|
||||
@@ -304,6 +380,11 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
|
||||
</div>
|
||||
</div>
|
||||
</>) : (<CardContent className="px-6 py-6">
|
||||
{onBack && (<div className="absolute top-4 right-4 z-10">
|
||||
<Button variant="ghost" size="icon" onClick={onBack}>
|
||||
<XCircle className="h-5 w-5"/>
|
||||
</Button>
|
||||
</div>)}
|
||||
<div className="flex gap-6 items-start">
|
||||
{artistInfo.images && (<div className="relative group">
|
||||
<img src={artistInfo.images} alt={artistInfo.name} className="w-48 h-48 rounded-full shadow-lg object-cover border-4 border-white"/>
|
||||
@@ -324,23 +405,24 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
|
||||
<p className="text-sm font-medium">Artist</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-4xl font-bold">{artistInfo.name}</h2>
|
||||
{artistInfo.verified && (<BadgeCheck className="h-6 w-6 text-blue-500 shrink-0"/>)}
|
||||
{artistInfo.verified && (<BadgeCheck className="h-6 w-6 text-white fill-blue-500 shrink-0"/>)}
|
||||
</div>
|
||||
{artistInfo.biography && (<p className="text-sm text-muted-foreground">{artistInfo.biography}</p>)}
|
||||
{artistInfo.biography && (<p className="text-sm text-muted-foreground line-clamp-4">{artistInfo.biography}</p>)}
|
||||
<div className="flex items-center gap-2 text-sm flex-wrap">
|
||||
<span>{artistInfo.followers.toLocaleString()} followers</span>
|
||||
{artistInfo.rank && (<>
|
||||
<span>#{artistInfo.rank} rank</span>
|
||||
<span>•</span>
|
||||
</>)}
|
||||
<span>{artistInfo.followers.toLocaleString()} {artistInfo.followers === 1 ? "follower" : "followers"}</span>
|
||||
{artistInfo.listeners && (<>
|
||||
<span>•</span>
|
||||
<span>{artistInfo.listeners.toLocaleString()} listeners</span>
|
||||
</>)}
|
||||
{artistInfo.rank && (<>
|
||||
<span>•</span>
|
||||
<span>#{artistInfo.rank} rank</span>
|
||||
<span>{artistInfo.listeners.toLocaleString()} {artistInfo.listeners === 1 ? "listener" : "listeners"}</span>
|
||||
</>)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm flex-wrap">
|
||||
<span>{displayedAlbumCount.toLocaleString()} {displayedAlbumCount === 1 ? "album" : "albums"}</span>
|
||||
<span>•</span>
|
||||
<span>{albumList.length} albums</span>
|
||||
<span>•</span>
|
||||
<span>{trackList.length} tracks</span>
|
||||
<span>{trackList.length.toLocaleString()} {trackList.length === 1 ? "track" : "tracks"}</span>
|
||||
{artistInfo.genres.length > 0 && (<>
|
||||
<span>•</span>
|
||||
<span>{artistInfo.genres.join(", ")}</span>
|
||||
@@ -351,9 +433,23 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
|
||||
</CardContent>)}
|
||||
</Card>
|
||||
|
||||
{artistInfo.gallery && artistInfo.gallery.length > 0 && (<div className="space-y-4">
|
||||
<div className="border-b">
|
||||
<div className="flex gap-6">
|
||||
<button onClick={() => setActiveTab("albums")} className={`pb-3 text-sm font-medium transition-colors border-b-2 -mb-px hover:text-foreground ${activeTab === "albums" ? "border-primary text-foreground" : "border-transparent text-muted-foreground"}`}>
|
||||
Albums
|
||||
</button>
|
||||
<button onClick={() => setActiveTab("tracks")} className={`pb-3 text-sm font-medium transition-colors border-b-2 -mb-px hover:text-foreground ${activeTab === "tracks" ? "border-primary text-foreground" : "border-transparent text-muted-foreground"}`}>
|
||||
All Tracks
|
||||
</button>
|
||||
{hasGallery && (<button onClick={() => setActiveTab("gallery")} className={`pb-3 text-sm font-medium transition-colors border-b-2 -mb-px hover:text-foreground ${activeTab === "gallery" ? "border-primary text-foreground" : "border-transparent text-muted-foreground"}`}>
|
||||
Gallery
|
||||
</button>)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeTab === "gallery" && hasGallery && (<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-2xl font-bold">Gallery ({artistInfo.gallery.length})</h3>
|
||||
<h3 className="text-2xl font-bold">Gallery ({artistInfo.gallery!.length.toLocaleString()})</h3>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button onClick={handleDownloadAllGallery} size="sm" variant="outline" disabled={downloadingAllGallery}>
|
||||
@@ -366,7 +462,7 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||
{artistInfo.gallery.map((imageUrl, index) => (<div key={index} className="relative group">
|
||||
{artistInfo.gallery!.map((imageUrl, index) => (<div key={index} className="relative group">
|
||||
<div className="relative aspect-square rounded-md overflow-hidden shadow-md">
|
||||
<img src={imageUrl} alt={`${artistInfo.name} gallery ${index + 1}`} className="w-full h-full object-cover"/>
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/50 transition-colors flex items-center justify-center">
|
||||
@@ -386,36 +482,113 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
|
||||
</div>
|
||||
</div>)}
|
||||
|
||||
{albumList.length > 0 && (<div className="space-y-4">
|
||||
<h3 className="text-2xl font-bold">Discography</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||
{albumList.map((album) => (<div key={album.id} className="group cursor-pointer" onClick={() => onAlbumClick({
|
||||
id: album.id,
|
||||
name: album.name,
|
||||
external_urls: album.external_urls,
|
||||
})}>
|
||||
<div className="relative mb-4">
|
||||
{album.images && (<img src={album.images} alt={album.name} className="w-full aspect-square object-cover rounded-md shadow-md transition-shadow group-hover:shadow-xl"/>)}
|
||||
</div>
|
||||
<h4 className="font-semibold truncate">{album.name}</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{album.release_date?.split("-")[0]}
|
||||
</p>
|
||||
</div>))}
|
||||
{activeTab === "albums" && albumList.length > 0 && (<div className="space-y-4">
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<h3 className="text-2xl font-bold">Discography</h3>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={onDownloadAll} size="sm" disabled={isDownloading}>
|
||||
{isDownloading && bulkDownloadType === "all" ? (<Spinner />) : (<Download className="h-4 w-4"/>)}
|
||||
Download Discography
|
||||
</Button>
|
||||
{selectedTracks.length > 0 && (<Button onClick={onDownloadSelected} size="sm" variant="secondary" disabled={isDownloading}>
|
||||
{isDownloading && bulkDownloadType === "selected" ? (<Spinner />) : (<Download className="h-4 w-4"/>)}
|
||||
Download Selected ({selectedTracks.length})
|
||||
</Button>)}
|
||||
</div>
|
||||
</div>
|
||||
{albumFilters.length > 1 && (<div className="flex flex-wrap gap-2">
|
||||
{albumFilters.map((filter) => (<Button key={filter} size="sm" variant={activeAlbumFilter === filter ? "default" : "outline"} onClick={() => setActiveAlbumFilter(filter)}>
|
||||
{formatAlbumFilterLabel(filter)}
|
||||
</Button>))}
|
||||
</div>)}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||
{filteredAlbums.map((album) => {
|
||||
const albumTracks = trackList.filter(t => t.album_name === album.name);
|
||||
const tracksWithId = albumTracks.filter(t => t.spotify_id);
|
||||
const isSelected = tracksWithId.length > 0 && tracksWithId.every(t => selectedTracks.includes(t.spotify_id!));
|
||||
const hasTracks = tracksWithId.length > 0;
|
||||
return (<div key={album.id} className="group cursor-pointer relative" onClick={() => onAlbumClick({
|
||||
id: album.id,
|
||||
name: album.name,
|
||||
external_urls: album.external_urls,
|
||||
})}>
|
||||
<div className="relative mb-2">
|
||||
|
||||
{hasTracks && (<div className={`absolute top-2 left-2 z-20 ${isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'} transition-opacity`} onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox checked={isSelected} onCheckedChange={() => onToggleSelectAll(albumTracks)} className="bg-black/50 border-white/70 data-[state=checked]:bg-primary data-[state=checked]:border-primary"/>
|
||||
</div>)}
|
||||
{album.images && (<img src={album.images} alt={album.name} className="w-full aspect-square object-cover rounded-md shadow-md transition-shadow group-hover:shadow-xl"/>)}
|
||||
<div className="absolute bottom-2 right-2">
|
||||
<span className="text-[10px] uppercase font-bold px-1.5 py-0.5 rounded bg-black/60 text-white backdrop-blur-[2px]">
|
||||
{album.album_type}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<h4 className="font-semibold truncate text-sm">{album.name}</h4>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground mt-1">
|
||||
<span>{album.release_date?.split("-")[0]}</span>
|
||||
{album.total_tracks && (<>
|
||||
<span>•</span>
|
||||
<span>{album.total_tracks} {album.total_tracks === 1 ? "track" : "tracks"}</span>
|
||||
</>)}
|
||||
</div>
|
||||
</div>);
|
||||
})}
|
||||
</div>
|
||||
{filteredAlbums.length === 0 && (<div className="rounded-lg border border-dashed border-border p-6 text-sm text-muted-foreground">
|
||||
No releases found for the selected discography filter.
|
||||
</div>)}
|
||||
</div>)}
|
||||
|
||||
{trackList.length > 0 && (<div className="space-y-4">
|
||||
{activeTab === "tracks" && trackList.length > 0 && (<div className="space-y-4">
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<h3 className="text-2xl font-bold">All Tracks</h3>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<Filter className="h-4 w-4"/>
|
||||
Filter Albums
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[500px] h-[80vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Select Albums</DialogTitle>
|
||||
</DialogHeader>
|
||||
<ScrollArea className="flex-1 pr-4">
|
||||
<div className="space-y-4">
|
||||
{filteredAlbumGroups.map(([albumName, data]) => {
|
||||
const tracksWithId = data.tracks.filter(t => t.spotify_id);
|
||||
const isSelected = tracksWithId.length > 0 && tracksWithId.every(t => selectedTracks.includes(t.spotify_id!));
|
||||
return (<div key={albumName} className="flex items-start space-x-3 p-2 hover:bg-muted/50 rounded-md transition-colors">
|
||||
<Checkbox id={`album-select-${albumName}`} checked={isSelected} onCheckedChange={() => onToggleSelectAll(data.tracks)} className="mt-1"/>
|
||||
<div className="grid gap-1.5 leading-none flex-1">
|
||||
<label htmlFor={`album-select-${albumName}`} className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer">
|
||||
{albumName}
|
||||
</label>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="capitalize bg-muted px-1.5 py-0.5 rounded text-[10px] font-semibold border">
|
||||
{data.type}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>{data.count} tracks</span>
|
||||
<span>•</span>
|
||||
<span>{data.tracks[0]?.release_date?.split('-')[0] || 'Unknown Year'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Button onClick={onDownloadAll} size="sm" disabled={isDownloading}>
|
||||
{isDownloading && bulkDownloadType === "all" ? (<Spinner />) : (<Download className="h-4 w-4"/>)}
|
||||
Download All
|
||||
</Button>
|
||||
{selectedTracks.length > 0 && (<Button onClick={onDownloadSelected} size="sm" variant="secondary" disabled={isDownloading}>
|
||||
{isDownloading && bulkDownloadType === "selected" ? (<Spinner />) : (<Download className="h-4 w-4"/>)}
|
||||
Download Selected ({selectedTracks.length})
|
||||
Download Selected ({selectedTracks.length.toLocaleString()})
|
||||
</Button>)}
|
||||
{onDownloadAllLyrics && (<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -434,13 +607,19 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Download All Covers</p>
|
||||
<p>Download All Separate Covers</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>)}
|
||||
{downloadedTracks.size > 0 && (<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button onClick={onOpenFolder} size="icon" variant="outline">
|
||||
<FolderOpen className="h-4 w-4"/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Open Folder</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>)}
|
||||
{downloadedTracks.size > 0 && (<Button onClick={onOpenFolder} size="sm" variant="outline">
|
||||
<FolderOpen className="h-4 w-4"/>
|
||||
Open Folder
|
||||
</Button>)}
|
||||
</div>
|
||||
</div>
|
||||
{isDownloading && (<DownloadProgress progress={downloadProgress} currentTrack={currentDownloadInfo} onStop={onStopDownload}/>)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Activity, Waves, Radio, TrendingUp, FileAudio, Clock, Gauge, HardDrive } from "lucide-react";
|
||||
import { Activity } from "lucide-react";
|
||||
import type { AnalysisResult } from "@/types/api";
|
||||
interface AudioAnalysisProps {
|
||||
result: AnalysisResult | null;
|
||||
@@ -13,32 +13,32 @@ interface AudioAnalysisProps {
|
||||
export function AudioAnalysis({ result, analyzing, onAnalyze, showAnalyzeButton = true, filePath }: AudioAnalysisProps) {
|
||||
if (analyzing) {
|
||||
return (<Card>
|
||||
<CardContent className="px-6">
|
||||
<div className="flex items-center justify-center py-8 gap-3">
|
||||
<Spinner />
|
||||
<span className="text-muted-foreground">Analyzing audio quality...</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>);
|
||||
<CardContent className="px-6">
|
||||
<div className="flex items-center justify-center py-8 gap-3">
|
||||
<Spinner />
|
||||
<span className="text-muted-foreground">Analyzing audio quality...</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>);
|
||||
}
|
||||
if (!result && showAnalyzeButton) {
|
||||
return (<Card>
|
||||
<CardContent className="px-6">
|
||||
<div className="flex flex-col items-center justify-center py-8 gap-4">
|
||||
<Activity className="h-12 w-12 text-primary"/>
|
||||
<div className="text-center space-y-2">
|
||||
<p className="font-medium">Audio Quality Analysis</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Verify the true lossless quality of downloaded files
|
||||
</p>
|
||||
</div>
|
||||
{onAnalyze && (<Button onClick={onAnalyze}>
|
||||
<Activity className="h-4 w-4"/>
|
||||
Analyze Audio
|
||||
</Button>)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>);
|
||||
<CardContent className="px-6">
|
||||
<div className="flex flex-col items-center justify-center py-8 gap-4">
|
||||
<Activity className="h-12 w-12 text-primary"/>
|
||||
<div className="text-center space-y-2">
|
||||
<p className="font-medium">Audio Quality Analysis</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Inspect spectral content and effective quality of FLAC, MP3, M4A, and AAC files
|
||||
</p>
|
||||
</div>
|
||||
{onAnalyze && (<Button onClick={onAnalyze}>
|
||||
<Activity className="h-4 w-4"/>
|
||||
Analyze Audio
|
||||
</Button>)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>);
|
||||
}
|
||||
if (!result) {
|
||||
return null;
|
||||
@@ -46,7 +46,7 @@ export function AudioAnalysis({ result, analyzing, onAnalyze, showAnalyzeButton
|
||||
const formatDuration = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
return `${mins}:${secs.toString().padStart(2, "0")}`;
|
||||
};
|
||||
const formatNumber = (num: number) => {
|
||||
return num.toFixed(2);
|
||||
@@ -60,66 +60,120 @@ export function AudioAnalysis({ result, analyzing, onAnalyze, showAnalyzeButton
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
|
||||
};
|
||||
const nyquistFreq = result.sample_rate / 2;
|
||||
const totalSamplesText = result.total_samples > 0 ? result.total_samples.toLocaleString() : "N/A";
|
||||
const freqResolutionLabel = result.file_type === "MP3" ? "Freq Res.:" : "Freq Resolution:";
|
||||
const hasCodecMeta = result.file_type === "MP3" && (Boolean(result.codec_mode) ||
|
||||
typeof result.bitrate_kbps === "number" ||
|
||||
typeof result.total_frames === "number" ||
|
||||
Boolean(result.codec_version));
|
||||
return (<Card className="gap-2">
|
||||
<CardHeader>
|
||||
{filePath && (<p className="text-sm font-mono break-all">{filePath}</p>)}
|
||||
</CardHeader>
|
||||
<CardHeader className="pb-2">
|
||||
{filePath && (<p className="text-sm font-mono break-all text-muted-foreground">{filePath}</p>)}
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-2">
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
|
||||
<div className="flex items-center gap-1">
|
||||
<Radio className="h-3 w-3 text-muted-foreground"/>
|
||||
<span className="text-muted-foreground">Sample Rate:</span>
|
||||
<span className="font-semibold">{(result.sample_rate / 1000).toFixed(1)} kHz</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<FileAudio className="h-3 w-3 text-muted-foreground"/>
|
||||
<span className="text-muted-foreground">Bit Depth:</span>
|
||||
<span className="font-semibold">{result.bit_depth}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Waves className="h-3 w-3 text-muted-foreground"/>
|
||||
<span className="text-muted-foreground">Channels:</span>
|
||||
<span className="font-semibold">{result.channels === 2 ? "Stereo" : result.channels === 1 ? "Mono" : `${result.channels}`}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3 text-muted-foreground"/>
|
||||
<span className="text-muted-foreground">Duration:</span>
|
||||
<span className="font-semibold">{formatDuration(result.duration)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Gauge className="h-3 w-3 text-muted-foreground"/>
|
||||
<span className="text-muted-foreground">Nyquist:</span>
|
||||
<span className="font-semibold">{(nyquistFreq / 1000).toFixed(1)} kHz</span>
|
||||
</div>
|
||||
{result.file_size > 0 && (<div className="flex items-center gap-1">
|
||||
<HardDrive className="h-3 w-3 text-muted-foreground"/>
|
||||
<span className="text-muted-foreground">Size:</span>
|
||||
<span className="font-semibold">{formatFileSize(result.file_size)}</span>
|
||||
</div>)}
|
||||
</div>
|
||||
<CardContent>
|
||||
<div className={`grid grid-cols-1 gap-6 md:grid-cols-2 ${hasCodecMeta ? "lg:grid-cols-4" : "lg:grid-cols-3"}`}>
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-semibold tracking-wider text-muted-foreground uppercase">Format</p>
|
||||
<ul className="text-sm space-y-1">
|
||||
{result.file_type && (<li className="flex justify-between">
|
||||
<span className="text-muted-foreground">Type:</span>
|
||||
<span className="font-medium font-mono">{result.file_type}</span>
|
||||
</li>)}
|
||||
<li className="flex justify-between">
|
||||
<span className="text-muted-foreground">Sample Rate:</span>
|
||||
<span className="font-medium font-mono">{(result.sample_rate / 1000).toFixed(1)} kHz</span>
|
||||
</li>
|
||||
<li className="flex justify-between">
|
||||
<span className="text-muted-foreground">Bit Depth:</span>
|
||||
<span className="font-medium font-mono">{result.bit_depth}</span>
|
||||
</li>
|
||||
<li className="flex justify-between">
|
||||
<span className="text-muted-foreground">Channels:</span>
|
||||
<span className="font-medium font-mono">{result.channels === 2 ? "Stereo" : result.channels === 1 ? "Mono" : `${result.channels}`}</span>
|
||||
</li>
|
||||
<li className="flex justify-between">
|
||||
<span className="text-muted-foreground">Duration:</span>
|
||||
<span className="font-medium font-mono">{formatDuration(result.duration)}</span>
|
||||
</li>
|
||||
{result.file_size > 0 && (<li className="flex justify-between">
|
||||
<span className="text-muted-foreground">Size:</span>
|
||||
<span className="font-medium font-mono">{formatFileSize(result.file_size)}</span>
|
||||
</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs border-t pt-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<TrendingUp className="h-3 w-3 text-muted-foreground"/>
|
||||
<span className="text-muted-foreground">Dynamic Range:</span>
|
||||
<span className="font-semibold">{formatNumber(result.dynamic_range)} dB</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-muted-foreground">Peak:</span>
|
||||
<span className="font-semibold">{formatNumber(result.peak_amplitude)} dB</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-muted-foreground">RMS:</span>
|
||||
<span className="font-semibold">{formatNumber(result.rms_level)} dB</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 ml-auto">
|
||||
<span className="text-muted-foreground">Samples:</span>
|
||||
<span className="font-semibold">{result.total_samples.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>);
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-semibold tracking-wider text-muted-foreground uppercase">Signal Analytics</p>
|
||||
<ul className="text-sm space-y-1">
|
||||
<li className="flex justify-between">
|
||||
<span className="text-muted-foreground">Nyquist:</span>
|
||||
<span className="font-medium font-mono">{(nyquistFreq / 1000).toFixed(1)} kHz</span>
|
||||
</li>
|
||||
<li className="flex justify-between">
|
||||
<span className="text-muted-foreground">Dynamic Range:</span>
|
||||
<span className="font-medium font-mono">{formatNumber(result.dynamic_range)} dB</span>
|
||||
</li>
|
||||
<li className="flex justify-between">
|
||||
<span className="text-muted-foreground">Peak Amplitude:</span>
|
||||
<span className="font-medium font-mono">{formatNumber(result.peak_amplitude)} dB</span>
|
||||
</li>
|
||||
<li className="flex justify-between">
|
||||
<span className="text-muted-foreground">RMS Level:</span>
|
||||
<span className="font-medium font-mono">{formatNumber(result.rms_level)} dB</span>
|
||||
</li>
|
||||
<li className="flex justify-between">
|
||||
<span className="text-muted-foreground">Total Samples:</span>
|
||||
<span className="font-medium font-mono">{totalSamplesText}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{hasCodecMeta && (<div className="space-y-2">
|
||||
<p className="text-xs font-semibold tracking-wider text-muted-foreground uppercase">MP3 Meta</p>
|
||||
<ul className="text-sm space-y-1">
|
||||
{result.codec_mode && (<li className="flex justify-between">
|
||||
<span className="text-muted-foreground">Mode:</span>
|
||||
<span className="font-medium font-mono">{result.codec_mode}</span>
|
||||
</li>)}
|
||||
{typeof result.bitrate_kbps === "number" && (<li className="flex justify-between">
|
||||
<span className="text-muted-foreground">Bitrate:</span>
|
||||
<span className="font-medium font-mono">{result.bitrate_kbps} kbps</span>
|
||||
</li>)}
|
||||
{typeof result.total_frames === "number" && result.total_frames > 0 && (<li className="flex justify-between">
|
||||
<span className="text-muted-foreground">Frames:</span>
|
||||
<span className="font-medium font-mono">{result.total_frames.toLocaleString()}</span>
|
||||
</li>)}
|
||||
{result.codec_version && (<li className="flex justify-between">
|
||||
<span className="text-muted-foreground">Version:</span>
|
||||
<span className="font-medium font-mono">{result.codec_version}</span>
|
||||
</li>)}
|
||||
</ul>
|
||||
</div>)}
|
||||
|
||||
{result.spectrum && (() => {
|
||||
const frames = result.spectrum.time_slices.length;
|
||||
const fftSize = (result.spectrum.freq_bins - 1) * 2;
|
||||
const freqRes = result.sample_rate / fftSize;
|
||||
return (<div className="space-y-2">
|
||||
<p className="text-xs font-semibold tracking-wider text-muted-foreground uppercase">Spectrum Meta</p>
|
||||
<ul className="text-sm space-y-1">
|
||||
<li className="flex justify-between">
|
||||
<span className="text-muted-foreground">Display Frames:</span>
|
||||
<span className="font-medium font-mono">{frames.toLocaleString()}</span>
|
||||
</li>
|
||||
<li className="flex justify-between">
|
||||
<span className="text-muted-foreground">FFT Size:</span>
|
||||
<span className="font-medium font-mono">{fftSize.toLocaleString()}</span>
|
||||
</li>
|
||||
<li className="flex justify-between">
|
||||
<span className="text-muted-foreground">{freqResolutionLabel}</span>
|
||||
<span className="font-medium font-mono">{freqRes.toFixed(2)} Hz/bin</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>);
|
||||
})()}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>);
|
||||
}
|
||||
|
||||
@@ -1,113 +1,884 @@
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { useState, useCallback, useRef, useEffect, type ChangeEvent, type CSSProperties, type DragEvent } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Upload, ArrowLeft, Trash2 } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Upload, ArrowLeft, Trash2, Download, FolderOpen, X, AlertCircle, CheckCircle2, FileMusic, ChevronDown, Play, StopCircle } from "lucide-react";
|
||||
import { AudioAnalysis } from "@/components/AudioAnalysis";
|
||||
import { SpectrumVisualization } from "@/components/SpectrumVisualization";
|
||||
import { SpectrumVisualization, createSpectrogramDataURL, type SpectrumVisualizationHandle } from "@/components/SpectrumVisualization";
|
||||
import { useAudioAnalysis } from "@/hooks/useAudioAnalysis";
|
||||
import { SelectFile } from "../../wailsjs/go/main/App";
|
||||
import type { AnalysisResult } from "@/types/api";
|
||||
import { loadAudioAnalysisPreferences } from "@/lib/audio-analysis-preferences";
|
||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||
import { GetFileSizes, ListAudioFilesInDir, SaveSpectrumImage, SelectAudioFiles, SelectFolder } from "../../wailsjs/go/main/App";
|
||||
import { OnFileDrop, OnFileDropOff } from "../../wailsjs/runtime/runtime";
|
||||
interface AudioAnalysisPageProps {
|
||||
onBack?: () => void;
|
||||
}
|
||||
type BatchItemStatus = "pending" | "analyzing" | "success" | "error";
|
||||
type BatchItemSource = "path" | "browser";
|
||||
interface BatchAnalysisItem {
|
||||
id: string;
|
||||
source: BatchItemSource;
|
||||
path: string;
|
||||
name: string;
|
||||
size: number;
|
||||
status: BatchItemStatus;
|
||||
error?: string;
|
||||
result?: AnalysisResult;
|
||||
file?: File;
|
||||
}
|
||||
interface QueueProgressState {
|
||||
completed: number;
|
||||
total: number;
|
||||
fileName: string;
|
||||
}
|
||||
const EMPTY_PROGRESS_STATE: QueueProgressState = {
|
||||
completed: 0,
|
||||
total: 0,
|
||||
fileName: "",
|
||||
};
|
||||
const SUPPORTED_AUDIO_EXTENSIONS = [".flac", ".mp3", ".m4a", ".aac"];
|
||||
const SUPPORTED_AUDIO_ACCEPT = [
|
||||
".flac",
|
||||
".mp3",
|
||||
".m4a",
|
||||
".aac",
|
||||
"audio/flac",
|
||||
"audio/x-flac",
|
||||
"audio/mpeg",
|
||||
"audio/mp3",
|
||||
"audio/mp4",
|
||||
"audio/x-m4a",
|
||||
"audio/aac",
|
||||
"audio/aacp",
|
||||
].join(",");
|
||||
const SUPPORTED_AUDIO_LABEL = "FLAC, MP3, M4A, or AAC";
|
||||
function isSupportedAudioPath(filePath: string): boolean {
|
||||
const normalized = filePath.toLowerCase();
|
||||
return SUPPORTED_AUDIO_EXTENSIONS.some((ext) => normalized.endsWith(ext));
|
||||
}
|
||||
function isSupportedAudioFile(file: File): boolean {
|
||||
const normalizedName = file.name.toLowerCase();
|
||||
const normalizedType = file.type.toLowerCase();
|
||||
return (SUPPORTED_AUDIO_EXTENSIONS.some((ext) => normalizedName.endsWith(ext)) ||
|
||||
normalizedType === "audio/flac" ||
|
||||
normalizedType === "audio/x-flac" ||
|
||||
normalizedType === "audio/mpeg" ||
|
||||
normalizedType === "audio/mp3" ||
|
||||
normalizedType === "audio/mp4" ||
|
||||
normalizedType === "audio/x-m4a" ||
|
||||
normalizedType === "audio/aac" ||
|
||||
normalizedType === "audio/aacp");
|
||||
}
|
||||
function isAbsolutePath(filePath: string): boolean {
|
||||
return /^(?:[a-zA-Z]:[\\/]|\\\\|\/)/.test(filePath);
|
||||
}
|
||||
function fileNameFromPath(filePath: string): string {
|
||||
const parts = filePath.split(/[/\\]/);
|
||||
return parts[parts.length - 1] || filePath;
|
||||
}
|
||||
function browserFileId(file: File): string {
|
||||
return `browser:${file.name}:${file.size}:${file.lastModified}`;
|
||||
}
|
||||
function downloadDataURL(dataUrl: string, fileName: string): void {
|
||||
const link = document.createElement("a");
|
||||
link.href = dataUrl;
|
||||
link.download = fileName;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes <= 0) {
|
||||
return "0 B";
|
||||
}
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB"];
|
||||
const index = Math.min(sizes.length - 1, Math.floor(Math.log(bytes) / Math.log(k)));
|
||||
return `${parseFloat((bytes / Math.pow(k, index)).toFixed(1))} ${sizes[index]}`;
|
||||
}
|
||||
function formatDuration(seconds: number): string {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins}:${secs.toString().padStart(2, "0")}`;
|
||||
}
|
||||
function itemMetaLine(item: BatchAnalysisItem): string {
|
||||
if (item.result) {
|
||||
const parts = [
|
||||
item.result.file_type ?? "Audio",
|
||||
`${(item.result.sample_rate / 1000).toFixed(1)} kHz`,
|
||||
formatDuration(item.result.duration),
|
||||
];
|
||||
if (typeof item.result.bitrate_kbps === "number" && item.result.bitrate_kbps > 0) {
|
||||
parts.push(`${item.result.bitrate_kbps} kbps`);
|
||||
}
|
||||
return parts.join(" • ");
|
||||
}
|
||||
switch (item.status) {
|
||||
case "analyzing":
|
||||
return "Analyzing audio quality...";
|
||||
case "error":
|
||||
return item.error || "Analysis failed";
|
||||
case "pending":
|
||||
default:
|
||||
return "Waiting to be analyzed";
|
||||
}
|
||||
}
|
||||
function statusIcon(status: BatchItemStatus) {
|
||||
switch (status) {
|
||||
case "analyzing":
|
||||
return <Spinner className="h-4 w-4 text-primary"/>;
|
||||
case "success":
|
||||
return <CheckCircle2 className="h-4 w-4 text-green-500"/>;
|
||||
case "error":
|
||||
return <AlertCircle className="h-4 w-4 text-destructive"/>;
|
||||
case "pending":
|
||||
default:
|
||||
return <FileMusic className="h-4 w-4 text-muted-foreground"/>;
|
||||
}
|
||||
}
|
||||
export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
|
||||
const { analyzing, result, analyzeFile, clearResult, selectedFilePath, spectrumLoading } = useAudioAnalysis();
|
||||
const { analysisProgress, spectrumLoading, spectrumProgress, analyzeFile, analyzeFilePath, cancelAnalysis, loadStoredAnalysis, clearStoredAnalysis, reAnalyzeSpectrum, clearResult, } = useAudioAnalysis();
|
||||
const [items, setItems] = useState<BatchAnalysisItem[]>([]);
|
||||
const [activeItemId, setActiveItemId] = useState<string | null>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const handleSelectFile = async () => {
|
||||
const [isExportingSelected, setIsExportingSelected] = useState(false);
|
||||
const [isExportingBatch, setIsExportingBatch] = useState(false);
|
||||
const [isBatchRunning, setIsBatchRunning] = useState(false);
|
||||
const [batchProgress, setBatchProgress] = useState<QueueProgressState>(EMPTY_PROGRESS_STATE);
|
||||
const [exportProgress, setExportProgress] = useState<QueueProgressState>(EMPTY_PROGRESS_STATE);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const spectrumRef = useRef<SpectrumVisualizationHandle>(null);
|
||||
const batchRunIdRef = useRef(0);
|
||||
const itemsRef = useRef(items);
|
||||
const activeItemIdRef = useRef<string | null>(activeItemId);
|
||||
useEffect(() => {
|
||||
itemsRef.current = items;
|
||||
}, [items]);
|
||||
useEffect(() => {
|
||||
activeItemIdRef.current = activeItemId;
|
||||
}, [activeItemId]);
|
||||
const setActiveSelection = useCallback((nextId: string | null) => {
|
||||
activeItemIdRef.current = nextId;
|
||||
setActiveItemId(nextId);
|
||||
}, []);
|
||||
const activeItem = items.find((item) => item.id === activeItemId) ?? null;
|
||||
const successItems = items.filter((item) => item.status === "success" && item.result?.spectrum);
|
||||
const pendingItems = items.filter((item) => item.status === "pending");
|
||||
const isSingleMode = items.length === 1;
|
||||
const isBatchMode = items.length > 1;
|
||||
const canResumeBatch = isBatchMode && !isBatchRunning && pendingItems.length > 0;
|
||||
const batchPercent = batchProgress.total > 0
|
||||
? Math.round(Math.max(0, Math.min(100, ((batchProgress.completed + (isBatchRunning ? analysisProgress.percent / 100 : 0)) / batchProgress.total) * 100)))
|
||||
: 0;
|
||||
const exportPercent = exportProgress.total > 0
|
||||
? Math.round(Math.max(0, Math.min(100, (exportProgress.completed / exportProgress.total) * 100)))
|
||||
: 0;
|
||||
useEffect(() => {
|
||||
if (!activeItem?.result) {
|
||||
return;
|
||||
}
|
||||
loadStoredAnalysis(activeItem.id, activeItem.result, activeItem.path);
|
||||
}, [activeItem, loadStoredAnalysis]);
|
||||
const runBatchAnalysis = useCallback(async (entries: BatchAnalysisItem[]) => {
|
||||
if (entries.length === 0) {
|
||||
return;
|
||||
}
|
||||
const runId = batchRunIdRef.current + 1;
|
||||
batchRunIdRef.current = runId;
|
||||
setIsBatchRunning(true);
|
||||
setBatchProgress({
|
||||
completed: 0,
|
||||
total: entries.length,
|
||||
fileName: entries[0]?.name ?? "",
|
||||
});
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
try {
|
||||
const filePath = await SelectFile();
|
||||
if (filePath) {
|
||||
await analyzeFile(filePath);
|
||||
for (let index = 0; index < entries.length; index++) {
|
||||
if (batchRunIdRef.current !== runId) {
|
||||
return;
|
||||
}
|
||||
const entry = entries[index];
|
||||
setBatchProgress({
|
||||
completed: index,
|
||||
total: entries.length,
|
||||
fileName: entry.name,
|
||||
});
|
||||
setItems((prev) => prev.map((item) => item.id === entry.id
|
||||
? { ...item, status: "analyzing", error: undefined }
|
||||
: item));
|
||||
const outcome = entry.source === "browser" && entry.file
|
||||
? await analyzeFile(entry.file, {
|
||||
analysisKey: entry.id,
|
||||
displayPath: entry.path,
|
||||
suppressToast: true,
|
||||
})
|
||||
: await analyzeFilePath(entry.path, {
|
||||
analysisKey: entry.id,
|
||||
displayPath: entry.path,
|
||||
suppressToast: true,
|
||||
});
|
||||
if (batchRunIdRef.current !== runId) {
|
||||
return;
|
||||
}
|
||||
if (outcome.cancelled) {
|
||||
return;
|
||||
}
|
||||
if (outcome.result) {
|
||||
const analysisResult = outcome.result;
|
||||
successCount++;
|
||||
setItems((prev) => prev.map((item) => item.id === entry.id
|
||||
? {
|
||||
...item,
|
||||
status: "success",
|
||||
error: undefined,
|
||||
result: analysisResult,
|
||||
size: analysisResult.file_size || item.size,
|
||||
}
|
||||
: item));
|
||||
const hasSelectedSuccess = itemsRef.current.some((item) => item.id === activeItemIdRef.current && item.status === "success" && item.result);
|
||||
if (!hasSelectedSuccess) {
|
||||
setActiveSelection(entry.id);
|
||||
}
|
||||
}
|
||||
else {
|
||||
failCount++;
|
||||
setItems((prev) => prev.map((item) => item.id === entry.id
|
||||
? {
|
||||
...item,
|
||||
status: "error",
|
||||
error: outcome.error || "Analysis failed",
|
||||
}
|
||||
: item));
|
||||
if (!activeItemIdRef.current) {
|
||||
setActiveSelection(entry.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (batchRunIdRef.current === runId) {
|
||||
setBatchProgress({
|
||||
completed: entries.length,
|
||||
total: entries.length,
|
||||
fileName: "",
|
||||
});
|
||||
if (successCount > 0) {
|
||||
toast.success("Batch Analysis Complete", {
|
||||
description: `Successfully analyzed ${successCount} file(s)${failCount > 0 ? `, ${failCount} failed` : ""}`,
|
||||
});
|
||||
}
|
||||
else if (failCount > 0) {
|
||||
toast.error("Batch Analysis Failed", {
|
||||
description: `All ${failCount} file(s) failed to analyze`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
finally {
|
||||
if (batchRunIdRef.current === runId) {
|
||||
setIsBatchRunning(false);
|
||||
}
|
||||
}
|
||||
}, [analyzeFile, analyzeFilePath, setActiveSelection]);
|
||||
const ensureIdleQueue = useCallback(() => {
|
||||
if (!isBatchRunning) {
|
||||
return true;
|
||||
}
|
||||
toast.info("Analysis in progress", {
|
||||
description: "Please wait for the current batch to finish or clear it first.",
|
||||
});
|
||||
return false;
|
||||
}, [isBatchRunning]);
|
||||
const addPathItems = useCallback(async (paths: string[]) => {
|
||||
if (!ensureIdleQueue()) {
|
||||
return;
|
||||
}
|
||||
const uniquePaths = Array.from(new Set(paths.filter(Boolean)));
|
||||
const invalidCount = uniquePaths.filter((path) => !isSupportedAudioPath(path)).length;
|
||||
const validPaths = uniquePaths.filter(isSupportedAudioPath);
|
||||
if (invalidCount > 0) {
|
||||
toast.error("Unsupported format", {
|
||||
description: `Only ${SUPPORTED_AUDIO_LABEL} files can be analyzed.`,
|
||||
});
|
||||
}
|
||||
if (validPaths.length === 0) {
|
||||
return;
|
||||
}
|
||||
const existingIds = new Set(itemsRef.current.map((item) => item.id));
|
||||
const newPaths = validPaths.filter((path) => !existingIds.has(path));
|
||||
if (newPaths.length === 0) {
|
||||
toast.info("No new files added", {
|
||||
description: "All selected files were already in the batch queue.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const fileSizes = await GetFileSizes(newPaths);
|
||||
const newItems = newPaths.map((path) => ({
|
||||
id: path,
|
||||
source: "path" as const,
|
||||
path,
|
||||
name: fileNameFromPath(path),
|
||||
size: fileSizes[path] || 0,
|
||||
status: "pending" as const,
|
||||
}));
|
||||
if (validPaths.length !== newPaths.length) {
|
||||
toast.info("Some files skipped", {
|
||||
description: `${validPaths.length - newPaths.length} file(s) were already queued.`,
|
||||
});
|
||||
}
|
||||
setItems((prev) => [...prev, ...newItems]);
|
||||
if (!activeItemIdRef.current) {
|
||||
setActiveSelection(newItems[0]?.id ?? null);
|
||||
}
|
||||
void runBatchAnalysis(newItems);
|
||||
}, [ensureIdleQueue, runBatchAnalysis, setActiveSelection]);
|
||||
const addBrowserFiles = useCallback(async (files: File[]) => {
|
||||
if (!ensureIdleQueue()) {
|
||||
return;
|
||||
}
|
||||
const validFiles = files.filter(isSupportedAudioFile);
|
||||
const invalidCount = files.length - validFiles.length;
|
||||
if (invalidCount > 0) {
|
||||
toast.error("Unsupported format", {
|
||||
description: `Only ${SUPPORTED_AUDIO_LABEL} files can be analyzed.`,
|
||||
});
|
||||
}
|
||||
if (validFiles.length === 0) {
|
||||
return;
|
||||
}
|
||||
const existingIds = new Set(itemsRef.current.map((item) => item.id));
|
||||
const newItems = validFiles
|
||||
.map((file) => ({
|
||||
id: browserFileId(file),
|
||||
source: "browser" as const,
|
||||
path: file.name,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
status: "pending" as const,
|
||||
file,
|
||||
}))
|
||||
.filter((item) => !existingIds.has(item.id));
|
||||
if (newItems.length === 0) {
|
||||
toast.info("No new files added", {
|
||||
description: "All selected files were already in the batch queue.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (validFiles.length !== newItems.length) {
|
||||
toast.info("Some files skipped", {
|
||||
description: `${validFiles.length - newItems.length} file(s) were already queued.`,
|
||||
});
|
||||
}
|
||||
setItems((prev) => [...prev, ...newItems]);
|
||||
if (!activeItemIdRef.current) {
|
||||
setActiveSelection(newItems[0]?.id ?? null);
|
||||
}
|
||||
void runBatchAnalysis(newItems);
|
||||
}, [ensureIdleQueue, runBatchAnalysis, setActiveSelection]);
|
||||
const handleSelectFiles = useCallback(async () => {
|
||||
if (!ensureIdleQueue()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const selectedPaths = await SelectAudioFiles();
|
||||
if (selectedPaths && selectedPaths.length > 0) {
|
||||
await addPathItems(selectedPaths);
|
||||
}
|
||||
return;
|
||||
}
|
||||
catch {
|
||||
fileInputRef.current?.click();
|
||||
return;
|
||||
}
|
||||
}, [addPathItems, ensureIdleQueue]);
|
||||
const handleSelectFolder = useCallback(async () => {
|
||||
if (!ensureIdleQueue()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const selectedFolder = await SelectFolder("");
|
||||
if (!selectedFolder) {
|
||||
return;
|
||||
}
|
||||
const folderFiles = await ListAudioFilesInDir(selectedFolder);
|
||||
if (!folderFiles || folderFiles.length === 0) {
|
||||
toast.info("No audio files found", {
|
||||
description: `No ${SUPPORTED_AUDIO_LABEL} files were found in the selected folder.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await addPathItems(folderFiles.map((file) => file.path));
|
||||
}
|
||||
catch (err) {
|
||||
toast.error("File Selection Failed", {
|
||||
description: err instanceof Error ? err.message : "Failed to select file",
|
||||
toast.error("Folder Selection Failed", {
|
||||
description: err instanceof Error ? err.message : "Failed to select folder",
|
||||
});
|
||||
}
|
||||
};
|
||||
const handleFileDrop = useCallback(async (_x: number, _y: number, paths: string[]) => {
|
||||
}, [addPathItems, ensureIdleQueue]);
|
||||
const handleInputChange = useCallback(async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(event.target.files ?? []);
|
||||
event.target.value = "";
|
||||
if (files.length === 0) {
|
||||
return;
|
||||
}
|
||||
await addBrowserFiles(files);
|
||||
}, [addBrowserFiles]);
|
||||
const handleHtmlDrop = useCallback(async (event: DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
setIsDragging(false);
|
||||
if (paths.length === 0)
|
||||
return;
|
||||
const filePath = paths[0];
|
||||
if (!filePath.toLowerCase().endsWith(".flac")) {
|
||||
toast.error("Invalid File Type", {
|
||||
description: "Please drop a FLAC file for analysis",
|
||||
});
|
||||
const files = Array.from(event.dataTransfer.files ?? []);
|
||||
if (files.length === 0) {
|
||||
return;
|
||||
}
|
||||
await analyzeFile(filePath);
|
||||
}, [analyzeFile]);
|
||||
await addBrowserFiles(files);
|
||||
}, [addBrowserFiles]);
|
||||
useEffect(() => {
|
||||
OnFileDrop((x, y, paths) => {
|
||||
handleFileDrop(x, y, paths);
|
||||
OnFileDrop((_x, _y, paths) => {
|
||||
setIsDragging(false);
|
||||
if (!paths || paths.length === 0) {
|
||||
return;
|
||||
}
|
||||
void addPathItems(paths);
|
||||
}, true);
|
||||
return () => {
|
||||
OnFileDropOff();
|
||||
};
|
||||
}, [handleFileDrop]);
|
||||
const handleAnalyzeAnother = () => {
|
||||
}, [addPathItems]);
|
||||
const handleSelectItem = useCallback((itemId: string) => {
|
||||
setActiveSelection(itemId);
|
||||
}, [setActiveSelection]);
|
||||
const handleRemoveItem = useCallback((itemId: string) => {
|
||||
if (isBatchRunning || isExportingBatch || isExportingSelected || spectrumLoading) {
|
||||
return;
|
||||
}
|
||||
clearStoredAnalysis(itemId);
|
||||
const nextItems = itemsRef.current.filter((item) => item.id !== itemId);
|
||||
itemsRef.current = nextItems;
|
||||
setItems(nextItems);
|
||||
if (activeItemIdRef.current === itemId) {
|
||||
const nextActive = nextItems.find((item) => item.status === "success" && item.result) ?? nextItems[0] ?? null;
|
||||
setActiveSelection(nextActive?.id ?? null);
|
||||
if (!nextActive) {
|
||||
clearResult();
|
||||
}
|
||||
}
|
||||
}, [clearResult, clearStoredAnalysis, isBatchRunning, isExportingBatch, isExportingSelected, setActiveSelection, spectrumLoading]);
|
||||
const handleClearAll = useCallback(() => {
|
||||
if (isExportingBatch || isExportingSelected) {
|
||||
return;
|
||||
}
|
||||
batchRunIdRef.current += 1;
|
||||
itemsRef.current = [];
|
||||
setItems([]);
|
||||
setActiveSelection(null);
|
||||
clearStoredAnalysis();
|
||||
clearResult();
|
||||
};
|
||||
setIsBatchRunning(false);
|
||||
setBatchProgress(EMPTY_PROGRESS_STATE);
|
||||
setExportProgress(EMPTY_PROGRESS_STATE);
|
||||
setIsDragging(false);
|
||||
}, [clearResult, clearStoredAnalysis, isExportingBatch, isExportingSelected, setActiveSelection]);
|
||||
const handleStopBatch = useCallback(() => {
|
||||
if (!isBatchRunning) {
|
||||
return;
|
||||
}
|
||||
batchRunIdRef.current += 1;
|
||||
cancelAnalysis();
|
||||
setIsBatchRunning(false);
|
||||
setBatchProgress(EMPTY_PROGRESS_STATE);
|
||||
setItems((prev) => prev.map((item) => item.status === "analyzing"
|
||||
? {
|
||||
...item,
|
||||
status: "pending",
|
||||
}
|
||||
: item));
|
||||
toast.info("Batch analysis stopped", {
|
||||
description: "Click Analyze to continue the remaining files.",
|
||||
});
|
||||
}, [cancelAnalysis, isBatchRunning]);
|
||||
const handleAnalyzePending = useCallback(() => {
|
||||
if (isBatchRunning || isExportingBatch || isExportingSelected || spectrumLoading) {
|
||||
return;
|
||||
}
|
||||
const nextPendingItems = itemsRef.current.filter((item) => item.status === "pending");
|
||||
if (nextPendingItems.length === 0) {
|
||||
return;
|
||||
}
|
||||
void runBatchAnalysis(nextPendingItems);
|
||||
}, [isBatchRunning, isExportingBatch, isExportingSelected, runBatchAnalysis, spectrumLoading]);
|
||||
const handleExportSelected = useCallback(async () => {
|
||||
if (!activeItem?.result?.spectrum || !spectrumRef.current) {
|
||||
return;
|
||||
}
|
||||
const dataUrl = spectrumRef.current.getCanvasDataURL();
|
||||
if (!dataUrl) {
|
||||
toast.error("Export Failed", {
|
||||
description: "Cannot get canvas data",
|
||||
});
|
||||
return;
|
||||
}
|
||||
setIsExportingSelected(true);
|
||||
try {
|
||||
if (activeItem.source === "path" && isAbsolutePath(activeItem.path)) {
|
||||
const outPath = await SaveSpectrumImage(activeItem.path, dataUrl);
|
||||
toast.success("PNG Exported", {
|
||||
description: `Saved to: ${outPath}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const baseName = activeItem.name.replace(/\.[^/.]+$/, "") || "spectrogram";
|
||||
downloadDataURL(dataUrl, `${baseName}_spectrogram.png`);
|
||||
toast.success("PNG Exported", {
|
||||
description: "Spectrogram image downloaded",
|
||||
});
|
||||
}
|
||||
catch (err) {
|
||||
toast.error("Export Failed", {
|
||||
description: err instanceof Error ? err.message : "Failed to export image",
|
||||
});
|
||||
}
|
||||
finally {
|
||||
setIsExportingSelected(false);
|
||||
}
|
||||
}, [activeItem]);
|
||||
const handleBatchExport = useCallback(async () => {
|
||||
const exportableItems = itemsRef.current.filter((item) => item.status === "success" && item.result?.spectrum);
|
||||
if (exportableItems.length === 0) {
|
||||
toast.error("Nothing to export", {
|
||||
description: "Analyze at least one file successfully before exporting PNGs.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const preferences = loadAudioAnalysisPreferences();
|
||||
setIsExportingBatch(true);
|
||||
setExportProgress({
|
||||
completed: 0,
|
||||
total: exportableItems.length,
|
||||
fileName: exportableItems[0]?.name ?? "",
|
||||
});
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
try {
|
||||
for (let index = 0; index < exportableItems.length; index++) {
|
||||
const item = exportableItems[index];
|
||||
const result = item.result;
|
||||
if (!result?.spectrum) {
|
||||
failCount++;
|
||||
continue;
|
||||
}
|
||||
setExportProgress({
|
||||
completed: index,
|
||||
total: exportableItems.length,
|
||||
fileName: item.name,
|
||||
});
|
||||
try {
|
||||
const dataUrl = await createSpectrogramDataURL({
|
||||
spectrumData: result.spectrum,
|
||||
sampleRate: result.sample_rate,
|
||||
duration: result.duration,
|
||||
freqScale: preferences.freqScale,
|
||||
colorScheme: preferences.colorScheme,
|
||||
fileName: item.name,
|
||||
});
|
||||
if (item.source === "path" && isAbsolutePath(item.path)) {
|
||||
await SaveSpectrumImage(item.path, dataUrl);
|
||||
}
|
||||
else {
|
||||
const baseName = item.name.replace(/\.[^/.]+$/, "") || "spectrogram";
|
||||
downloadDataURL(dataUrl, `${baseName}_spectrogram.png`);
|
||||
}
|
||||
successCount++;
|
||||
}
|
||||
catch {
|
||||
failCount++;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
setExportProgress({
|
||||
completed: exportableItems.length,
|
||||
total: exportableItems.length,
|
||||
fileName: "",
|
||||
});
|
||||
if (successCount > 0) {
|
||||
toast.success("Batch PNG Export Complete", {
|
||||
description: `Exported ${successCount} spectrogram PNG file(s)${failCount > 0 ? `, ${failCount} failed` : ""}`,
|
||||
});
|
||||
}
|
||||
else {
|
||||
toast.error("Batch PNG Export Failed", {
|
||||
description: "No spectrogram PNG files were exported.",
|
||||
});
|
||||
}
|
||||
}
|
||||
finally {
|
||||
setIsExportingBatch(false);
|
||||
}
|
||||
}, []);
|
||||
const handleReAnalyzeSelectedSpectrum = useCallback(async (fftSize: number, windowFunction: string) => {
|
||||
if (!activeItem?.result) {
|
||||
return;
|
||||
}
|
||||
const nextResult = await reAnalyzeSpectrum(fftSize, windowFunction);
|
||||
if (!nextResult) {
|
||||
return;
|
||||
}
|
||||
setItems((prev) => prev.map((item) => item.id === activeItem.id
|
||||
? {
|
||||
...item,
|
||||
result: nextResult,
|
||||
status: "success",
|
||||
error: undefined,
|
||||
}
|
||||
: item));
|
||||
}, [activeItem, reAnalyzeSpectrum]);
|
||||
const batchDetailContent = !activeItem ? (<Card>
|
||||
<CardContent className="flex min-h-[320px] items-center justify-center px-6 py-10">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Select a file from the batch queue to inspect its analysis result.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>) : activeItem.status !== "success" || !activeItem.result ? (<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">{activeItem.name}</CardTitle>
|
||||
<p className="break-all font-mono text-sm text-muted-foreground">{activeItem.path}</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{activeItem.status === "analyzing" && (<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Spinner />
|
||||
<span className="text-sm text-muted-foreground">Analyzing audio quality...</span>
|
||||
</div>
|
||||
<Progress value={analysisProgress.percent} className="h-2 w-full"/>
|
||||
<p className="text-xs text-muted-foreground">{analysisProgress.message}</p>
|
||||
</div>)}
|
||||
{activeItem.status === "pending" && (<p className="text-sm text-muted-foreground">
|
||||
This file is queued and waiting for batch analysis to start.
|
||||
</p>)}
|
||||
{activeItem.status === "error" && (<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
|
||||
{activeItem.error || "Analysis failed"}
|
||||
</div>)}
|
||||
</CardContent>
|
||||
</Card>) : (<div className="space-y-4">
|
||||
<AudioAnalysis result={activeItem.result} analyzing={false} showAnalyzeButton={false} filePath={activeItem.path}/>
|
||||
|
||||
<SpectrumVisualization ref={spectrumRef} sampleRate={activeItem.result.sample_rate} duration={activeItem.result.duration} spectrumData={activeItem.result.spectrum} fileName={activeItem.name} onReAnalyze={handleReAnalyzeSelectedSpectrum} isAnalyzingSpectrum={spectrumLoading} spectrumProgress={spectrumProgress}/>
|
||||
</div>);
|
||||
const singleModeContent = !activeItem ? null : activeItem.status === "success" && activeItem.result ? (<div className="mx-auto w-full max-w-6xl space-y-4">
|
||||
<AudioAnalysis result={activeItem.result} analyzing={false} showAnalyzeButton={false} filePath={activeItem.path}/>
|
||||
|
||||
<SpectrumVisualization ref={spectrumRef} sampleRate={activeItem.result.sample_rate} duration={activeItem.result.duration} spectrumData={activeItem.result.spectrum} fileName={activeItem.name} onReAnalyze={handleReAnalyzeSelectedSpectrum} isAnalyzingSpectrum={spectrumLoading} spectrumProgress={spectrumProgress}/>
|
||||
</div>) : activeItem.status === "analyzing" || activeItem.status === "pending" ? (<div className="flex h-[400px] items-center justify-center">
|
||||
<div className="w-full max-w-md space-y-2">
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<span>{activeItem.status === "pending" ? "Preparing..." : "Processing..."}</span>
|
||||
<span className="tabular-nums">{analysisProgress.percent}%</span>
|
||||
</div>
|
||||
<Progress value={analysisProgress.percent} className="h-2 w-full"/>
|
||||
<p className="text-center text-xs text-muted-foreground">{analysisProgress.message}</p>
|
||||
</div>
|
||||
</div>) : (<div className="flex h-[400px] items-center justify-center">
|
||||
<div className="w-full max-w-md rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
|
||||
{activeItem.error || "Analysis failed"}
|
||||
</div>
|
||||
</div>);
|
||||
const showSingleModeActions = isSingleMode && activeItem?.status === "success" && activeItem.result;
|
||||
return (<div className="space-y-6">
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
{onBack && (<Button variant="ghost" size="icon" onClick={onBack}>
|
||||
<ArrowLeft className="h-5 w-5"/>
|
||||
</Button>)}
|
||||
<h1 className="text-2xl font-bold">Audio Quality Analyzer</h1>
|
||||
</div>
|
||||
{result && (<Button onClick={handleAnalyzeAnother} variant="outline" size="sm">
|
||||
<Trash2 className="h-4 w-4"/>
|
||||
Clear
|
||||
</Button>)}
|
||||
</div>
|
||||
<input ref={fileInputRef} type="file" multiple accept={SUPPORTED_AUDIO_ACCEPT} className="hidden" onChange={handleInputChange}/>
|
||||
|
||||
|
||||
{!result && !analyzing && (<div className={`flex flex-col items-center justify-center h-[400px] border-2 border-dashed rounded-lg transition-colors ${isDragging
|
||||
? "border-primary bg-primary/10"
|
||||
: "border-muted-foreground/30"}`} onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-4">
|
||||
{onBack && (<Button variant="ghost" size="icon" onClick={onBack}>
|
||||
<ArrowLeft className="h-5 w-5"/>
|
||||
</Button>)}
|
||||
<h1 className="text-2xl font-bold">Audio Quality Analyzer</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{isBatchMode && isBatchRunning && (<Button onClick={handleStopBatch} variant="destructive" size="sm" disabled={isExportingBatch || isExportingSelected} className="gap-1.5">
|
||||
<StopCircle className="h-4 w-4"/>
|
||||
Stop
|
||||
</Button>)}
|
||||
{canResumeBatch && (<Button onClick={handleAnalyzePending} variant="outline" size="sm" disabled={isExportingBatch || isExportingSelected || spectrumLoading}>
|
||||
<Play className="h-4 w-4"/>
|
||||
Analyze
|
||||
</Button>)}
|
||||
{isBatchMode && (<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" disabled={isBatchRunning || isExportingBatch || isExportingSelected}>
|
||||
<Upload className="h-4 w-4 mr-1"/>
|
||||
Add
|
||||
<ChevronDown className="ml-1 h-4 w-4"/>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-[180px]">
|
||||
<DropdownMenuItem onClick={handleSelectFiles} className="cursor-pointer">
|
||||
<Upload className="h-4 w-4"/>
|
||||
Add Files
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleSelectFolder} className="cursor-pointer">
|
||||
<FolderOpen className="h-4 w-4"/>
|
||||
Add Folder
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>)}
|
||||
{showSingleModeActions && (<Button onClick={handleExportSelected} variant="outline" size="sm" disabled={isExportingSelected || spectrumLoading}>
|
||||
<Download className="h-4 w-4 mr-1"/>
|
||||
{isExportingSelected ? "Exporting..." : "Export PNG"}
|
||||
</Button>)}
|
||||
{isBatchMode && (<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" disabled={successItems.length === 0 || isExportingBatch || isExportingSelected || isBatchRunning || spectrumLoading}>
|
||||
<Download className="h-4 w-4 mr-1"/>
|
||||
{isExportingBatch ? "Exporting..." : isExportingSelected ? "Exporting..." : "Export"}
|
||||
<ChevronDown className="ml-1 h-4 w-4"/>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-[200px]">
|
||||
<DropdownMenuItem onClick={handleExportSelected} className="cursor-pointer" disabled={!activeItem?.result?.spectrum}>
|
||||
<Download className="h-4 w-4"/>
|
||||
Export Selected PNG
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleBatchExport} className="cursor-pointer" disabled={successItems.length === 0}>
|
||||
<Download className="h-4 w-4"/>
|
||||
Export All PNG
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>)}
|
||||
{showSingleModeActions && (<Button onClick={handleClearAll} variant="outline" size="sm" disabled={isExportingSelected}>
|
||||
<Trash2 className="h-4 w-4 mr-1"/>
|
||||
Clear
|
||||
</Button>)}
|
||||
{isBatchMode && (<Button onClick={handleClearAll} variant="outline" size="sm" disabled={isExportingBatch || isExportingSelected}>
|
||||
<Trash2 className="h-4 w-4 mr-1"/>
|
||||
Clear
|
||||
</Button>)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{items.length === 0 && (<div className={`flex h-[400px] flex-col items-center justify-center rounded-lg border-2 border-dashed transition-all ${isDragging ? "border-primary bg-primary/10" : "border-muted-foreground/30"}`} onDragOver={(event) => {
|
||||
event.preventDefault();
|
||||
setIsDragging(true);
|
||||
}} onDragLeave={(e) => {
|
||||
e.preventDefault();
|
||||
}} onDragLeave={(event) => {
|
||||
event.preventDefault();
|
||||
setIsDragging(false);
|
||||
}} onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
}} style={{ "--wails-drop-target": "drop" } as React.CSSProperties}>
|
||||
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
|
||||
<Upload className="h-8 w-8 text-primary"/>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-4 text-center">
|
||||
{isDragging
|
||||
? "Drop your FLAC file here"
|
||||
: "Drag and drop a FLAC file here, or click the button below to select"}
|
||||
</p>
|
||||
<Button onClick={handleSelectFile} size="lg">
|
||||
<Upload className="h-5 w-5"/>
|
||||
Select FLAC File
|
||||
</Button>
|
||||
</div>)}
|
||||
}} onDrop={handleHtmlDrop} style={{ "--wails-drop-target": "drop" } as CSSProperties}>
|
||||
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
|
||||
<Upload className="h-8 w-8 text-primary"/>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-4 text-center">
|
||||
{isDragging
|
||||
? "Drop your audio files here"
|
||||
: "Drag and drop audio files here, or click the button below to select"}
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<Button onClick={handleSelectFiles} size="lg">
|
||||
<Upload className="h-5 w-5"/>
|
||||
Select Files
|
||||
</Button>
|
||||
<Button onClick={handleSelectFolder} size="lg" variant="outline">
|
||||
<Upload className="h-5 w-5"/>
|
||||
Select Folder
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-4 text-center">
|
||||
Supported formats: FLAC, MP3, M4A, AAC
|
||||
</p>
|
||||
</div>)}
|
||||
|
||||
|
||||
{analyzing && !result && (<div className="flex flex-col items-center justify-center py-16">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mb-4"></div>
|
||||
<p className="text-sm text-muted-foreground">Analyzing audio file...</p>
|
||||
</div>)}
|
||||
{isSingleMode && (<div className="space-y-4">
|
||||
{singleModeContent}
|
||||
</div>)}
|
||||
|
||||
|
||||
{result && (<div className="space-y-4">
|
||||
|
||||
<AudioAnalysis result={result} analyzing={analyzing} showAnalyzeButton={false} filePath={selectedFilePath}/>
|
||||
{isBatchMode && (<div className="grid gap-4 xl:grid-cols-[360px,minmax(0,1fr)]">
|
||||
<div className="space-y-3">
|
||||
{(isBatchRunning || isExportingBatch) && (<Card className="gap-2 py-4">
|
||||
<CardHeader className="px-4 pb-0">
|
||||
<CardTitle className="text-sm">
|
||||
{isExportingBatch ? "Batch PNG Export" : "Batch Analysis"}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 px-4">
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span className="truncate pr-3">
|
||||
{isExportingBatch
|
||||
? exportProgress.fileName || "Preparing export..."
|
||||
: batchProgress.fileName || analysisProgress.message}
|
||||
</span>
|
||||
<span className="tabular-nums">
|
||||
{isExportingBatch
|
||||
? `${exportProgress.completed}/${exportProgress.total}`
|
||||
: `${Math.min(batchProgress.completed + (isBatchRunning ? 1 : 0), batchProgress.total)}/${batchProgress.total}`}
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={isExportingBatch ? exportPercent : batchPercent} className="h-1.5 w-full"/>
|
||||
{!isExportingBatch && (<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>{analysisProgress.message}</span>
|
||||
<span className="tabular-nums">{analysisProgress.percent}%</span>
|
||||
</div>)}
|
||||
</CardContent>
|
||||
</Card>)}
|
||||
|
||||
|
||||
{spectrumLoading ? (<div className="flex flex-col items-center justify-center py-16 border rounded-lg">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mb-2"></div>
|
||||
<p className="text-sm text-muted-foreground">Loading spectrum data...</p>
|
||||
</div>) : (<SpectrumVisualization sampleRate={result.sample_rate} bitsPerSample={result.bits_per_sample} duration={result.duration} spectrumData={result.spectrum}/>)}
|
||||
</div>)}
|
||||
</div>);
|
||||
<Card className="gap-2 overflow-hidden py-4">
|
||||
<CardHeader className="px-4 pb-0">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<CardTitle className="text-sm">Batch Queue</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{items.length} queued • {successItems.length} ready
|
||||
</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4">
|
||||
<div className="max-h-[232px] space-y-2 overflow-y-auto pr-1">
|
||||
{items.map((item) => {
|
||||
const isActive = item.id === activeItemId;
|
||||
const isSelectable = item.status !== "pending";
|
||||
return (<div key={item.id} role={isSelectable ? "button" : undefined} tabIndex={isSelectable ? 0 : -1} className={`flex w-full items-start gap-2.5 rounded-lg border px-3 py-2.5 text-left transition-colors ${isActive
|
||||
? "border-primary bg-primary/5"
|
||||
: isSelectable
|
||||
? "border-border hover:border-primary/40"
|
||||
: "border-border"}`} onClick={() => {
|
||||
if (!isSelectable) {
|
||||
return;
|
||||
}
|
||||
handleSelectItem(item.id);
|
||||
}} onKeyDown={(event) => {
|
||||
if (!isSelectable) {
|
||||
return;
|
||||
}
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
handleSelectItem(item.id);
|
||||
}
|
||||
}}>
|
||||
<div className="mt-0.5 shrink-0">{statusIcon(item.status)}</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{item.name}</p>
|
||||
<p className={`truncate text-xs ${item.status === "error" ? "text-destructive" : "text-muted-foreground"}`}>
|
||||
{itemMetaLine(item)}
|
||||
</p>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2 text-[11px] text-muted-foreground">
|
||||
<span>{formatFileSize(item.size)}</span>
|
||||
<span>{fileNameFromPath(item.path).split(".").pop()?.toUpperCase() || "AUDIO"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="button" variant="ghost" size="icon" className="h-8 w-8 shrink-0" onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
handleRemoveItem(item.id);
|
||||
}} disabled={isBatchRunning || isExportingBatch || isExportingSelected || spectrumLoading}>
|
||||
<X className="h-4 w-4"/>
|
||||
</Button>
|
||||
</div>);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{batchDetailContent}
|
||||
</div>
|
||||
</div>)}
|
||||
</div>);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { ToggleGroup, ToggleGroupItem, } from "@/components/ui/toggle-group";
|
||||
import { Upload, Download, X, CheckCircle2, AlertCircle, Trash2, FileMusic, WandSparkles, } from "lucide-react";
|
||||
import { Upload, X, CheckCircle2, AlertCircle, Trash2, FileMusic, WandSparkles, } from "lucide-react";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { IsFFmpegInstalled, DownloadFFmpeg, ConvertAudio, SelectAudioFiles, } from "../../wailsjs/go/main/App";
|
||||
import { ConvertAudio, SelectAudioFiles, SelectFolder, ListAudioFilesInDir, } from "../../wailsjs/go/main/App";
|
||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||
import { OnFileDrop, OnFileDropOff } from "../../wailsjs/runtime/runtime";
|
||||
import { useDownloadProgress } from "@/hooks/useDownloadProgress";
|
||||
interface AudioFile {
|
||||
path: string;
|
||||
name: string;
|
||||
@@ -38,9 +36,6 @@ const M4A_CODEC_OPTIONS = [
|
||||
];
|
||||
const STORAGE_KEY = "spotiflac_audio_converter_state";
|
||||
export function AudioConverterPage() {
|
||||
const [ffmpegInstalled, setFfmpegInstalled] = useState<boolean>(false);
|
||||
const [installingFfmpeg, setInstallingFfmpeg] = useState(false);
|
||||
const downloadProgress = useDownloadProgress();
|
||||
const [files, setFiles] = useState<AudioFile[]>(() => {
|
||||
try {
|
||||
const saved = sessionStorage.getItem(STORAGE_KEY);
|
||||
@@ -114,9 +109,6 @@ export function AudioConverterPage() {
|
||||
console.error("Failed to save state:", err);
|
||||
}
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
checkFfmpegInstallation();
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
saveState({ files, outputFormat, bitrate, m4aCodec });
|
||||
}, [files, outputFormat, bitrate, m4aCodec, saveState]);
|
||||
@@ -147,41 +139,6 @@ export function AudioConverterPage() {
|
||||
window.removeEventListener("focus", checkFullscreen);
|
||||
};
|
||||
}, []);
|
||||
const checkFfmpegInstallation = async () => {
|
||||
try {
|
||||
const installed = await IsFFmpegInstalled();
|
||||
setFfmpegInstalled(installed);
|
||||
}
|
||||
catch (err) {
|
||||
console.error("Failed to check ffmpeg:", err);
|
||||
setFfmpegInstalled(false);
|
||||
}
|
||||
};
|
||||
const handleInstallFfmpeg = async () => {
|
||||
setInstallingFfmpeg(true);
|
||||
try {
|
||||
const result = await DownloadFFmpeg();
|
||||
if (result.success) {
|
||||
toast.success("FFmpeg Installed", {
|
||||
description: "FFmpeg has been installed successfully",
|
||||
});
|
||||
setFfmpegInstalled(true);
|
||||
}
|
||||
else {
|
||||
toast.error("Installation Failed", {
|
||||
description: result.error || "Failed to install FFmpeg",
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
toast.error("Installation Failed", {
|
||||
description: err instanceof Error ? err.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
finally {
|
||||
setInstallingFfmpeg(false);
|
||||
}
|
||||
};
|
||||
const handleSelectFiles = async () => {
|
||||
try {
|
||||
const selectedFiles = await SelectAudioFiles();
|
||||
@@ -195,6 +152,27 @@ export function AudioConverterPage() {
|
||||
});
|
||||
}
|
||||
};
|
||||
const handleSelectFolder = async () => {
|
||||
try {
|
||||
const selectedFolder = await SelectFolder("");
|
||||
if (selectedFolder) {
|
||||
const folderFiles = await ListAudioFilesInDir(selectedFolder);
|
||||
if (folderFiles && folderFiles.length > 0) {
|
||||
addFiles(folderFiles.map((f) => f.path));
|
||||
}
|
||||
else {
|
||||
toast.info("No audio files found", {
|
||||
description: "No FLAC or MP3 files found in the selected folder.",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
toast.error("Folder Selection Failed", {
|
||||
description: err instanceof Error ? err.message : "Failed to select folder",
|
||||
});
|
||||
}
|
||||
};
|
||||
const addFiles = useCallback(async (paths: string[]) => {
|
||||
const validExtensions = [".mp3", ".flac"];
|
||||
const m4aFiles = paths.filter((path) => {
|
||||
@@ -250,15 +228,13 @@ export function AudioConverterPage() {
|
||||
addFiles(paths);
|
||||
}, [addFiles]);
|
||||
useEffect(() => {
|
||||
if (ffmpegInstalled === true) {
|
||||
OnFileDrop((x, y, paths) => {
|
||||
handleFileDrop(x, y, paths);
|
||||
}, true);
|
||||
return () => {
|
||||
OnFileDropOff();
|
||||
};
|
||||
}
|
||||
}, [handleFileDrop, ffmpegInstalled]);
|
||||
OnFileDrop((x, y, paths) => {
|
||||
handleFileDrop(x, y, paths);
|
||||
}, true);
|
||||
return () => {
|
||||
OnFileDropOff();
|
||||
};
|
||||
}, [handleFileDrop]);
|
||||
const removeFile = (path: string) => {
|
||||
setFiles((prev) => prev.filter((f) => f.path !== path));
|
||||
};
|
||||
@@ -288,7 +264,7 @@ export function AudioConverterPage() {
|
||||
codec: outputFormat === "m4a" ? m4aCodec : "",
|
||||
});
|
||||
setFiles((prev) => prev.map((f) => {
|
||||
const result = results.find((r) => r.input_file === f.path);
|
||||
const result = results.find((r) => r.input_file === f.path || r.input_file.toLowerCase() === f.path.toLowerCase());
|
||||
if (result) {
|
||||
return {
|
||||
...f,
|
||||
@@ -336,62 +312,28 @@ export function AudioConverterPage() {
|
||||
};
|
||||
const convertableCount = files.filter((f) => f.status === "pending" || f.status === "success").length;
|
||||
const successCount = files.filter((f) => f.status === "success").length;
|
||||
if (ffmpegInstalled === false) {
|
||||
return (<div className={`space-y-6 ${isFullscreen ? "h-full flex flex-col" : ""}`}>
|
||||
<div className="flex items-center gap-4">
|
||||
<h1 className="text-2xl font-bold">Audio Converter</h1>
|
||||
</div>
|
||||
return (<div className={`space-y-6 ${isFullscreen ? "h-full flex flex-col" : ""}`}>
|
||||
|
||||
<div className={`flex flex-col items-center justify-center border-2 border-dashed rounded-lg transition-all ${isFullscreen ? "flex-1 min-h-[400px]" : "h-[400px]"} border-muted-foreground/30`}>
|
||||
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
|
||||
<Download className="h-8 w-8 text-primary"/>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-4 text-center">
|
||||
FFmpeg is required to convert audio files
|
||||
</p>
|
||||
<Button onClick={handleInstallFfmpeg} disabled={installingFfmpeg} size="lg">
|
||||
{installingFfmpeg ? (<>
|
||||
<Spinner className="h-5 w-5"/>
|
||||
Installing FFmpeg...
|
||||
</>) : (<>
|
||||
<Download className="h-5 w-5"/>
|
||||
Install FFmpeg
|
||||
</>)}
|
||||
</Button>
|
||||
|
||||
{installingFfmpeg && downloadProgress.is_downloading && downloadProgress.mb_downloaded > 0 && (<div className="w-full max-w-md mt-6 space-y-2 px-4">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Downloading FFmpeg</span>
|
||||
<span className="font-mono tabular-nums">
|
||||
{downloadProgress.mb_downloaded.toFixed(2)} MB
|
||||
{downloadProgress.speed_mbps > 0 && (<span className="text-muted-foreground ml-2">
|
||||
@ {downloadProgress.speed_mbps.toFixed(2)} MB/s
|
||||
</span>)}
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={Math.min(100, (downloadProgress.mb_downloaded / 200) * 100)} className="h-2"/>
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Audio Converter</h1>
|
||||
{files.length > 0 && (<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleSelectFiles}>
|
||||
<Upload className="h-4 w-4"/>
|
||||
Add Files
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleSelectFolder}>
|
||||
<Upload className="h-4 w-4"/>
|
||||
Add Folder
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={clearFiles} disabled={converting}>
|
||||
<Trash2 className="h-4 w-4"/>
|
||||
Clear All
|
||||
</Button>
|
||||
</div>)}
|
||||
</div>
|
||||
</div>);
|
||||
}
|
||||
return (<div className={`space-y-6 ${isFullscreen ? "h-full flex flex-col" : ""}`}>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Audio Converter</h1>
|
||||
{files.length > 0 && (<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleSelectFiles}>
|
||||
<Upload className="h-4 w-4"/>
|
||||
Add More
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={clearFiles} disabled={converting}>
|
||||
<Trash2 className="h-4 w-4"/>
|
||||
Clear All
|
||||
</Button>
|
||||
</div>)}
|
||||
</div>
|
||||
|
||||
|
||||
<div className={`flex flex-col items-center justify-center border-2 border-dashed rounded-lg transition-all ${isFullscreen ? "flex-1 min-h-[400px]" : "h-[400px]"} ${isDragging
|
||||
|
||||
<div className={`flex flex-col items-center justify-center border-2 border-dashed rounded-lg transition-all ${isFullscreen ? "flex-1 min-h-[400px]" : "h-[400px]"} ${isDragging
|
||||
? "border-primary bg-primary/10"
|
||||
: "border-muted-foreground/30"}`} onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
@@ -403,110 +345,116 @@ export function AudioConverterPage() {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
}} style={{ "--wails-drop-target": "drop" } as React.CSSProperties}>
|
||||
{files.length === 0 ? (<>
|
||||
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
|
||||
<Upload className="h-8 w-8 text-primary"/>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-4 text-center">
|
||||
{isDragging
|
||||
{files.length === 0 ? (<>
|
||||
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
|
||||
<Upload className="h-8 w-8 text-primary"/>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-4 text-center">
|
||||
{isDragging
|
||||
? "Drop your audio files here"
|
||||
: "Drag and drop audio files here, or click the button below to select"}
|
||||
</p>
|
||||
<Button onClick={handleSelectFiles} size="lg">
|
||||
<Upload className="h-5 w-5"/>
|
||||
Select Files
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground mt-4 text-center">
|
||||
Supported formats: FLAC, MP3
|
||||
</p>
|
||||
</>) : (<div className="w-full h-full p-6 space-y-4 flex flex-col">
|
||||
|
||||
<div className="space-y-2 pb-4 border-b shrink-0">
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="whitespace-nowrap">Format:</Label>
|
||||
<ToggleGroup type="single" variant="outline" value={outputFormat} onValueChange={(value) => {
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<Button onClick={handleSelectFiles} size="lg">
|
||||
<Upload className="h-5 w-5"/>
|
||||
Select Files
|
||||
</Button>
|
||||
<Button onClick={handleSelectFolder} size="lg" variant="outline">
|
||||
<Upload className="h-5 w-5"/>
|
||||
Select Folder
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-4 text-center">
|
||||
Supported formats: FLAC, MP3
|
||||
</p>
|
||||
</>) : (<div className="w-full h-full p-6 space-y-4 flex flex-col">
|
||||
|
||||
<div className="space-y-2 pb-4 border-b shrink-0">
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="whitespace-nowrap">Format:</Label>
|
||||
<ToggleGroup type="single" variant="outline" value={outputFormat} onValueChange={(value) => {
|
||||
if (value && !isFormatDisabled)
|
||||
setOutputFormat(value as "mp3" | "m4a");
|
||||
}} disabled={isFormatDisabled}>
|
||||
{!isFormatDisabled && (<ToggleGroupItem value="mp3" aria-label="MP3">
|
||||
MP3
|
||||
</ToggleGroupItem>)}
|
||||
<ToggleGroupItem value="m4a" aria-label="M4A" disabled={isFormatDisabled}>
|
||||
M4A
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
|
||||
{outputFormat === "m4a" && hasFlacFiles && (<div className="flex items-center gap-2">
|
||||
<Label className="whitespace-nowrap">Codec:</Label>
|
||||
<ToggleGroup type="single" variant="outline" value={m4aCodec} onValueChange={(value) => {
|
||||
{!isFormatDisabled && (<ToggleGroupItem value="mp3" aria-label="MP3">
|
||||
MP3
|
||||
</ToggleGroupItem>)}
|
||||
<ToggleGroupItem value="m4a" aria-label="M4A" disabled={isFormatDisabled}>
|
||||
M4A
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
|
||||
{outputFormat === "m4a" && hasFlacFiles && (<div className="flex items-center gap-2">
|
||||
<Label className="whitespace-nowrap">Codec:</Label>
|
||||
<ToggleGroup type="single" variant="outline" value={m4aCodec} onValueChange={(value) => {
|
||||
if (value)
|
||||
setM4aCodec(value as "aac" | "alac");
|
||||
}}>
|
||||
{M4A_CODEC_OPTIONS.map((option) => (<ToggleGroupItem key={option.value} value={option.value} aria-label={option.label}>
|
||||
{option.label}
|
||||
</ToggleGroupItem>))}
|
||||
</ToggleGroup>
|
||||
</div>)}
|
||||
|
||||
{!(outputFormat === "m4a" && m4aCodec === "alac") && (<div className="flex items-center gap-2">
|
||||
<Label className="whitespace-nowrap">Bitrate:</Label>
|
||||
<ToggleGroup type="single" variant="outline" value={bitrate} onValueChange={(value) => {
|
||||
{M4A_CODEC_OPTIONS.map((option) => (<ToggleGroupItem key={option.value} value={option.value} aria-label={option.label}>
|
||||
{option.label}
|
||||
</ToggleGroupItem>))}
|
||||
</ToggleGroup>
|
||||
</div>)}
|
||||
|
||||
{!(outputFormat === "m4a" && m4aCodec === "alac") && (<div className="flex items-center gap-2">
|
||||
<Label className="whitespace-nowrap">Bitrate:</Label>
|
||||
<ToggleGroup type="single" variant="outline" value={bitrate} onValueChange={(value) => {
|
||||
if (value)
|
||||
setBitrate(value);
|
||||
}}>
|
||||
{BITRATE_OPTIONS.map((option) => (<ToggleGroupItem key={option.value} value={option.value} aria-label={option.label}>
|
||||
{option.label}
|
||||
</ToggleGroupItem>))}
|
||||
</ToggleGroup>
|
||||
</div>)}
|
||||
{BITRATE_OPTIONS.map((option) => (<ToggleGroupItem key={option.value} value={option.value} aria-label={option.label}>
|
||||
{option.label}
|
||||
</ToggleGroupItem>))}
|
||||
</ToggleGroup>
|
||||
</div>)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex items-center justify-between shrink-0">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{files.length} file(s) • {successCount} converted
|
||||
|
||||
<div className="flex items-center justify-between shrink-0">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{files.length} file(s) • {successCount} converted
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex-1 space-y-2 overflow-y-auto min-h-0">
|
||||
{files.map((file) => (<div key={file.path} className="flex items-center gap-3 rounded-lg border p-3">
|
||||
{getStatusIcon(file.status)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="truncate text-sm font-medium">{file.name}</p>
|
||||
{file.error && (<p className="truncate text-xs text-destructive">
|
||||
{file.error}
|
||||
</p>)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatFileSize(file.size)}
|
||||
</span>
|
||||
<span className="text-xs uppercase text-muted-foreground">
|
||||
{file.format}
|
||||
</span>
|
||||
{file.status !== "converting" && (<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => removeFile(file.path)} disabled={converting}>
|
||||
<X className="h-4 w-4"/>
|
||||
</Button>)}
|
||||
</div>))}
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex justify-center pt-4 border-t shrink-0">
|
||||
<Button onClick={handleConvert} disabled={converting || convertableCount === 0} size="lg">
|
||||
{converting ? (<>
|
||||
<Spinner className="h-4 w-4"/>
|
||||
Converting...
|
||||
</>) : (<>
|
||||
<WandSparkles className="h-4 w-4"/>
|
||||
Convert {convertableCount > 0 ? `${convertableCount} File(s)` : ""}
|
||||
</>)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>)}
|
||||
</div>
|
||||
<div className="flex-1 space-y-2 overflow-y-auto min-h-0">
|
||||
{files.map((file) => (<div key={file.path} className="flex items-center gap-3 rounded-lg border p-3">
|
||||
{getStatusIcon(file.status)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="truncate text-sm font-medium">{file.name}</p>
|
||||
{file.error && (<p className="truncate text-xs text-destructive">
|
||||
{file.error}
|
||||
</p>)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatFileSize(file.size)}
|
||||
</span>
|
||||
<span className="text-xs uppercase text-muted-foreground">
|
||||
{file.format}
|
||||
</span>
|
||||
{file.status !== "converting" && (<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => removeFile(file.path)} disabled={converting}>
|
||||
<X className="h-4 w-4"/>
|
||||
</Button>)}
|
||||
</div>))}
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex justify-center pt-4 border-t shrink-0">
|
||||
<Button onClick={handleConvert} disabled={converting || convertableCount === 0} size="lg">
|
||||
{converting ? (<>
|
||||
<Spinner className="h-4 w-4"/>
|
||||
Converting...
|
||||
</>) : (<>
|
||||
<WandSparkles className="h-4 w-4"/>
|
||||
Convert {convertableCount > 0 ? `${convertableCount} File(s)` : ""}
|
||||
</>)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>)}
|
||||
</div>
|
||||
</div>);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,468 @@
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { Upload, X, CheckCircle2, AlertCircle, Trash2, FileMusic } from "lucide-react";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { SelectAudioFiles, SelectFolder, ListAudioFilesInDir, ResampleAudio } from "../../wailsjs/go/main/App";
|
||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||
import { OnFileDrop, OnFileDropOff } from "../../wailsjs/runtime/runtime";
|
||||
import { AudioLinesIcon } from "@/components/ui/audio-lines";
|
||||
interface AudioFile {
|
||||
path: string;
|
||||
name: string;
|
||||
format: string;
|
||||
size: number;
|
||||
status: "pending" | "resampling" | "success" | "error";
|
||||
error?: string;
|
||||
outputPath?: string;
|
||||
srcSampleRate?: number;
|
||||
srcBitDepth?: number;
|
||||
}
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0)
|
||||
return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
|
||||
}
|
||||
function formatSampleRate(sr: number): string {
|
||||
if (!sr)
|
||||
return "";
|
||||
if (sr === 44100)
|
||||
return "44.1kHz";
|
||||
if (sr >= 1000)
|
||||
return `${sr / 1000}kHz`;
|
||||
return `${sr}Hz`;
|
||||
}
|
||||
const SAMPLE_RATE_OPTIONS = [
|
||||
{ value: "44100", label: "44.1kHz" },
|
||||
{ value: "48000", label: "48kHz" },
|
||||
{ value: "96000", label: "96kHz" },
|
||||
{ value: "192000", label: "192kHz" },
|
||||
];
|
||||
const BIT_DEPTH_OPTIONS = [
|
||||
{ value: "16", label: "16-bit" },
|
||||
{ value: "24", label: "24-bit" },
|
||||
];
|
||||
const STORAGE_KEY = "spotiflac_audio_resampler_state";
|
||||
export function AudioResamplerPage() {
|
||||
const [files, setFiles] = useState<AudioFile[]>(() => {
|
||||
try {
|
||||
const saved = sessionStorage.getItem(STORAGE_KEY);
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved);
|
||||
if (parsed.files && Array.isArray(parsed.files) && parsed.files.length > 0) {
|
||||
return parsed.files;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
console.error("Failed to load saved state:", err);
|
||||
}
|
||||
return [];
|
||||
});
|
||||
const [sampleRate, setSampleRate] = useState(() => {
|
||||
try {
|
||||
const saved = sessionStorage.getItem(STORAGE_KEY);
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved);
|
||||
if (parsed.sampleRate)
|
||||
return parsed.sampleRate;
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
}
|
||||
return "44100";
|
||||
});
|
||||
const [bitDepth, setBitDepth] = useState(() => {
|
||||
try {
|
||||
const saved = sessionStorage.getItem(STORAGE_KEY);
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved);
|
||||
if (parsed.bitDepth)
|
||||
return parsed.bitDepth;
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
}
|
||||
return "16";
|
||||
});
|
||||
const [resampling, setResampling] = useState(false);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const saveState = useCallback((stateToSave: {
|
||||
files: AudioFile[];
|
||||
sampleRate: string;
|
||||
bitDepth: string;
|
||||
}) => {
|
||||
try {
|
||||
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(stateToSave));
|
||||
}
|
||||
catch (err) {
|
||||
console.error("Failed to save state:", err);
|
||||
}
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
saveState({ files, sampleRate, bitDepth });
|
||||
}, [files, sampleRate, bitDepth, saveState]);
|
||||
useEffect(() => {
|
||||
const checkFullscreen = () => {
|
||||
const isMaximized = window.innerHeight >= window.screen.height * 0.9;
|
||||
setIsFullscreen(isMaximized);
|
||||
};
|
||||
checkFullscreen();
|
||||
window.addEventListener("resize", checkFullscreen);
|
||||
window.addEventListener("focus", checkFullscreen);
|
||||
return () => {
|
||||
window.removeEventListener("resize", checkFullscreen);
|
||||
window.removeEventListener("focus", checkFullscreen);
|
||||
};
|
||||
}, []);
|
||||
const fetchAudioInfo = useCallback(async (paths: string[]) => {
|
||||
if (paths.length === 0)
|
||||
return;
|
||||
try {
|
||||
const GetFlacInfoBatch = (window as any)["go"]["main"]["App"]["GetFlacInfoBatch"];
|
||||
const infos: Array<{
|
||||
path: string;
|
||||
sample_rate: number;
|
||||
bits_per_sample: number;
|
||||
}> = await GetFlacInfoBatch(paths);
|
||||
setFiles((prev) => prev.map((f) => {
|
||||
const info = infos.find((i) => i.path === f.path || i.path.toLowerCase() === f.path.toLowerCase());
|
||||
if (info) {
|
||||
return {
|
||||
...f,
|
||||
srcSampleRate: info.sample_rate || undefined,
|
||||
srcBitDepth: info.bits_per_sample || undefined,
|
||||
};
|
||||
}
|
||||
return f;
|
||||
}));
|
||||
}
|
||||
catch (err) {
|
||||
console.error("Failed to fetch audio info:", err);
|
||||
}
|
||||
}, []);
|
||||
const handleSelectFiles = async () => {
|
||||
try {
|
||||
const selectedFiles = await SelectAudioFiles();
|
||||
if (selectedFiles && selectedFiles.length > 0) {
|
||||
addFiles(selectedFiles);
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
toast.error("File Selection Failed", {
|
||||
description: err instanceof Error ? err.message : "Failed to select files",
|
||||
});
|
||||
}
|
||||
};
|
||||
const handleSelectFolder = async () => {
|
||||
try {
|
||||
const selectedFolder = await SelectFolder("");
|
||||
if (selectedFolder) {
|
||||
const folderFiles = await ListAudioFilesInDir(selectedFolder);
|
||||
if (folderFiles && folderFiles.length > 0) {
|
||||
addFiles(folderFiles.map((f) => f.path));
|
||||
}
|
||||
else {
|
||||
toast.info("No audio files found", {
|
||||
description: "No FLAC files found in the selected folder.",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
toast.error("Folder Selection Failed", {
|
||||
description: err instanceof Error ? err.message : "Failed to select folder",
|
||||
});
|
||||
}
|
||||
};
|
||||
const addFiles = useCallback(async (paths: string[]) => {
|
||||
const validExtensions = [".flac"];
|
||||
const invalidFiles = paths.filter((path) => {
|
||||
const ext = path.toLowerCase().slice(path.lastIndexOf("."));
|
||||
return !validExtensions.includes(ext);
|
||||
});
|
||||
if (invalidFiles.length > 0) {
|
||||
toast.error("Unsupported format", {
|
||||
description: "Only FLAC files are supported for resampling.",
|
||||
});
|
||||
}
|
||||
const GetFileSizes = (files: string[]): Promise<Record<string, number>> => (window as any)["go"]["main"]["App"]["GetFileSizes"](files);
|
||||
const validPaths = paths.filter((path) => {
|
||||
const ext = path.toLowerCase().slice(path.lastIndexOf("."));
|
||||
return validExtensions.includes(ext);
|
||||
});
|
||||
const fileSizes = validPaths.length > 0 ? await GetFileSizes(validPaths) : {};
|
||||
let newlyAddedPaths: string[] = [];
|
||||
setFiles((prev) => {
|
||||
const newFiles: AudioFile[] = validPaths
|
||||
.filter((path) => !prev.some((f) => f.path === path))
|
||||
.map((path) => {
|
||||
const name = path.split(/[/\\]/).pop() || path;
|
||||
const ext = name.slice(name.lastIndexOf(".") + 1).toLowerCase();
|
||||
return {
|
||||
path,
|
||||
name,
|
||||
format: ext,
|
||||
size: fileSizes[path] || 0,
|
||||
status: "pending" as const,
|
||||
};
|
||||
});
|
||||
newlyAddedPaths = newFiles.map((f) => f.path);
|
||||
if (newFiles.length > 0) {
|
||||
if (paths.length > newFiles.length + invalidFiles.length) {
|
||||
const skipped = paths.length - newFiles.length - invalidFiles.length;
|
||||
toast.info("Some files skipped", {
|
||||
description: `${skipped} file(s) were already added`,
|
||||
});
|
||||
}
|
||||
return [...prev, ...newFiles];
|
||||
}
|
||||
if (validPaths.length > 0 && newFiles.length === 0) {
|
||||
toast.info("No new files added", {
|
||||
description: "All valid files were already added",
|
||||
});
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
setTimeout(() => {
|
||||
if (newlyAddedPaths.length > 0) {
|
||||
fetchAudioInfo(newlyAddedPaths);
|
||||
}
|
||||
}, 50);
|
||||
}, [fetchAudioInfo]);
|
||||
const handleFileDrop = useCallback(async (_x: number, _y: number, paths: string[]) => {
|
||||
setIsDragging(false);
|
||||
if (paths.length === 0)
|
||||
return;
|
||||
addFiles(paths);
|
||||
}, [addFiles]);
|
||||
useEffect(() => {
|
||||
OnFileDrop((x, y, paths) => {
|
||||
handleFileDrop(x, y, paths);
|
||||
}, true);
|
||||
return () => {
|
||||
OnFileDropOff();
|
||||
};
|
||||
}, [handleFileDrop]);
|
||||
const removeFile = (path: string) => {
|
||||
setFiles((prev) => prev.filter((f) => f.path !== path));
|
||||
};
|
||||
const clearFiles = () => {
|
||||
setFiles([]);
|
||||
};
|
||||
const handleResample = async () => {
|
||||
if (files.length === 0) {
|
||||
toast.error("No files selected", {
|
||||
description: "Please add FLAC files to resample",
|
||||
});
|
||||
return;
|
||||
}
|
||||
setResampling(true);
|
||||
try {
|
||||
const inputPaths = files.map((f) => f.path);
|
||||
setFiles((prev) => prev.map((f) => {
|
||||
if (inputPaths.includes(f.path)) {
|
||||
return { ...f, status: "resampling" as const, error: undefined };
|
||||
}
|
||||
return f;
|
||||
}));
|
||||
const results = await ResampleAudio({
|
||||
input_files: inputPaths,
|
||||
sample_rate: sampleRate,
|
||||
bit_depth: bitDepth,
|
||||
});
|
||||
setFiles((prev) => prev.map((f) => {
|
||||
const result = results.find((r: any) => r.input_file === f.path || r.input_file.toLowerCase() === f.path.toLowerCase());
|
||||
if (result) {
|
||||
return {
|
||||
...f,
|
||||
status: result.success ? "success" : "error",
|
||||
error: result.error,
|
||||
outputPath: result.output_file,
|
||||
};
|
||||
}
|
||||
return f;
|
||||
}));
|
||||
const successCount = results.filter((r: any) => r.success).length;
|
||||
const failCount = results.filter((r: any) => !r.success).length;
|
||||
if (successCount > 0) {
|
||||
toast.success("Resampling Complete", {
|
||||
description: `Successfully resampled ${successCount} file(s)${failCount > 0 ? `, ${failCount} failed` : ""}`,
|
||||
});
|
||||
}
|
||||
else if (failCount > 0) {
|
||||
toast.error("Resampling Failed", {
|
||||
description: `All ${failCount} file(s) failed to resample`,
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
toast.error("Resampling Error", {
|
||||
description: err instanceof Error ? err.message : "Unknown error",
|
||||
});
|
||||
setFiles((prev) => prev.map((f) => ({ ...f, status: "error" as const, error: "Resampling failed" })));
|
||||
}
|
||||
finally {
|
||||
setResampling(false);
|
||||
}
|
||||
};
|
||||
const getStatusIcon = (status: AudioFile["status"]) => {
|
||||
switch (status) {
|
||||
case "resampling":
|
||||
return <Spinner className="h-4 w-4 text-primary"/>;
|
||||
case "success":
|
||||
return <CheckCircle2 className="h-4 w-4 text-green-500"/>;
|
||||
case "error":
|
||||
return <AlertCircle className="h-4 w-4 text-destructive"/>;
|
||||
default:
|
||||
return <FileMusic className="h-4 w-4 text-muted-foreground"/>;
|
||||
}
|
||||
};
|
||||
const resampleableCount = files.filter((f) => f.status === "pending" || f.status === "success").length;
|
||||
const successCount = files.filter((f) => f.status === "success").length;
|
||||
return (<div className={`space-y-6 ${isFullscreen ? "h-full flex flex-col" : ""}`}>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Audio Resampler</h1>
|
||||
{files.length > 0 && (<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleSelectFiles}>
|
||||
<Upload className="h-4 w-4"/>
|
||||
Add Files
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleSelectFolder}>
|
||||
<Upload className="h-4 w-4"/>
|
||||
Add Folder
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={clearFiles} disabled={resampling}>
|
||||
<Trash2 className="h-4 w-4"/>
|
||||
Clear All
|
||||
</Button>
|
||||
</div>)}
|
||||
</div>
|
||||
|
||||
<div className={`flex flex-col items-center justify-center border-2 border-dashed rounded-lg transition-all ${isFullscreen ? "flex-1 min-h-[400px]" : "h-[400px]"} ${isDragging
|
||||
? "border-primary bg-primary/10"
|
||||
: "border-muted-foreground/30"}`} onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
}} onDragLeave={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
}} onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
}} style={{ "--wails-drop-target": "drop" } as React.CSSProperties}>
|
||||
{files.length === 0 ? (<>
|
||||
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
|
||||
<Upload className="h-8 w-8 text-primary"/>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-4 text-center">
|
||||
{isDragging
|
||||
? "Drop your audio files here"
|
||||
: "Drag and drop audio files here, or click the button below to select"}
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<Button onClick={handleSelectFiles} size="lg">
|
||||
<Upload className="h-5 w-5"/>
|
||||
Select Files
|
||||
</Button>
|
||||
<Button onClick={handleSelectFolder} size="lg" variant="outline">
|
||||
<Upload className="h-5 w-5"/>
|
||||
Select Folder
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-4 text-center">
|
||||
Supported format: FLAC
|
||||
</p>
|
||||
</>) : (<div className="w-full h-full p-6 space-y-4 flex flex-col">
|
||||
<div className="space-y-2 pb-4 border-b shrink-0">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="whitespace-nowrap">Bit Depth:</Label>
|
||||
<ToggleGroup type="single" variant="outline" value={bitDepth} onValueChange={(value) => {
|
||||
if (value)
|
||||
setBitDepth(value);
|
||||
}}>
|
||||
{BIT_DEPTH_OPTIONS.map((option) => (<ToggleGroupItem key={option.value} value={option.value} aria-label={option.label}>
|
||||
{option.label}
|
||||
</ToggleGroupItem>))}
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="whitespace-nowrap">Sample Rate:</Label>
|
||||
<ToggleGroup type="single" variant="outline" value={sampleRate} onValueChange={(value) => {
|
||||
if (value)
|
||||
setSampleRate(value);
|
||||
}}>
|
||||
{SAMPLE_RATE_OPTIONS.map((option) => (<ToggleGroupItem key={option.value} value={option.value} aria-label={option.label}>
|
||||
{option.label}
|
||||
</ToggleGroupItem>))}
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between shrink-0">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{files.length} file(s) • {successCount} resampled
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-2 overflow-y-auto min-h-0">
|
||||
{files.map((file) => {
|
||||
const srcParts: string[] = [];
|
||||
if (file.srcBitDepth)
|
||||
srcParts.push(`${file.srcBitDepth}-bit`);
|
||||
if (file.srcSampleRate)
|
||||
srcParts.push(formatSampleRate(file.srcSampleRate));
|
||||
const srcSpec = srcParts.join(" / ");
|
||||
return (<div key={file.path} className="flex items-center gap-3 rounded-lg border p-3">
|
||||
{getStatusIcon(file.status)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="truncate text-sm font-medium">{file.name}</p>
|
||||
{file.error && (<p className="truncate text-xs text-destructive">
|
||||
{file.error}
|
||||
</p>)}
|
||||
</div>
|
||||
|
||||
{srcSpec ? (<span className="text-xs font-medium text-primary bg-primary/10 rounded px-1.5 py-0.5 whitespace-nowrap shrink-0">
|
||||
{srcSpec}
|
||||
</span>) : file.status === "pending" ? (<span className="text-xs text-muted-foreground/50 whitespace-nowrap shrink-0">
|
||||
reading...
|
||||
</span>) : null}
|
||||
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
{formatFileSize(file.size)}
|
||||
</span>
|
||||
<span className="text-xs uppercase text-muted-foreground shrink-0">
|
||||
{file.format}
|
||||
</span>
|
||||
{file.status !== "resampling" && (<Button variant="ghost" size="icon" className="h-8 w-8 shrink-0" onClick={() => removeFile(file.path)} disabled={resampling}>
|
||||
<X className="h-4 w-4"/>
|
||||
</Button>)}
|
||||
</div>);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center pt-4 border-t shrink-0">
|
||||
<Button onClick={handleResample} disabled={resampling || resampleableCount === 0} size="lg">
|
||||
{resampling ? (<>
|
||||
<Spinner className="h-4 w-4"/>
|
||||
Resampling...
|
||||
</>) : (<>
|
||||
<AudioLinesIcon size={16} className="text-primary-foreground"/>
|
||||
Resample{" "}
|
||||
{resampleableCount > 0 ? `${resampleableCount} File(s)` : ""}
|
||||
</>)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>)}
|
||||
</div>
|
||||
</div>);
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Trash2, Copy, Check } from "lucide-react";
|
||||
import { Trash2, Copy, Check, FileDown } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { logger, type LogEntry } from "@/lib/logger";
|
||||
import { useDownloadQueueData } from "@/hooks/useDownloadQueueData";
|
||||
import { ExportFailedDownloads } from "../../wailsjs/go/main/App";
|
||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||
const levelColors: Record<string, string> = {
|
||||
info: "text-blue-500",
|
||||
success: "text-green-500",
|
||||
@@ -21,6 +24,13 @@ export function DebugLoggerPage() {
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const queueInfo = useDownloadQueueData();
|
||||
const hasDownloadActivity = queueInfo.queue.length > 0 ||
|
||||
queueInfo.queued_count > 0 ||
|
||||
queueInfo.completed_count > 0 ||
|
||||
queueInfo.failed_count > 0 ||
|
||||
queueInfo.skipped_count > 0;
|
||||
const canExportFailed = hasDownloadActivity && queueInfo.failed_count > 0;
|
||||
useEffect(() => {
|
||||
const unsubscribe = logger.subscribe(() => {
|
||||
setLogs(logger.getLogs());
|
||||
@@ -51,10 +61,32 @@ export function DebugLoggerPage() {
|
||||
console.error("Failed to copy logs:", err);
|
||||
}
|
||||
};
|
||||
const handleExportFailed = async () => {
|
||||
if (!canExportFailed) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const message = await ExportFailedDownloads();
|
||||
if (message.startsWith("Successfully")) {
|
||||
toast.success(message);
|
||||
}
|
||||
else if (message !== "Export cancelled") {
|
||||
toast.info(message);
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error("Failed to export:", error);
|
||||
toast.error(`Failed to export: ${error}`);
|
||||
}
|
||||
};
|
||||
return (<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Debug Logs</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" className="gap-1.5" onClick={handleExportFailed} disabled={!canExportFailed}>
|
||||
<FileDown className="h-4 w-4"/>
|
||||
Export Failed
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="gap-1.5" onClick={handleCopy} disabled={logs.length === 0}>
|
||||
{copied ? <Check className="h-4 w-4"/> : <Copy className="h-4 w-4"/>}
|
||||
Copy
|
||||
|
||||
@@ -13,18 +13,18 @@ export function DownloadProgressToast({ onClick }: DownloadProgressToastProps) {
|
||||
return null;
|
||||
}
|
||||
return (<div className="fixed bottom-4 left-[calc(56px+1rem)] z-50 animate-in slide-in-from-bottom-5 data-[state=closed]:animate-out data-[state=closed]:slide-out-to-bottom-5">
|
||||
<Button variant="outline" className="bg-background border rounded-lg shadow-lg p-3 h-auto hover:bg-muted/50 transition-colors cursor-pointer" onClick={onClick}>
|
||||
<Button variant="outline" className="h-auto cursor-pointer rounded-lg border-border bg-background p-3 text-foreground shadow-lg transition-colors hover:bg-muted dark:border-blue-800 dark:bg-blue-950 dark:text-blue-100 dark:hover:bg-blue-900" onClick={onClick}>
|
||||
<div className="flex items-center gap-3">
|
||||
<Download className={`h-4 w-4 text-primary ${progress.is_downloading ? 'animate-bounce' : ''}`}/>
|
||||
<Download className={`h-4 w-4 text-blue-600 dark:text-blue-400 ${progress.is_downloading ? 'animate-bounce' : ''}`}/>
|
||||
<div className="flex flex-col min-w-[80px]">
|
||||
<p className="text-sm font-medium font-mono tabular-nums">
|
||||
{progress.mb_downloaded.toFixed(2)} MB
|
||||
</p>
|
||||
{progress.speed_mbps > 0 && (<p className="text-xs text-muted-foreground font-mono tabular-nums">
|
||||
{progress.speed_mbps > 0 && (<p className="text-xs font-mono tabular-nums text-muted-foreground dark:text-blue-300">
|
||||
{progress.speed_mbps.toFixed(2)} MB/s
|
||||
</p>)}
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground ml-1"/>
|
||||
<ChevronRight className="ml-1 h-4 w-4 text-muted-foreground dark:text-blue-300"/>
|
||||
</div>
|
||||
</Button>
|
||||
</div>);
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { X, Download, CheckCircle2, XCircle, Clock, FileCheck, Trash2, HardDrive, Zap, Timer } from "lucide-react";
|
||||
import { X, Download, CheckCircle2, XCircle, Clock, FileCheck, Trash2, HardDrive, Zap, Timer, FileDown } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { GetDownloadQueue, ClearCompletedDownloads } from "../../wailsjs/go/main/App";
|
||||
import { GetDownloadQueue, ClearCompletedDownloads, ClearAllDownloads, ExportFailedDownloads } from "../../wailsjs/go/main/App";
|
||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||
import { backend } from "../../wailsjs/go/models";
|
||||
interface DownloadQueueProps {
|
||||
isOpen: boolean;
|
||||
@@ -47,6 +48,32 @@ export function DownloadQueue({ isOpen, onClose }: DownloadQueueProps) {
|
||||
console.error("Failed to clear history:", error);
|
||||
}
|
||||
};
|
||||
const handleReset = async () => {
|
||||
try {
|
||||
await ClearAllDownloads();
|
||||
const info = await GetDownloadQueue();
|
||||
setQueueInfo(info);
|
||||
toast.success("Download queue reset");
|
||||
}
|
||||
catch (error) {
|
||||
console.error("Failed to reset queue:", error);
|
||||
}
|
||||
};
|
||||
const handleExportFailed = async () => {
|
||||
try {
|
||||
const message = await ExportFailedDownloads();
|
||||
if (message.startsWith("Successfully")) {
|
||||
toast.success(message);
|
||||
}
|
||||
else if (message !== "Export cancelled") {
|
||||
toast.info(message);
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error("Failed to export:", error);
|
||||
toast.error(`Failed to export: ${error}`);
|
||||
}
|
||||
};
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case "downloading":
|
||||
@@ -72,8 +99,8 @@ export function DownloadQueue({ isOpen, onClose }: DownloadQueueProps) {
|
||||
queued: "outline",
|
||||
};
|
||||
return (<Badge variant={variants[status] || "outline"} className="text-xs">
|
||||
{status}
|
||||
</Badge>);
|
||||
{status}
|
||||
</Badge>);
|
||||
};
|
||||
const formatDuration = (startTimestamp: number) => {
|
||||
if (startTimestamp === 0)
|
||||
@@ -93,139 +120,155 @@ export function DownloadQueue({ isOpen, onClose }: DownloadQueueProps) {
|
||||
return `${seconds}s`;
|
||||
}
|
||||
};
|
||||
const [filterStatus, setFilterStatus] = useState<string>("all");
|
||||
const toggleFilter = (status: string) => {
|
||||
setFilterStatus(prev => prev === status ? "all" : status);
|
||||
};
|
||||
const filteredQueue = queueInfo.queue.filter((item: any) => {
|
||||
if (filterStatus === "all")
|
||||
return true;
|
||||
return item.status === filterStatus;
|
||||
});
|
||||
return (<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-[1200px] w-[95vw] max-h-[80vh] flex flex-col p-0 gap-0 [&>button]:hidden">
|
||||
<DialogHeader className="px-6 pt-6 pb-4 border-b space-y-0">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<DialogTitle className="text-lg font-semibold">Download Queue</DialogTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
{(queueInfo.completed_count > 0 || queueInfo.failed_count > 0 || queueInfo.skipped_count > 0) && (<Button variant="ghost" size="sm" className="h-7 text-xs gap-1.5" onClick={handleClearHistory}>
|
||||
<Trash2 className="h-3 w-3"/>
|
||||
Clear History
|
||||
</Button>)}
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 rounded-full hover:bg-muted" onClick={onClose}>
|
||||
<X className="h-4 w-4"/>
|
||||
</Button>
|
||||
</div>
|
||||
<DialogContent className="max-w-[1200px] w-[95vw] max-h-[80vh] flex flex-col p-0 gap-0 [&>button]:hidden">
|
||||
<DialogHeader className="px-6 pt-6 pb-4 border-b space-y-0">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<DialogTitle className="text-lg font-semibold hover:text-primary transition-colors cursor-pointer" onClick={handleReset}>Download Queue</DialogTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
{(queueInfo.completed_count > 0 || queueInfo.failed_count > 0 || queueInfo.skipped_count > 0) && (<Button variant="ghost" size="sm" className="h-7 text-xs gap-1.5" onClick={handleClearHistory}>
|
||||
<Trash2 className="h-3 w-3"/>
|
||||
Clear History
|
||||
</Button>)}
|
||||
{queueInfo.failed_count > 0 && (<Button variant="ghost" size="sm" className="h-7 text-xs gap-1.5" onClick={handleExportFailed}>
|
||||
<FileDown className="h-3 w-3"/>
|
||||
Export Failures
|
||||
</Button>)}
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 rounded-full hover:bg-muted" onClick={onClose}>
|
||||
<X className="h-4 w-4"/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Clock className="h-3.5 w-3.5 text-muted-foreground"/>
|
||||
<span className="text-muted-foreground">Queued:</span>
|
||||
<span className="font-semibold">{queueInfo.queued_count}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-green-500"/>
|
||||
<span className="text-muted-foreground">Completed:</span>
|
||||
<span className="font-semibold">{queueInfo.completed_count}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<FileCheck className="h-3.5 w-3.5 text-yellow-500"/>
|
||||
<span className="text-muted-foreground">Skipped:</span>
|
||||
<span className="font-semibold">{queueInfo.skipped_count}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<XCircle className="h-3.5 w-3.5 text-red-500"/>
|
||||
<span className="text-muted-foreground">Failed:</span>
|
||||
<span className="font-semibold">{queueInfo.failed_count}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<div className={`flex items-center gap-1.5 cursor-pointer hover:opacity-80 transition-all select-none ${filterStatus === 'queued' ? 'bg-secondary px-2 py-0.5 rounded-md ring-1 ring-border' : ''}`} onClick={() => toggleFilter('queued')}>
|
||||
<Clock className="h-3.5 w-3.5 text-muted-foreground"/>
|
||||
<span className="text-muted-foreground">Queued:</span>
|
||||
<span className="font-semibold">{queueInfo.queued_count}</span>
|
||||
</div>
|
||||
<div className={`flex items-center gap-1.5 cursor-pointer hover:opacity-80 transition-all select-none ${filterStatus === 'completed' ? 'bg-green-500/10 px-2 py-0.5 rounded-md ring-1 ring-green-500/20' : ''}`} onClick={() => toggleFilter('completed')}>
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-green-500"/>
|
||||
<span className="text-muted-foreground">Completed:</span>
|
||||
<span className="font-semibold">{queueInfo.completed_count}</span>
|
||||
</div>
|
||||
<div className={`flex items-center gap-1.5 cursor-pointer hover:opacity-80 transition-all select-none ${filterStatus === 'skipped' ? 'bg-yellow-500/10 px-2 py-0.5 rounded-md ring-1 ring-yellow-500/20' : ''}`} onClick={() => toggleFilter('skipped')}>
|
||||
<FileCheck className="h-3.5 w-3.5 text-yellow-500"/>
|
||||
<span className="text-muted-foreground">Skipped:</span>
|
||||
<span className="font-semibold">{queueInfo.skipped_count}</span>
|
||||
</div>
|
||||
<div className={`flex items-center gap-1.5 cursor-pointer hover:opacity-80 transition-all select-none ${filterStatus === 'failed' ? 'bg-red-500/10 px-2 py-0.5 rounded-md ring-1 ring-red-500/20' : ''}`} onClick={() => toggleFilter('failed')}>
|
||||
<XCircle className="h-3.5 w-3.5 text-red-500"/>
|
||||
<span className="text-muted-foreground">Failed:</span>
|
||||
<span className="font-semibold">{queueInfo.failed_count}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex items-center gap-4 text-sm pt-3 mt-3 border-t">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<HardDrive className="h-3.5 w-3.5 text-muted-foreground"/>
|
||||
<span className="text-muted-foreground">Downloaded:</span>
|
||||
<span className="font-semibold font-mono">
|
||||
{queueInfo.total_downloaded > 0 ? `${queueInfo.total_downloaded.toFixed(2)} MB` : "0.00 MB"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Zap className="h-3.5 w-3.5 text-muted-foreground"/>
|
||||
<span className="text-muted-foreground">Speed:</span>
|
||||
<span className="font-semibold font-mono">
|
||||
{queueInfo.current_speed > 0 && queueInfo.is_downloading
|
||||
|
||||
<div className="flex items-center gap-4 text-sm pt-3 mt-3 border-t">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<HardDrive className="h-3.5 w-3.5 text-muted-foreground"/>
|
||||
<span className="text-muted-foreground">Downloaded:</span>
|
||||
<span className="font-semibold font-mono">
|
||||
{queueInfo.total_downloaded > 0 ? `${queueInfo.total_downloaded.toFixed(2)} MB` : "0.00 MB"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Zap className="h-3.5 w-3.5 text-muted-foreground"/>
|
||||
<span className="text-muted-foreground">Speed:</span>
|
||||
<span className="font-semibold font-mono">
|
||||
{queueInfo.current_speed > 0 && queueInfo.is_downloading
|
||||
? `${queueInfo.current_speed.toFixed(2)} MB/s`
|
||||
: "—"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Timer className="h-3.5 w-3.5 text-muted-foreground"/>
|
||||
<span className="text-muted-foreground">Duration:</span>
|
||||
<span className="font-semibold font-mono">
|
||||
{queueInfo.session_start_time > 0 ? formatDuration(queueInfo.session_start_time) : "—"}
|
||||
</span>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Timer className="h-3.5 w-3.5 text-muted-foreground"/>
|
||||
<span className="text-muted-foreground">Duration:</span>
|
||||
<span className="font-semibold font-mono">
|
||||
{queueInfo.session_start_time > 0 ? formatDuration(queueInfo.session_start_time) : "—"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</DialogHeader>
|
||||
</DialogHeader>
|
||||
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 custom-scrollbar">
|
||||
<div className="space-y-2 py-4">
|
||||
{queueInfo.queue.length === 0 ? (<div className="text-center py-12 text-muted-foreground">
|
||||
<Download className="h-12 w-12 mx-auto mb-3 opacity-20"/>
|
||||
<p>No downloads in queue</p>
|
||||
</div>) : (queueInfo.queue.map((item) => (<div key={item.id} className="border rounded-lg p-3 hover:bg-muted/30 transition-colors">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-1">{getStatusIcon(item.status)}</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2 mb-1">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{item.track_name}</p>
|
||||
<p className="text-sm text-muted-foreground truncate">
|
||||
{item.artist_name}
|
||||
{item.album_name && ` • ${item.album_name}`}
|
||||
</p>
|
||||
</div>
|
||||
{getStatusBadge(item.status)}
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-6 custom-scrollbar">
|
||||
<div className="space-y-2 py-4">
|
||||
{queueInfo.queue.length === 0 ? (<div className="text-center py-12 text-muted-foreground">
|
||||
<Download className="h-12 w-12 mx-auto mb-3 opacity-20"/>
|
||||
<p>No downloads in queue</p>
|
||||
</div>) : filteredQueue.length === 0 ? (<div className="text-center py-12 text-muted-foreground">
|
||||
<p>No downloads with status "{filterStatus}"</p>
|
||||
<Button variant="link" onClick={() => setFilterStatus("all")}>Clear filter</Button>
|
||||
</div>) : (filteredQueue.map((item: any) => (<div key={item.id} className="border rounded-lg p-3 hover:bg-muted/30 transition-colors">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-1">{getStatusIcon(item.status)}</div>
|
||||
|
||||
|
||||
{item.status === "downloading" && (<div className="flex items-center gap-3 mt-1.5 text-xs text-muted-foreground font-mono">
|
||||
<span>
|
||||
{item.progress > 0
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2 mb-1">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{item.track_name}</p>
|
||||
<p className="text-sm text-muted-foreground truncate">
|
||||
{item.artist_name}
|
||||
{item.album_name && ` • ${item.album_name}`}
|
||||
</p>
|
||||
</div>
|
||||
{getStatusBadge(item.status)}
|
||||
</div>
|
||||
|
||||
|
||||
{item.status === "downloading" && (<div className="flex items-center gap-3 mt-1.5 text-xs text-muted-foreground font-mono">
|
||||
<span>
|
||||
{item.progress > 0
|
||||
? `${item.progress.toFixed(2)} MB`
|
||||
: queueInfo.is_downloading && queueInfo.current_speed > 0
|
||||
? "Downloading..."
|
||||
: "Starting..."}
|
||||
</span>
|
||||
<span>
|
||||
{item.speed > 0
|
||||
</span>
|
||||
<span>
|
||||
{item.speed > 0
|
||||
? `${item.speed.toFixed(2)} MB/s`
|
||||
: queueInfo.current_speed > 0
|
||||
? `${queueInfo.current_speed.toFixed(2)} MB/s`
|
||||
: "—"}
|
||||
</span>
|
||||
</div>)}
|
||||
</span>
|
||||
</div>)}
|
||||
|
||||
|
||||
{item.status === "completed" && (<div className="flex items-center gap-3 mt-1.5 text-xs text-muted-foreground">
|
||||
<span className="font-mono">{item.progress.toFixed(2)} MB</span>
|
||||
</div>)}
|
||||
|
||||
|
||||
{item.status === "skipped" && (<div className="mt-1.5 text-xs text-muted-foreground">
|
||||
File already exists
|
||||
</div>)}
|
||||
{item.status === "completed" && (<div className="flex items-center gap-3 mt-1.5 text-xs text-muted-foreground">
|
||||
<span className="font-mono">{item.progress.toFixed(2)} MB</span>
|
||||
</div>)}
|
||||
|
||||
|
||||
{item.status === "failed" && item.error_message && (<div className="mt-1.5 text-xs text-red-500 bg-red-50 dark:bg-red-950/20 rounded px-2 py-1">
|
||||
{item.error_message}
|
||||
</div>)}
|
||||
|
||||
|
||||
{(item.status === "completed" || item.status === "skipped") && item.file_path && (<div className="mt-1.5 text-xs text-muted-foreground truncate font-mono">
|
||||
{item.file_path}
|
||||
</div>)}
|
||||
</div>
|
||||
</div>
|
||||
</div>)))}
|
||||
</div>
|
||||
{item.status === "skipped" && (<div className="mt-1.5 text-xs text-muted-foreground">
|
||||
File already exists
|
||||
</div>)}
|
||||
|
||||
|
||||
{item.status === "failed" && item.error_message && (<div className="mt-1.5 text-xs text-red-500 bg-red-50 dark:bg-red-950/20 rounded px-2 py-1">
|
||||
{item.error_message}
|
||||
</div>)}
|
||||
|
||||
|
||||
{(item.status === "completed" || item.status === "skipped") && item.file_path && (<div className="mt-1.5 text-xs text-muted-foreground truncate font-mono">
|
||||
{item.file_path}
|
||||
</div>)}
|
||||
</div>
|
||||
</div>
|
||||
</div>)))}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>);
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>);
|
||||
}
|
||||
|
||||
@@ -17,12 +17,6 @@ const ListDirectoryFiles = (path: string): Promise<backend.FileInfo[]> => (windo
|
||||
const PreviewRenameFiles = (files: string[], format: string): Promise<backend.RenamePreview[]> => (window as any)['go']['main']['App']['PreviewRenameFiles'](files, format);
|
||||
const RenameFilesByMetadata = (files: string[], format: string): Promise<backend.RenameResult[]> => (window as any)['go']['main']['App']['RenameFilesByMetadata'](files, format);
|
||||
const ReadFileMetadata = (path: string): Promise<backend.AudioMetadata> => (window as any)['go']['main']['App']['ReadFileMetadata'](path);
|
||||
const IsFFprobeInstalled = (): Promise<boolean> => (window as any)['go']['main']['App']['IsFFprobeInstalled']();
|
||||
const DownloadFFmpeg = (): Promise<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
error?: string;
|
||||
}> => (window as any)['go']['main']['App']['DownloadFFmpeg']();
|
||||
const ReadTextFile = (path: string): Promise<string> => (window as any)['go']['main']['App']['ReadTextFile'](path);
|
||||
const RenameFileTo = (oldPath: string, newName: string): Promise<void> => (window as any)['go']['main']['App']['RenameFileTo'](oldPath, newName);
|
||||
const ReadImageAsBase64 = (path: string): Promise<string> => (window as any)['go']['main']['App']['ReadImageAsBase64'](path);
|
||||
@@ -118,8 +112,6 @@ export function FileManagerPage() {
|
||||
const [metadataFile, setMetadataFile] = useState<string>("");
|
||||
const [metadataInfo, setMetadataInfo] = useState<FileMetadata | null>(null);
|
||||
const [loadingMetadata, setLoadingMetadata] = useState(false);
|
||||
const [showFFprobeDialog, setShowFFprobeDialog] = useState(false);
|
||||
const [installingFFprobe, setInstallingFFprobe] = useState(false);
|
||||
const [showLyricsPreview, setShowLyricsPreview] = useState(false);
|
||||
const [lyricsContent, setLyricsContent] = useState("");
|
||||
const [lyricsFile, setLyricsFile] = useState("");
|
||||
@@ -279,14 +271,6 @@ export function FileManagerPage() {
|
||||
toast.error("No files selected");
|
||||
return;
|
||||
}
|
||||
const hasM4A = Array.from(selectedFiles).some(f => f.toLowerCase().endsWith(".m4a"));
|
||||
if (hasM4A) {
|
||||
const installed = await IsFFprobeInstalled();
|
||||
if (!installed) {
|
||||
setShowFFprobeDialog(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const result = await PreviewRenameFiles(Array.from(selectedFiles), renameFormat);
|
||||
setPreviewData(result);
|
||||
@@ -299,13 +283,6 @@ export function FileManagerPage() {
|
||||
};
|
||||
const handleShowMetadata = async (filePath: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (filePath.toLowerCase().endsWith(".m4a")) {
|
||||
const installed = await IsFFprobeInstalled();
|
||||
if (!installed) {
|
||||
setShowFFprobeDialog(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setMetadataFile(filePath);
|
||||
setLoadingMetadata(true);
|
||||
try {
|
||||
@@ -321,24 +298,6 @@ export function FileManagerPage() {
|
||||
setLoadingMetadata(false);
|
||||
}
|
||||
};
|
||||
const handleInstallFFprobe = async () => {
|
||||
setInstallingFFprobe(true);
|
||||
try {
|
||||
const result = await DownloadFFmpeg();
|
||||
if (result.success) {
|
||||
toast.success("FFprobe installed successfully");
|
||||
setShowFFprobeDialog(false);
|
||||
}
|
||||
else
|
||||
toast.error("Failed to install FFprobe", { description: result.error || result.message });
|
||||
}
|
||||
catch (err) {
|
||||
toast.error("Failed to install FFprobe", { description: err instanceof Error ? err.message : "Unknown error" });
|
||||
}
|
||||
finally {
|
||||
setInstallingFFprobe(false);
|
||||
}
|
||||
};
|
||||
const handleShowLyrics = async (filePath: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setLyricsFile(filePath);
|
||||
@@ -389,17 +348,17 @@ export function FileManagerPage() {
|
||||
if (!text)
|
||||
return null;
|
||||
return (<div key={index} className="flex items-center gap-2 py-1">
|
||||
<Badge variant="secondary" className="font-mono text-xs shrink-0">
|
||||
{formatTimestamp(timestamp)}
|
||||
</Badge>
|
||||
<span className="text-sm">{text}</span>
|
||||
</div>);
|
||||
<Badge variant="secondary" className="font-mono text-xs shrink-0">
|
||||
{formatTimestamp(timestamp)}
|
||||
</Badge>
|
||||
<span className="text-sm">{text}</span>
|
||||
</div>);
|
||||
}
|
||||
if (!line.trim())
|
||||
return null;
|
||||
return (<div key={index} className="py-1">
|
||||
<span className="text-sm">{line}</span>
|
||||
</div>);
|
||||
<span className="text-sm">{line}</span>
|
||||
</div>);
|
||||
}).filter(item => item !== null);
|
||||
};
|
||||
const handleCopyLyrics = async () => {
|
||||
@@ -463,331 +422,318 @@ export function FileManagerPage() {
|
||||
};
|
||||
const renderTrackTree = (nodes: FileNode[], depth = 0) => {
|
||||
return nodes.map((node) => (<div key={node.path}>
|
||||
<div className={`flex items-center gap-2 py-1.5 px-2 rounded hover:bg-muted/50 cursor-pointer ${selectedFiles.has(node.path) ? "bg-primary/10" : ""}`} style={{ paddingLeft: `${depth * 16 + 8}px` }} onClick={() => (node.is_dir ? toggleExpand(node.path) : toggleSelect(node.path))}>
|
||||
{node.is_dir ? (<>
|
||||
<Checkbox checked={isFolderSelected(node) === true} ref={(el) => {
|
||||
<div className={`flex items-center gap-2 py-1.5 px-2 rounded hover:bg-muted/50 cursor-pointer ${selectedFiles.has(node.path) ? "bg-primary/10" : ""}`} style={{ paddingLeft: `${depth * 16 + 8}px` }} onClick={() => (node.is_dir ? toggleExpand(node.path) : toggleSelect(node.path))}>
|
||||
{node.is_dir ? (<>
|
||||
<Checkbox checked={isFolderSelected(node) === true} ref={(el) => {
|
||||
if (el)
|
||||
(el as HTMLButtonElement).dataset.state = isFolderSelected(node) === "indeterminate" ? "indeterminate" : isFolderSelected(node) ? "checked" : "unchecked";
|
||||
}} onCheckedChange={() => toggleFolderSelect(node)} onClick={(e) => e.stopPropagation()} className="shrink-0 data-[state=indeterminate]:bg-primary data-[state=indeterminate]:text-primary-foreground"/>
|
||||
{node.expanded ? <ChevronDown className="h-4 w-4 text-muted-foreground shrink-0"/> : <ChevronRight className="h-4 w-4 text-muted-foreground shrink-0"/>}
|
||||
<Folder className="h-4 w-4 text-yellow-500 shrink-0"/>
|
||||
</>) : (<>
|
||||
<Checkbox checked={selectedFiles.has(node.path)} onCheckedChange={() => toggleSelect(node.path)} onClick={(e) => e.stopPropagation()} className="shrink-0"/>
|
||||
<FileMusic className="h-4 w-4 text-primary shrink-0"/>
|
||||
</>)}
|
||||
<span className="truncate text-sm flex-1">
|
||||
{node.name}
|
||||
{node.is_dir && <span className="text-muted-foreground ml-1">({getAllFilesFlat([node]).length})</span>}
|
||||
</span>
|
||||
{!node.is_dir && (<>
|
||||
<span className="text-xs text-muted-foreground shrink-0">{formatFileSize(node.size)}</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button className="p-1 rounded hover:bg-muted shrink-0" onClick={(e) => handleShowMetadata(node.path, e)}>
|
||||
<Info className="h-3.5 w-3.5 text-muted-foreground"/>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>View Metadata</TooltipContent>
|
||||
</Tooltip>
|
||||
</>)}
|
||||
</div>
|
||||
{node.is_dir && node.expanded && node.children && <div>{renderTrackTree(node.children, depth + 1)}</div>}
|
||||
</div>));
|
||||
{node.expanded ? <ChevronDown className="h-4 w-4 text-muted-foreground shrink-0"/> : <ChevronRight className="h-4 w-4 text-muted-foreground shrink-0"/>}
|
||||
<Folder className="h-4 w-4 text-yellow-500 shrink-0"/>
|
||||
</>) : (<>
|
||||
<Checkbox checked={selectedFiles.has(node.path)} onCheckedChange={() => toggleSelect(node.path)} onClick={(e) => e.stopPropagation()} className="shrink-0"/>
|
||||
<FileMusic className="h-4 w-4 text-primary shrink-0"/>
|
||||
</>)}
|
||||
<span className="truncate text-sm flex-1">
|
||||
{node.name}
|
||||
{node.is_dir && <span className="text-muted-foreground ml-1">({getAllFilesFlat([node]).length})</span>}
|
||||
</span>
|
||||
{!node.is_dir && (<>
|
||||
<span className="text-xs text-muted-foreground shrink-0">{formatFileSize(node.size)}</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button className="p-1 rounded hover:bg-muted shrink-0" onClick={(e) => handleShowMetadata(node.path, e)}>
|
||||
<Info className="h-3.5 w-3.5 text-muted-foreground"/>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>View Metadata</TooltipContent>
|
||||
</Tooltip>
|
||||
</>)}
|
||||
</div>
|
||||
{node.is_dir && node.expanded && node.children && <div>{renderTrackTree(node.children, depth + 1)}</div>}
|
||||
</div>));
|
||||
};
|
||||
const renderLyricTree = (nodes: FileNode[], depth = 0) => {
|
||||
return nodes.map((node) => (<div key={node.path}>
|
||||
<div className="flex items-center gap-2 py-1.5 px-2 rounded hover:bg-muted/50 cursor-pointer" style={{ paddingLeft: `${depth * 16 + 8}px` }} onClick={(e) => node.is_dir ? toggleExpand(node.path) : handleShowLyrics(node.path, e)}>
|
||||
{node.is_dir ? (<>
|
||||
{node.expanded ? <ChevronDown className="h-4 w-4 text-muted-foreground shrink-0"/> : <ChevronRight className="h-4 w-4 text-muted-foreground shrink-0"/>}
|
||||
<Folder className="h-4 w-4 text-yellow-500 shrink-0"/>
|
||||
</>) : (<FileText className="h-4 w-4 text-blue-500 shrink-0"/>)}
|
||||
<span className="truncate text-sm flex-1">
|
||||
{node.name}
|
||||
{node.is_dir && <span className="text-muted-foreground ml-1">({getAllFilesFlat([node]).length})</span>}
|
||||
</span>
|
||||
{!node.is_dir && (<>
|
||||
<span className="text-xs text-muted-foreground shrink-0">{formatFileSize(node.size)}</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button className="p-1 rounded hover:bg-muted shrink-0" onClick={(e) => handleManualRename(node.path, e)}>
|
||||
<Pencil className="h-3.5 w-3.5 text-muted-foreground"/>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Rename</TooltipContent>
|
||||
</Tooltip>
|
||||
</>)}
|
||||
</div>
|
||||
{node.is_dir && node.expanded && node.children && <div>{renderLyricTree(node.children, depth + 1)}</div>}
|
||||
</div>));
|
||||
<div className="flex items-center gap-2 py-1.5 px-2 rounded hover:bg-muted/50 cursor-pointer" style={{ paddingLeft: `${depth * 16 + 8}px` }} onClick={(e) => node.is_dir ? toggleExpand(node.path) : handleShowLyrics(node.path, e)}>
|
||||
{node.is_dir ? (<>
|
||||
{node.expanded ? <ChevronDown className="h-4 w-4 text-muted-foreground shrink-0"/> : <ChevronRight className="h-4 w-4 text-muted-foreground shrink-0"/>}
|
||||
<Folder className="h-4 w-4 text-yellow-500 shrink-0"/>
|
||||
</>) : (<FileText className="h-4 w-4 text-blue-500 shrink-0"/>)}
|
||||
<span className="truncate text-sm flex-1">
|
||||
{node.name}
|
||||
{node.is_dir && <span className="text-muted-foreground ml-1">({getAllFilesFlat([node]).length})</span>}
|
||||
</span>
|
||||
{!node.is_dir && (<>
|
||||
<span className="text-xs text-muted-foreground shrink-0">{formatFileSize(node.size)}</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button className="p-1 rounded hover:bg-muted shrink-0" onClick={(e) => handleManualRename(node.path, e)}>
|
||||
<Pencil className="h-3.5 w-3.5 text-muted-foreground"/>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Rename</TooltipContent>
|
||||
</Tooltip>
|
||||
</>)}
|
||||
</div>
|
||||
{node.is_dir && node.expanded && node.children && <div>{renderLyricTree(node.children, depth + 1)}</div>}
|
||||
</div>));
|
||||
};
|
||||
const renderCoverTree = (nodes: FileNode[], depth = 0) => {
|
||||
return nodes.map((node) => (<div key={node.path}>
|
||||
<div className="flex items-center gap-2 py-1.5 px-2 rounded hover:bg-muted/50 cursor-pointer" style={{ paddingLeft: `${depth * 16 + 8}px` }} onClick={(e) => node.is_dir ? toggleExpand(node.path) : handleShowCover(node.path, e)}>
|
||||
{node.is_dir ? (<>
|
||||
{node.expanded ? <ChevronDown className="h-4 w-4 text-muted-foreground shrink-0"/> : <ChevronRight className="h-4 w-4 text-muted-foreground shrink-0"/>}
|
||||
<Folder className="h-4 w-4 text-yellow-500 shrink-0"/>
|
||||
</>) : (<Image className="h-4 w-4 text-green-500 shrink-0"/>)}
|
||||
<span className="truncate text-sm flex-1">
|
||||
{node.name}
|
||||
{node.is_dir && <span className="text-muted-foreground ml-1">({getAllFilesFlat([node]).length})</span>}
|
||||
</span>
|
||||
{!node.is_dir && (<>
|
||||
<span className="text-xs text-muted-foreground shrink-0">{formatFileSize(node.size)}</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button className="p-1 rounded hover:bg-muted shrink-0" onClick={(e) => handleManualRename(node.path, e)}>
|
||||
<Pencil className="h-3.5 w-3.5 text-muted-foreground"/>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Rename</TooltipContent>
|
||||
</Tooltip>
|
||||
</>)}
|
||||
</div>
|
||||
{node.is_dir && node.expanded && node.children && <div>{renderCoverTree(node.children, depth + 1)}</div>}
|
||||
</div>));
|
||||
<div className="flex items-center gap-2 py-1.5 px-2 rounded hover:bg-muted/50 cursor-pointer" style={{ paddingLeft: `${depth * 16 + 8}px` }} onClick={(e) => node.is_dir ? toggleExpand(node.path) : handleShowCover(node.path, e)}>
|
||||
{node.is_dir ? (<>
|
||||
{node.expanded ? <ChevronDown className="h-4 w-4 text-muted-foreground shrink-0"/> : <ChevronRight className="h-4 w-4 text-muted-foreground shrink-0"/>}
|
||||
<Folder className="h-4 w-4 text-yellow-500 shrink-0"/>
|
||||
</>) : (<Image className="h-4 w-4 text-green-500 shrink-0"/>)}
|
||||
<span className="truncate text-sm flex-1">
|
||||
{node.name}
|
||||
{node.is_dir && <span className="text-muted-foreground ml-1">({getAllFilesFlat([node]).length})</span>}
|
||||
</span>
|
||||
{!node.is_dir && (<>
|
||||
<span className="text-xs text-muted-foreground shrink-0">{formatFileSize(node.size)}</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button className="p-1 rounded hover:bg-muted shrink-0" onClick={(e) => handleManualRename(node.path, e)}>
|
||||
<Pencil className="h-3.5 w-3.5 text-muted-foreground"/>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Rename</TooltipContent>
|
||||
</Tooltip>
|
||||
</>)}
|
||||
</div>
|
||||
{node.is_dir && node.expanded && node.children && <div>{renderCoverTree(node.children, depth + 1)}</div>}
|
||||
</div>));
|
||||
};
|
||||
const allSelected = allAudioFiles.length > 0 && selectedFiles.size === allAudioFiles.length;
|
||||
return (<div className={`space-y-6 ${isFullscreen ? "h-full flex flex-col" : ""}`}>
|
||||
<div className="flex items-center justify-between shrink-0">
|
||||
<h1 className="text-2xl font-bold">File Manager</h1>
|
||||
<div className="flex items-center justify-between shrink-0">
|
||||
<h1 className="text-2xl font-bold">File Manager</h1>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<InputWithContext value={rootPath} onChange={(e) => setRootPath(e.target.value)} placeholder="Select a folder..." className="flex-1"/>
|
||||
<Button onClick={handleSelectFolder}>
|
||||
<FolderOpen className="h-4 w-4"/>
|
||||
Browse
|
||||
</Button>
|
||||
<Button variant="outline" onClick={loadFiles} disabled={loading || !rootPath}>
|
||||
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`}/>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex gap-2 border-b shrink-0">
|
||||
<Button variant={activeTab === "track" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("track")} className="rounded-b-none">
|
||||
<FileMusic className="h-4 w-4"/>
|
||||
Track ({allAudioFiles.length})
|
||||
</Button>
|
||||
<Button variant={activeTab === "lyric" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("lyric")} className="rounded-b-none">
|
||||
<FileText className="h-4 w-4"/>
|
||||
Lyric ({allLyricFiles.length})
|
||||
</Button>
|
||||
<Button variant={activeTab === "cover" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("cover")} className="rounded-b-none">
|
||||
<Image className="h-4 w-4"/>
|
||||
Cover ({allCoverFiles.length})
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
{activeTab === "track" && (<div className="space-y-2 shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-sm">Rename Format</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help"/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p className="text-xs whitespace-nowrap">Variables: {"{title}"}, {"{artist}"}, {"{album}"}, {"{album_artist}"}, {"{track}"}, {"{disc}"}, {"{year}"}, {"{date}"}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<InputWithContext value={rootPath} onChange={(e) => setRootPath(e.target.value)} placeholder="Select a folder..." className="flex-1"/>
|
||||
<Button onClick={handleSelectFolder}>
|
||||
<FolderOpen className="h-4 w-4"/>
|
||||
Browse
|
||||
</Button>
|
||||
<Button variant="outline" onClick={loadFiles} disabled={loading || !rootPath}>
|
||||
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`}/>
|
||||
Refresh
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={formatPreset} onValueChange={setFormatPreset}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(FORMAT_PRESETS).map(([key, { label }]) => (<SelectItem key={key} value={key}>{label}</SelectItem>))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{formatPreset === "custom" && (<InputWithContext value={customFormat} onChange={(e) => setCustomFormat(e.target.value)} placeholder="{artist} - {title}" className="flex-1"/>)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon" onClick={() => setShowResetConfirm(true)}>
|
||||
<RotateCcw className="h-4 w-4"/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Reset to Default</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Preview: <span className="font-mono">{renameFormat.replace(/\{title\}/g, "All The Stars").replace(/\{artist\}/g, "Kendrick Lamar, SZA").replace(/\{album\}/g, "Black Panther").replace(/\{album_artist\}/g, "Kendrick Lamar").replace(/\{track\}/g, "01").replace(/\{disc\}/g, "1").replace(/\{year\}/g, "2018").replace(/\{date\}/g, "2018-02-09")}.flac</span>
|
||||
</p>
|
||||
</div>)}
|
||||
|
||||
|
||||
<div className="flex gap-2 border-b shrink-0">
|
||||
<Button variant={activeTab === "track" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("track")} className="rounded-b-none">
|
||||
<FileMusic className="h-4 w-4"/>
|
||||
Track ({allAudioFiles.length})
|
||||
</Button>
|
||||
<Button variant={activeTab === "lyric" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("lyric")} className="rounded-b-none">
|
||||
<FileText className="h-4 w-4"/>
|
||||
Lyric ({allLyricFiles.length})
|
||||
</Button>
|
||||
<Button variant={activeTab === "cover" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("cover")} className="rounded-b-none">
|
||||
<Image className="h-4 w-4"/>
|
||||
Cover ({allCoverFiles.length})
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
{activeTab === "track" && (<div className="space-y-2 shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-sm">Rename Format</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help"/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p className="text-xs whitespace-nowrap">Variables: {"{title}"}, {"{artist}"}, {"{album}"}, {"{album_artist}"}, {"{track}"}, {"{disc}"}, {"{year}"}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={formatPreset} onValueChange={setFormatPreset}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(FORMAT_PRESETS).map(([key, { label }]) => (<SelectItem key={key} value={key}>{label}</SelectItem>))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{formatPreset === "custom" && (<InputWithContext value={customFormat} onChange={(e) => setCustomFormat(e.target.value)} placeholder="{artist} - {title}" className="flex-1"/>)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon" onClick={() => setShowResetConfirm(true)}>
|
||||
<RotateCcw className="h-4 w-4"/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Reset to Default</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Preview: <span className="font-mono">{renameFormat.replace(/\{title\}/g, "All The Stars").replace(/\{artist\}/g, "Kendrick Lamar, SZA").replace(/\{album\}/g, "Black Panther").replace(/\{album_artist\}/g, "Kendrick Lamar").replace(/\{track\}/g, "01").replace(/\{disc\}/g, "1").replace(/\{year\}/g, "2018")}.flac</span>
|
||||
</p>
|
||||
</div>)}
|
||||
<div className={`border rounded-lg ${isFullscreen ? "flex-1 flex flex-col min-h-0" : ""}`}>
|
||||
{activeTab === "track" && (<div className="flex items-center justify-between p-3 border-b bg-muted/30 shrink-0">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="sm" onClick={allSelected ? deselectAll : selectAll}>
|
||||
{allSelected ? "Deselect All" : "Select All"}
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">{selectedFiles.size} of {allAudioFiles.length} file(s) selected</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => handlePreview(true)} disabled={selectedFiles.size === 0 || loading}>
|
||||
<Eye className="h-4 w-4"/>
|
||||
Preview
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => handlePreview(false)} disabled={selectedFiles.size === 0 || loading}>
|
||||
<Pencil className="h-4 w-4"/>
|
||||
Rename
|
||||
</Button>
|
||||
</div>
|
||||
</div>)}
|
||||
|
||||
|
||||
<div className={`border rounded-lg ${isFullscreen ? "flex-1 flex flex-col min-h-0" : ""}`}>
|
||||
{activeTab === "track" && (<div className="flex items-center justify-between p-3 border-b bg-muted/30 shrink-0">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="sm" onClick={allSelected ? deselectAll : selectAll}>
|
||||
{allSelected ? "Deselect All" : "Select All"}
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">{selectedFiles.size} of {allAudioFiles.length} file(s) selected</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => handlePreview(true)} disabled={selectedFiles.size === 0 || loading}>
|
||||
<Eye className="h-4 w-4"/>
|
||||
Preview
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => handlePreview(false)} disabled={selectedFiles.size === 0 || loading}>
|
||||
<Pencil className="h-4 w-4"/>
|
||||
Rename
|
||||
</Button>
|
||||
</div>
|
||||
</div>)}
|
||||
|
||||
<div className={`overflow-y-auto p-2 ${isFullscreen ? "flex-1 min-h-0" : "max-h-[400px]"}`}>
|
||||
{loading ? (<div className="flex items-center justify-center py-8"><Spinner className="h-6 w-6"/></div>) : filteredFiles.length === 0 ? (<div className="text-center py-8 text-muted-foreground">
|
||||
{rootPath ? `No ${activeTab} files found` : "Select a folder to browse"}
|
||||
</div>) : (activeTab === "track" ? renderTrackTree(filteredFiles) :
|
||||
<div className={`overflow-y-auto p-2 ${isFullscreen ? "flex-1 min-h-0" : "max-h-[400px]"}`}>
|
||||
{loading ? (<div className="flex items-center justify-center py-8"><Spinner className="h-6 w-6"/></div>) : filteredFiles.length === 0 ? (<div className="text-center py-8 text-muted-foreground">
|
||||
{rootPath ? `No ${activeTab} files found` : "Select a folder to browse"}
|
||||
</div>) : (activeTab === "track" ? renderTrackTree(filteredFiles) :
|
||||
activeTab === "lyric" ? renderLyricTree(filteredFiles) :
|
||||
renderCoverTree(filteredFiles))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<Dialog open={showResetConfirm} onOpenChange={setShowResetConfirm}>
|
||||
<DialogContent className="max-w-md [&>button]:hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Reset to Default?</DialogTitle>
|
||||
<DialogDescription>This will reset the rename format to "Title - Artist". Your custom format will be lost.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowResetConfirm(false)}>Cancel</Button>
|
||||
<Button onClick={resetToDefault}>Reset</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
|
||||
<Dialog open={showPreview} onOpenChange={setShowPreview}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col [&>button]:hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Rename Preview</DialogTitle>
|
||||
<DialogDescription>Review the changes before renaming. Files with errors will be skipped.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-y-auto space-y-2 py-4">
|
||||
{previewData.map((item, index) => (<div key={index} className={`p-3 rounded-lg border ${item.error ? "border-destructive/50 bg-destructive/5" : "border-border"}`}>
|
||||
<div className="text-sm">
|
||||
<div className="text-muted-foreground break-all">{item.old_name}</div>
|
||||
{item.error ? <div className="text-destructive text-xs mt-1">{item.error}</div> : <div className="text-primary font-medium break-all mt-1">→ {item.new_name}</div>}
|
||||
</div>
|
||||
</div>))}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
{previewOnly ? (<Button onClick={() => setShowPreview(false)}>Close</Button>) : (<>
|
||||
<Button variant="outline" onClick={() => setShowPreview(false)}>Cancel</Button>
|
||||
<Button onClick={handleRename} disabled={renaming}>
|
||||
{renaming ? <><Spinner className="h-4 w-4"/>Renaming...</> : <>Rename {previewData.filter((p) => !p.error).length} File(s)</>}
|
||||
</Button>
|
||||
</>)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Dialog open={showResetConfirm} onOpenChange={setShowResetConfirm}>
|
||||
<DialogContent className="max-w-md [&>button]:hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Reset to Default?</DialogTitle>
|
||||
<DialogDescription>This will reset the rename format to "Title - Artist". Your custom format will be lost.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowResetConfirm(false)}>Cancel</Button>
|
||||
<Button onClick={resetToDefault}>Reset</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
|
||||
<Dialog open={showMetadata} onOpenChange={setShowMetadata}>
|
||||
<DialogContent className="max-w-md [&>button]:hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>File Metadata</DialogTitle>
|
||||
<DialogDescription className="break-all">{metadataFile.split(/[/\\]/).pop()}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{loadingMetadata ? (<div className="flex items-center justify-center py-8"><Spinner className="h-6 w-6"/></div>) : metadataInfo ? (<div className="space-y-3 py-2">
|
||||
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm"><span className="text-muted-foreground">Title</span><span>{metadataInfo.title || "-"}</span></div>
|
||||
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm"><span className="text-muted-foreground">Artist</span><span>{metadataInfo.artist || "-"}</span></div>
|
||||
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm"><span className="text-muted-foreground">Album</span><span>{metadataInfo.album || "-"}</span></div>
|
||||
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm"><span className="text-muted-foreground">Album Artist</span><span>{metadataInfo.album_artist || "-"}</span></div>
|
||||
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm"><span className="text-muted-foreground">Track</span><span>{metadataInfo.track_number || "-"}</span></div>
|
||||
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm"><span className="text-muted-foreground">Disc</span><span>{metadataInfo.disc_number || "-"}</span></div>
|
||||
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm"><span className="text-muted-foreground">Year</span><span>{metadataInfo.year ? metadataInfo.year.substring(0, 4) : "-"}</span></div>
|
||||
</div>) : (<div className="text-center py-4 text-muted-foreground">No metadata available</div>)}
|
||||
<DialogFooter><Button onClick={() => setShowMetadata(false)}>Close</Button></DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
|
||||
<Dialog open={showFFprobeDialog} onOpenChange={setShowFFprobeDialog}>
|
||||
<DialogContent className="max-w-md [&>button]:hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>FFprobe Required</DialogTitle>
|
||||
<DialogDescription>Reading M4A metadata requires FFprobe. Would you like to download and install it now?</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowFFprobeDialog(false)} disabled={installingFFprobe}>Cancel</Button>
|
||||
<Button onClick={handleInstallFFprobe} disabled={installingFFprobe}>
|
||||
{installingFFprobe ? <><Spinner className="h-4 w-4"/>Installing...</> : "Install FFprobe"}
|
||||
<Dialog open={showPreview} onOpenChange={setShowPreview}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col [&>button]:hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Rename Preview</DialogTitle>
|
||||
<DialogDescription>Review the changes before renaming. Files with errors will be skipped.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-y-auto space-y-2 py-4">
|
||||
{previewData.map((item, index) => (<div key={index} className={`p-3 rounded-lg border ${item.error ? "border-destructive/50 bg-destructive/5" : "border-border"}`}>
|
||||
<div className="text-sm">
|
||||
<div className="text-muted-foreground break-all">{item.old_name}</div>
|
||||
{item.error ? <div className="text-destructive text-xs mt-1">{item.error}</div> : <div className="text-primary font-medium break-all mt-1">→ {item.new_name}</div>}
|
||||
</div>
|
||||
</div>))}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
{previewOnly ? (<Button onClick={() => setShowPreview(false)}>Close</Button>) : (<>
|
||||
<Button variant="outline" onClick={() => setShowPreview(false)}>Cancel</Button>
|
||||
<Button onClick={handleRename} disabled={renaming}>
|
||||
{renaming ? <><Spinner className="h-4 w-4"/>Renaming...</> : <>Rename {previewData.filter((p) => !p.error).length} File(s)</>}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
|
||||
<Dialog open={showLyricsPreview} onOpenChange={setShowLyricsPreview}>
|
||||
<DialogContent className="max-w-lg max-h-[80vh] overflow-hidden flex flex-col [&>button]:hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Lyrics Preview</DialogTitle>
|
||||
<DialogDescription className="break-all">{lyricsFile.split(/[/\\]/).pop()}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex gap-2 border-b pb-2">
|
||||
<Button variant={lyricsTab === "synced" ? "default" : "ghost"} size="sm" onClick={() => setLyricsTab("synced")}>Synced</Button>
|
||||
<Button variant={lyricsTab === "plain" ? "default" : "ghost"} size="sm" onClick={() => setLyricsTab("plain")}>Plain</Button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto py-4">
|
||||
{lyricsTab === "synced" ? (<div className="bg-muted/30 p-4 rounded-lg space-y-0">
|
||||
{renderSyncedLyrics(lyricsContent)}
|
||||
</div>) : (<pre className="text-sm whitespace-pre-wrap font-mono bg-muted/30 p-4 rounded-lg">
|
||||
{getPlainLyrics(lyricsContent) || "No lyrics content"}
|
||||
</pre>)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleCopyLyrics} className="gap-1.5">
|
||||
{copySuccess ? <Check className="h-4 w-4"/> : <Copy className="h-4 w-4"/>}
|
||||
Copy
|
||||
</Button>
|
||||
<Button onClick={() => setShowLyricsPreview(false)}>Close</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
|
||||
<Dialog open={showCoverPreview} onOpenChange={setShowCoverPreview}>
|
||||
<DialogContent className="max-w-lg [&>button]:hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Cover Preview</DialogTitle>
|
||||
<DialogDescription className="break-all">{coverFile.split(/[/\\]/).pop()}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex items-center justify-center p-4">
|
||||
{coverData ? <img src={coverData} alt="Cover" className="max-w-full max-h-[350px] rounded-lg object-contain"/> : <div className="text-muted-foreground">Loading...</div>}
|
||||
</div>
|
||||
<DialogFooter><Button onClick={() => setShowCoverPreview(false)}>Close</Button></DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Dialog open={showMetadata} onOpenChange={setShowMetadata}>
|
||||
<DialogContent className="max-w-md [&>button]:hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>File Metadata</DialogTitle>
|
||||
<DialogDescription className="break-all">{metadataFile.split(/[/\\]/).pop()}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{loadingMetadata ? (<div className="flex items-center justify-center py-8"><Spinner className="h-6 w-6"/></div>) : metadataInfo ? (<div className="space-y-3 py-2">
|
||||
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm"><span className="text-muted-foreground">Title</span><span>{metadataInfo.title || "-"}</span></div>
|
||||
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm"><span className="text-muted-foreground">Artist</span><span>{metadataInfo.artist || "-"}</span></div>
|
||||
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm"><span className="text-muted-foreground">Album</span><span>{metadataInfo.album || "-"}</span></div>
|
||||
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm"><span className="text-muted-foreground">Album Artist</span><span>{metadataInfo.album_artist || "-"}</span></div>
|
||||
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm"><span className="text-muted-foreground">Track</span><span>{metadataInfo.track_number || "-"}</span></div>
|
||||
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm"><span className="text-muted-foreground">Disc</span><span>{metadataInfo.disc_number || "-"}</span></div>
|
||||
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm"><span className="text-muted-foreground">Year</span><span>{metadataInfo.year ? metadataInfo.year.substring(0, 4) : "-"}</span></div>
|
||||
</div>) : (<div className="text-center py-4 text-muted-foreground">No metadata available</div>)}
|
||||
<DialogFooter><Button onClick={() => setShowMetadata(false)}>Close</Button></DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
|
||||
<Dialog open={showManualRename} onOpenChange={setShowManualRename}>
|
||||
<DialogContent className="max-w-2xl [&>button]:hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Rename File</DialogTitle>
|
||||
<DialogDescription className="break-all">{manualRenameFile.split(/[/\\]/).pop()}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<Label htmlFor="newName" className="text-sm">New Name</Label>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<InputWithContext id="newName" value={manualRenameName} onChange={(e) => setManualRenameName(e.target.value)} placeholder="Enter new name" className="flex-1" onKeyDown={(e) => {
|
||||
|
||||
|
||||
|
||||
|
||||
<Dialog open={showLyricsPreview} onOpenChange={setShowLyricsPreview}>
|
||||
<DialogContent className="max-w-lg max-h-[80vh] overflow-hidden flex flex-col [&>button]:hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Lyrics Preview</DialogTitle>
|
||||
<DialogDescription className="break-all">{lyricsFile.split(/[/\\]/).pop()}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex gap-2 border-b pb-2">
|
||||
<Button variant={lyricsTab === "synced" ? "default" : "ghost"} size="sm" onClick={() => setLyricsTab("synced")}>Synced</Button>
|
||||
<Button variant={lyricsTab === "plain" ? "default" : "ghost"} size="sm" onClick={() => setLyricsTab("plain")}>Plain</Button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto py-4">
|
||||
{lyricsTab === "synced" ? (<div className="bg-muted/30 p-4 rounded-lg space-y-0">
|
||||
{renderSyncedLyrics(lyricsContent)}
|
||||
</div>) : (<pre className="text-sm whitespace-pre-wrap font-mono bg-muted/30 p-4 rounded-lg">
|
||||
{getPlainLyrics(lyricsContent) || "No lyrics content"}
|
||||
</pre>)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleCopyLyrics} className="gap-1.5">
|
||||
{copySuccess ? <Check className="h-4 w-4"/> : <Copy className="h-4 w-4"/>}
|
||||
Copy
|
||||
</Button>
|
||||
<Button onClick={() => setShowLyricsPreview(false)}>Close</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
|
||||
<Dialog open={showCoverPreview} onOpenChange={setShowCoverPreview}>
|
||||
<DialogContent className="max-w-lg [&>button]:hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Cover Preview</DialogTitle>
|
||||
<DialogDescription className="break-all">{coverFile.split(/[/\\]/).pop()}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex items-center justify-center p-4">
|
||||
{coverData ? <img src={coverData} alt="Cover" className="max-w-full max-h-[350px] rounded-lg object-contain"/> : <div className="text-muted-foreground">Loading...</div>}
|
||||
</div>
|
||||
<DialogFooter><Button onClick={() => setShowCoverPreview(false)}>Close</Button></DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
|
||||
<Dialog open={showManualRename} onOpenChange={setShowManualRename}>
|
||||
<DialogContent className="max-w-2xl [&>button]:hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Rename File</DialogTitle>
|
||||
<DialogDescription className="break-all">{manualRenameFile.split(/[/\\]/).pop()}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<Label htmlFor="newName" className="text-sm">New Name</Label>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<InputWithContext id="newName" value={manualRenameName} onChange={(e) => setManualRenameName(e.target.value)} placeholder="Enter new name" className="flex-1" onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !manualRenaming)
|
||||
handleConfirmManualRename();
|
||||
}}/>
|
||||
<span className="text-sm text-muted-foreground shrink-0">{manualRenameFile.match(/\.[^.]+$/)?.[0] || ""}</span>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground shrink-0">{manualRenameFile.match(/\.[^.]+$/)?.[0] || ""}</span>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowManualRename(false)} disabled={manualRenaming}>Cancel</Button>
|
||||
<Button onClick={handleConfirmManualRename} disabled={manualRenaming || !manualRenameName.trim()}>
|
||||
{manualRenaming ? <><Spinner className="h-4 w-4"/>Renaming...</> : "Rename"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>);
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowManualRename(false)} disabled={manualRenaming}>Cancel</Button>
|
||||
<Button onClick={handleConfirmManualRename} disabled={manualRenaming || !manualRenameName.trim()}>
|
||||
{manualRenaming ? <><Spinner className="h-4 w-4"/>Renaming...</> : "Rename"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,660 @@
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Trash2, ExternalLink, Search, ArrowUpDown, History, Play, Pause, Database, CloudUpload, Music2, Disc3, ListMusic, UserRound } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious } from "@/components/ui/pagination";
|
||||
import { GetDownloadHistory, ClearDownloadHistory, GetPreviewURL, GetFetchHistory, DeleteDownloadHistoryItem, DeleteFetchHistoryItem, ClearFetchHistoryByType } from "../../wailsjs/go/main/App";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { openExternal } from "@/lib/utils";
|
||||
import { SPOTIFY_PREVIEW_VOLUME } from "@/lib/preview";
|
||||
import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons";
|
||||
const formatDate = (timestamp: number) => {
|
||||
const date = new Date(timestamp * 1000);
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0');
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||
};
|
||||
interface DownloadHistoryItem {
|
||||
id: string;
|
||||
spotify_id: string;
|
||||
title: string;
|
||||
artists: string;
|
||||
album: string;
|
||||
duration_str: string;
|
||||
cover_url: string;
|
||||
quality: string;
|
||||
format: string;
|
||||
path: string;
|
||||
source: string;
|
||||
timestamp: number;
|
||||
}
|
||||
interface FetchHistoryItem {
|
||||
id: string;
|
||||
url: string;
|
||||
type: string;
|
||||
name: string;
|
||||
info: string;
|
||||
image: string;
|
||||
data: string;
|
||||
timestamp: number;
|
||||
}
|
||||
interface HistoryPageProps {
|
||||
onHistorySelect?: (cachedData: string) => void;
|
||||
}
|
||||
export function HistoryPage({ onHistorySelect }: HistoryPageProps) {
|
||||
const [activeTab, setActiveTab] = useState("downloads");
|
||||
const [downloadHistory, setDownloadHistory] = useState<DownloadHistoryItem[]>([]);
|
||||
const [filteredDownloadHistory, setFilteredDownloadHistory] = useState<DownloadHistoryItem[]>([]);
|
||||
const [showClearDownloadConfirm, setShowClearDownloadConfirm] = useState(false);
|
||||
const [downloadSearchQuery, setDownloadSearchQuery] = useState("");
|
||||
const [downloadSortBy, setDownloadSortBy] = useState("default");
|
||||
const [downloadCurrentPage, setDownloadCurrentPage] = useState(1);
|
||||
const [playingPreviewId, setPlayingPreviewId] = useState<string | null>(null);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const [fetchHistory, setFetchHistory] = useState<FetchHistoryItem[]>([]);
|
||||
const [filteredFetchHistory, setFilteredFetchHistory] = useState<FetchHistoryItem[]>([]);
|
||||
const [activeFetchTab, setActiveFetchTab] = useState("track");
|
||||
const [showClearFetchConfirm, setShowClearFetchConfirm] = useState(false);
|
||||
const [fetchSearchQuery, setFetchSearchQuery] = useState("");
|
||||
const [fetchCurrentPage, setFetchCurrentPage] = useState(1);
|
||||
const ITEMS_PER_PAGE = 50;
|
||||
const getTrackLink = (spotifyId: string) => {
|
||||
if (spotifyId?.startsWith("tidal_"))
|
||||
return { url: `https://listen.tidal.com/track/${spotifyId.replace("tidal_", "")}`, label: "Open in Tidal" };
|
||||
if (spotifyId?.startsWith("qobuz_"))
|
||||
return { url: `https://www.qobuz.com/track/${spotifyId.replace("qobuz_", "")}`, label: "Open in Qobuz" };
|
||||
if (spotifyId?.startsWith("amazon_"))
|
||||
return { url: `https://music.amazon.com/tracks/${spotifyId.replace("amazon_", "")}`, label: "Open in Amazon Music" };
|
||||
if (spotifyId?.startsWith("deezer_"))
|
||||
return { url: `https://www.deezer.com/track/${spotifyId.replace("deezer_", "")}`, label: "Open in Deezer" };
|
||||
return { url: `https://open.spotify.com/track/${spotifyId}`, label: "Open in Spotify" };
|
||||
};
|
||||
const getSourceIcon = (source: string) => {
|
||||
const s = source?.toLowerCase() || "";
|
||||
if (s.includes("tidal"))
|
||||
return <TidalIcon className="h-4 w-4 object-contain rounded"/>;
|
||||
if (s.includes("qobuz"))
|
||||
return <QobuzIcon className="h-4 w-4 object-contain"/>;
|
||||
if (s.includes("amazon"))
|
||||
return <AmazonIcon className="h-4 w-4 object-contain rounded"/>;
|
||||
if (s.includes("deezer"))
|
||||
return <Music2 className="h-4 w-4"/>;
|
||||
if (s.includes("spotify"))
|
||||
return <Music2 className="h-4 w-4"/>;
|
||||
return <Music2 className="h-4 w-4 opacity-50"/>;
|
||||
};
|
||||
const fetchDownloadHistory = async () => {
|
||||
try {
|
||||
const items = await GetDownloadHistory();
|
||||
setDownloadHistory((items || []) as unknown as DownloadHistoryItem[]);
|
||||
}
|
||||
catch (err) {
|
||||
console.error("Failed to fetch download history:", err);
|
||||
}
|
||||
};
|
||||
const fetchFetchHistory = async () => {
|
||||
try {
|
||||
const items = await GetFetchHistory();
|
||||
setFetchHistory(items || []);
|
||||
}
|
||||
catch (err) {
|
||||
console.error("Failed to fetch fetch history:", err);
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
if (activeTab === "downloads") {
|
||||
fetchDownloadHistory();
|
||||
const interval = setInterval(fetchDownloadHistory, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
else {
|
||||
fetchFetchHistory();
|
||||
const interval = setInterval(fetchFetchHistory, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [activeTab]);
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
let result = [...downloadHistory];
|
||||
if (downloadSearchQuery) {
|
||||
const query = downloadSearchQuery.toLowerCase();
|
||||
result = result.filter(item => item.title.toLowerCase().includes(query) ||
|
||||
item.artists.toLowerCase().includes(query) ||
|
||||
item.album.toLowerCase().includes(query));
|
||||
}
|
||||
const parseDuration = (str: string) => {
|
||||
const parts = str.split(':').map(Number);
|
||||
if (parts.length === 2)
|
||||
return parts[0] * 60 + parts[1];
|
||||
if (parts.length === 3)
|
||||
return parts[0] * 3600 + parts[1] * 60 + parts[2];
|
||||
return 0;
|
||||
};
|
||||
result.sort((a, b) => {
|
||||
switch (downloadSortBy) {
|
||||
case "default":
|
||||
case "date_desc": return b.timestamp - a.timestamp;
|
||||
case "date_asc": return a.timestamp - b.timestamp;
|
||||
case "title_asc": return a.title.localeCompare(b.title);
|
||||
case "title_desc": return b.title.localeCompare(a.title);
|
||||
case "artist_asc": return a.artists.localeCompare(b.artists);
|
||||
case "artist_desc": return b.artists.localeCompare(a.artists);
|
||||
case "duration_asc": return parseDuration(a.duration_str) - parseDuration(b.duration_str);
|
||||
case "duration_desc": return parseDuration(b.duration_str) - parseDuration(a.duration_str);
|
||||
default: return 0;
|
||||
}
|
||||
});
|
||||
setFilteredDownloadHistory(result);
|
||||
}, [downloadHistory, downloadSearchQuery, downloadSortBy]);
|
||||
useEffect(() => {
|
||||
setDownloadCurrentPage(1);
|
||||
}, [downloadSearchQuery, downloadSortBy]);
|
||||
useEffect(() => {
|
||||
let result = [...fetchHistory];
|
||||
if (activeFetchTab !== "all") {
|
||||
result = result.filter(item => item.type.toLowerCase() === activeFetchTab.toLowerCase());
|
||||
}
|
||||
if (fetchSearchQuery) {
|
||||
const query = fetchSearchQuery.toLowerCase();
|
||||
result = result.filter(item => item.name.toLowerCase().includes(query) ||
|
||||
item.info.toLowerCase().includes(query));
|
||||
}
|
||||
result.sort((a, b) => b.timestamp - a.timestamp);
|
||||
setFilteredFetchHistory(result);
|
||||
}, [fetchHistory, fetchSearchQuery, activeFetchTab]);
|
||||
useEffect(() => {
|
||||
setFetchCurrentPage(1);
|
||||
}, [fetchSearchQuery, activeFetchTab]);
|
||||
const handlePreview = async (id: string, spotifyId: string) => {
|
||||
if (playingPreviewId === id) {
|
||||
audioRef.current?.pause();
|
||||
setPlayingPreviewId(null);
|
||||
return;
|
||||
}
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
}
|
||||
try {
|
||||
const url = await GetPreviewURL(spotifyId);
|
||||
if (url) {
|
||||
const audio = new Audio(url);
|
||||
audioRef.current = audio;
|
||||
audio.volume = SPOTIFY_PREVIEW_VOLUME;
|
||||
audio.onended = () => setPlayingPreviewId(null);
|
||||
audio.play();
|
||||
setPlayingPreviewId(id);
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
console.error("Failed to play preview:", e);
|
||||
}
|
||||
};
|
||||
const handleClearDownloadHistory = async () => {
|
||||
await ClearDownloadHistory();
|
||||
fetchDownloadHistory();
|
||||
setShowClearDownloadConfirm(false);
|
||||
};
|
||||
const handleDeleteDownloadItem = async (id: string) => {
|
||||
await DeleteDownloadHistoryItem(id);
|
||||
setDownloadHistory(prev => prev.filter(item => item.id !== id));
|
||||
};
|
||||
const handleClearFetchHistory = async () => {
|
||||
await ClearFetchHistoryByType(activeFetchTab);
|
||||
fetchFetchHistory();
|
||||
setShowClearFetchConfirm(false);
|
||||
};
|
||||
const handleDeleteFetchItem = async (id: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
await DeleteFetchHistoryItem(id);
|
||||
setFetchHistory(prev => prev.filter(item => item.id !== id));
|
||||
};
|
||||
const getPaginationPages = (current: number, total: number): (number | 'ellipsis')[] => {
|
||||
if (total <= 10)
|
||||
return Array.from({ length: total }, (_, i) => i + 1);
|
||||
const pages: (number | 'ellipsis')[] = [];
|
||||
pages.push(1);
|
||||
if (current <= 7) {
|
||||
for (let i = 2; i <= 10; i++)
|
||||
pages.push(i);
|
||||
pages.push('ellipsis');
|
||||
pages.push(total);
|
||||
}
|
||||
else if (current >= total - 7) {
|
||||
pages.push('ellipsis');
|
||||
for (let i = total - 9; i <= total; i++)
|
||||
pages.push(i);
|
||||
}
|
||||
else {
|
||||
pages.push('ellipsis');
|
||||
pages.push(current - 1);
|
||||
pages.push(current);
|
||||
pages.push(current + 1);
|
||||
pages.push('ellipsis');
|
||||
pages.push(total);
|
||||
}
|
||||
return pages;
|
||||
};
|
||||
const renderDownloadHistory = () => {
|
||||
const totalPages = Math.ceil(filteredDownloadHistory.length / ITEMS_PER_PAGE);
|
||||
const startIndex = (downloadCurrentPage - 1) * ITEMS_PER_PAGE;
|
||||
const paginated = filteredDownloadHistory.slice(startIndex, startIndex + ITEMS_PER_PAGE);
|
||||
return (<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-xl font-bold tracking-tight">Downloads</h2>
|
||||
{filteredDownloadHistory.length > 0 && (<Badge variant="secondary" className="font-mono">
|
||||
{filteredDownloadHistory.length.toLocaleString('en-US')}
|
||||
</Badge>)}
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => setShowClearDownloadConfirm(true)} disabled={downloadHistory.length === 0} className="cursor-pointer gap-2">
|
||||
<Trash2 className="h-4 w-4"/> Clear All
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground"/>
|
||||
<Input placeholder="Search downloads..." value={downloadSearchQuery} onChange={(e) => setDownloadSearchQuery(e.target.value)} className="pl-8 h-9"/>
|
||||
</div>
|
||||
<Select value={downloadSortBy} onValueChange={setDownloadSortBy}>
|
||||
<SelectTrigger className="w-[180px] h-9">
|
||||
<ArrowUpDown className="mr-2 h-4 w-4"/>
|
||||
<SelectValue placeholder="Sort by"/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">Default</SelectItem>
|
||||
<SelectItem value="date_desc">Date (Newest)</SelectItem>
|
||||
<SelectItem value="date_asc">Date (Oldest)</SelectItem>
|
||||
<SelectItem value="title_asc">Title (A-Z)</SelectItem>
|
||||
<SelectItem value="title_desc">Title (Z-A)</SelectItem>
|
||||
<SelectItem value="artist_asc">Artist (A-Z)</SelectItem>
|
||||
<SelectItem value="artist_desc">Artist (Z-A)</SelectItem>
|
||||
<SelectItem value="duration_asc">Duration (Short)</SelectItem>
|
||||
<SelectItem value="duration_desc">Duration (Long)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border overflow-hidden">
|
||||
{paginated.length === 0 ? (<div className="flex flex-col items-center justify-center p-16 text-center text-muted-foreground gap-3">
|
||||
<div className="rounded-full bg-muted/50 p-4 ring-8 ring-muted/20">
|
||||
<History className="h-10 w-10 opacity-40"/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium text-foreground/80">No download history</p>
|
||||
<p className="text-sm">Your downloaded tracks will appear here.</p>
|
||||
</div>
|
||||
</div>) : (<table className="w-full table-fixed">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground w-12 text-xs uppercase">#</th>
|
||||
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground text-xs uppercase w-[35%]">Title</th>
|
||||
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground hidden md:table-cell text-xs uppercase w-48 lg:w-48 xl:w-56">Album</th>
|
||||
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground hidden lg:table-cell w-32 text-xs uppercase">Format</th>
|
||||
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground hidden xl:table-cell w-16 text-xs uppercase text-nowrap">Dur</th>
|
||||
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground hidden md:table-cell w-36 text-xs uppercase text-nowrap">Downloaded At</th>
|
||||
<th className="h-10 px-4 text-center align-middle font-medium text-muted-foreground w-16 text-xs uppercase text-nowrap">Source</th>
|
||||
<th className="h-10 px-4 text-center align-middle font-medium text-muted-foreground w-32 text-xs uppercase text-nowrap">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paginated.map((item, index) => (<tr key={item.id} className="border-b transition-colors hover:bg-muted/50">
|
||||
<td className="p-3 align-middle text-sm text-muted-foreground text-left font-mono">
|
||||
{startIndex + index + 1}
|
||||
</td>
|
||||
<td className="p-3 align-middle min-w-0">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<img src={item.cover_url || "https://placehold.co/300?text=No+Cover"} alt={item.album} className="h-10 w-10 rounded shrink-0 bg-secondary object-cover" onError={(e) => { (e.target as HTMLImageElement).src = "https://placehold.co/300?text=No+Cover"; }}/>
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
<span className="font-medium text-sm truncate">{item.title}</span>
|
||||
<span className="text-xs text-muted-foreground truncate">{item.artists}</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-3 align-middle text-sm text-muted-foreground hidden md:table-cell">
|
||||
<div className="truncate">{item.album}</div>
|
||||
</td>
|
||||
<td className="p-3 align-middle text-left hidden lg:table-cell">
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<span className="text-xs font-bold text-foreground">
|
||||
{['HI_RES_LOSSLESS', 'LOSSLESS', 'flac', '6', '7', '27'].includes(item.format?.toLowerCase() || '') ? 'FLAC' : item.format?.toUpperCase()}
|
||||
</span>
|
||||
{item.quality && <span className="text-[11px] text-muted-foreground leading-none whitespace-nowrap">{item.quality}</span>}
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-3 align-middle text-sm text-muted-foreground text-left hidden xl:table-cell font-mono">
|
||||
{item.duration_str}
|
||||
</td>
|
||||
<td className="p-3 align-middle text-xs text-muted-foreground hidden md:table-cell whitespace-nowrap text-left">
|
||||
<div className="flex flex-col">
|
||||
<span>{formatDate(item.timestamp).split(' ')[0]}</span>
|
||||
<span className="text-[10px] text-muted-foreground">{formatDate(item.timestamp).split(' ')[1]}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-3 align-middle text-center">
|
||||
<div className="flex items-center justify-center">
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center justify-center">
|
||||
{getSourceIcon(item.source)}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="capitalize">{item.source || "Unknown"}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-3 align-middle text-center">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
{!(item.spotify_id?.startsWith('tidal_') || item.spotify_id?.startsWith('qobuz_') || item.spotify_id?.startsWith('amazon_') || item.spotify_id?.startsWith('deezer_')) && (<TooltipProvider>
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 cursor-pointer" onClick={() => handlePreview(item.id, item.spotify_id)} disabled={!item.spotify_id}>
|
||||
{playingPreviewId === item.id ? <Pause className="h-4 w-4"/> : <Play className="h-4 w-4"/>}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{playingPreviewId === item.id ? "Pause Preview" : "Play Preview"}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>)}
|
||||
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 cursor-pointer" onClick={() => openExternal(getTrackLink(item.spotify_id).url)}>
|
||||
<ExternalLink className="h-4 w-4"/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{getTrackLink(item.spotify_id).label}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 cursor-pointer text-destructive hover:text-destructive" onClick={() => handleDeleteDownloadItem(item.id)}>
|
||||
<Trash2 className="h-4 w-4"/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Delete</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</td>
|
||||
</tr>))}
|
||||
</tbody>
|
||||
</table>)}
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious href="#" onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (downloadCurrentPage > 1)
|
||||
setDownloadCurrentPage(downloadCurrentPage - 1);
|
||||
}} className={downloadCurrentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}/>
|
||||
</PaginationItem>
|
||||
|
||||
{getPaginationPages(downloadCurrentPage, totalPages).map((page, index) => (page === 'ellipsis' ? (<PaginationItem key={`ellipsis-${index}`}>
|
||||
<PaginationEllipsis />
|
||||
</PaginationItem>) : (<PaginationItem key={page}>
|
||||
<PaginationLink href="#" onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setDownloadCurrentPage(page as number);
|
||||
}} isActive={downloadCurrentPage === page} className="cursor-pointer">
|
||||
{page}
|
||||
</PaginationLink>
|
||||
</PaginationItem>)))}
|
||||
|
||||
<PaginationItem>
|
||||
<PaginationNext href="#" onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (downloadCurrentPage < totalPages)
|
||||
setDownloadCurrentPage(downloadCurrentPage + 1);
|
||||
}} className={downloadCurrentPage === totalPages ? "pointer-events-none opacity-50" : "cursor-pointer"}/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>)}
|
||||
</div>);
|
||||
};
|
||||
const renderFetchHistory = () => {
|
||||
const totalPages = Math.ceil(filteredFetchHistory.length / ITEMS_PER_PAGE);
|
||||
const startIndex = (fetchCurrentPage - 1) * ITEMS_PER_PAGE;
|
||||
const paginated = filteredFetchHistory.slice(startIndex, startIndex + ITEMS_PER_PAGE);
|
||||
return (<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-xl font-bold tracking-tight">Fetches</h2>
|
||||
{fetchHistory.length > 0 && (<Badge variant="secondary" className="font-mono">
|
||||
{fetchHistory.length.toLocaleString('en-US')}
|
||||
</Badge>)}
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => setShowClearFetchConfirm(true)} disabled={fetchHistory.length === 0} className="cursor-pointer gap-2">
|
||||
<Trash2 className="h-4 w-4"/> Clear All
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex gap-2 border-b shrink-0">
|
||||
<Button variant={activeFetchTab === "track" ? "default" : "ghost"} size="sm" onClick={() => setActiveFetchTab("track")} className="rounded-b-none">
|
||||
<Music2 className="h-4 w-4"/>
|
||||
Tracks
|
||||
</Button>
|
||||
<Button variant={activeFetchTab === "album" ? "default" : "ghost"} size="sm" onClick={() => setActiveFetchTab("album")} className="rounded-b-none">
|
||||
<Disc3 className="h-4 w-4"/>
|
||||
Albums
|
||||
</Button>
|
||||
<Button variant={activeFetchTab === "playlist" ? "default" : "ghost"} size="sm" onClick={() => setActiveFetchTab("playlist")} className="rounded-b-none">
|
||||
<ListMusic className="h-4 w-4"/>
|
||||
Playlists
|
||||
</Button>
|
||||
<Button variant={activeFetchTab === "artist" ? "default" : "ghost"} size="sm" onClick={() => setActiveFetchTab("artist")} className="rounded-b-none">
|
||||
<UserRound className="h-4 w-4"/>
|
||||
Artists
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground"/>
|
||||
<Input placeholder="Search fetch history..." value={fetchSearchQuery} onChange={(e) => setFetchSearchQuery(e.target.value)} className="pl-8 h-9"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border overflow-hidden">
|
||||
{paginated.length === 0 ? (<div className="flex flex-col items-center justify-center py-12 text-center text-muted-foreground gap-3">
|
||||
<Database className="h-10 w-10 opacity-40"/>
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium text-foreground/80">No fetch history</p>
|
||||
<p className="text-sm">Fetched metadata will appear here.</p>
|
||||
</div>
|
||||
</div>) : (<table className="w-full table-fixed">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground w-12 text-xs uppercase">#</th>
|
||||
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground text-xs uppercase w-1/3">
|
||||
{activeFetchTab === 'artist' ? 'Name' : 'Title'}
|
||||
</th>
|
||||
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground hidden md:table-cell text-xs uppercase">Details</th>
|
||||
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground hidden lg:table-cell w-40 text-xs uppercase text-nowrap">Fetched At</th>
|
||||
<th className="h-10 px-4 text-center align-middle font-medium text-muted-foreground w-32 text-xs uppercase text-nowrap">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paginated.map((item, index) => (<tr key={item.id} className="border-b transition-colors hover:bg-muted/50">
|
||||
<td className="p-3 align-middle text-sm text-muted-foreground text-left font-mono">
|
||||
{startIndex + index + 1}
|
||||
</td>
|
||||
<td className="p-3 align-middle min-w-0">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="h-10 w-10 rounded shrink-0 bg-secondary overflow-hidden">
|
||||
{item.image ? (<img src={item.image} alt={item.name} className="h-full w-full object-cover"/>) : (<div className="h-full w-full flex items-center justify-center text-xs text-muted-foreground font-medium bg-muted">
|
||||
{item.type.slice(0, 2).toUpperCase()}
|
||||
</div>)}
|
||||
</div>
|
||||
<span className="font-medium text-sm truncate">{item.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-3 align-middle text-sm text-muted-foreground hidden md:table-cell">
|
||||
<div className="truncate">{item.info}</div>
|
||||
</td>
|
||||
<td className="p-3 align-middle text-xs text-muted-foreground hidden lg:table-cell whitespace-nowrap">
|
||||
<div className="flex flex-col">
|
||||
<span>{formatDate(item.timestamp).split(' ')[0]}</span>
|
||||
<span className="text-[10px] text-muted-foreground">{formatDate(item.timestamp).split(' ')[1]}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-3 align-middle text-center">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 cursor-pointer" onClick={() => onHistorySelect?.(item.data)}>
|
||||
<CloudUpload className="h-4 w-4"/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Load</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 cursor-pointer text-destructive hover:text-destructive" onClick={(e) => handleDeleteFetchItem(item.id, e)}>
|
||||
<Trash2 className="h-4 w-4"/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Delete</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</td>
|
||||
</tr>))}
|
||||
</tbody>
|
||||
</table>)}
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious href="#" onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (fetchCurrentPage > 1)
|
||||
setFetchCurrentPage(fetchCurrentPage - 1);
|
||||
}} className={fetchCurrentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}/>
|
||||
</PaginationItem>
|
||||
|
||||
{getPaginationPages(fetchCurrentPage, totalPages).map((page, index) => (page === 'ellipsis' ? (<PaginationItem key={`ellipsis-${index}`}>
|
||||
<PaginationEllipsis />
|
||||
</PaginationItem>) : (<PaginationItem key={page}>
|
||||
<PaginationLink href="#" onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setFetchCurrentPage(page as number);
|
||||
}} isActive={fetchCurrentPage === page} className="cursor-pointer">
|
||||
{page}
|
||||
</PaginationLink>
|
||||
</PaginationItem>)))}
|
||||
|
||||
<PaginationItem>
|
||||
<PaginationNext href="#" onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (fetchCurrentPage < totalPages)
|
||||
setFetchCurrentPage(fetchCurrentPage + 1);
|
||||
}} className={fetchCurrentPage === totalPages ? "pointer-events-none opacity-50" : "cursor-pointer"}/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>)}
|
||||
</div>);
|
||||
};
|
||||
return (<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<h1 className="text-2xl font-bold">History</h1>
|
||||
</div>
|
||||
|
||||
<div className="border-b">
|
||||
<div className="flex gap-6">
|
||||
<button onClick={() => setActiveTab("downloads")} className={`pb-3 text-sm font-medium transition-colors border-b-2 -mb-px hover:text-foreground ${activeTab === "downloads" ? "border-primary text-foreground" : "border-transparent text-muted-foreground"}`}>
|
||||
Downloads
|
||||
</button>
|
||||
<button onClick={() => setActiveTab("fetches")} className={`pb-3 text-sm font-medium transition-colors border-b-2 -mb-px hover:text-foreground ${activeTab === "fetches" ? "border-primary text-foreground" : "border-transparent text-muted-foreground"}`}>
|
||||
Fetches
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeTab === "downloads" && (<div className="mt-6">
|
||||
{renderDownloadHistory()}
|
||||
</div>)}
|
||||
|
||||
{activeTab === "fetches" && (<div className="mt-6">
|
||||
{renderFetchHistory()}
|
||||
</div>)}
|
||||
|
||||
<Dialog open={showClearDownloadConfirm} onOpenChange={setShowClearDownloadConfirm}>
|
||||
<DialogContent className="max-w-md [&>button]:hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Clear Download History?</DialogTitle>
|
||||
<DialogDescription>
|
||||
This will remove all entries from your download history. This action cannot be undone.
|
||||
Note: The actual downloaded files will NOT be deleted.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowClearDownloadConfirm(false)} className="cursor-pointer">Cancel</Button>
|
||||
<Button variant="destructive" onClick={handleClearDownloadHistory} className="cursor-pointer">
|
||||
Clear History
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={showClearFetchConfirm} onOpenChange={setShowClearFetchConfirm}>
|
||||
<DialogContent className="max-w-md [&>button]:hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Clear {activeFetchTab.charAt(0).toUpperCase() + activeFetchTab.slice(1)} History?</DialogTitle>
|
||||
<DialogDescription>
|
||||
This will remove all {activeFetchTab} entries from your fetch history cache.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowClearFetchConfirm(false)} className="cursor-pointer">Cancel</Button>
|
||||
<Button variant="destructive" onClick={handleClearFetchHistory} className="cursor-pointer">
|
||||
Clear History
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>);
|
||||
}
|
||||
@@ -1,18 +1,77 @@
|
||||
export const TidalIcon = ({ className = "w-4 h-4" }: {
|
||||
import amazonMusicIcon from "../assets/icons/amazon-music.png";
|
||||
import qobuzIcon from "../assets/icons/qobuz.png";
|
||||
import tidalIcon from "../assets/icons/tidal.png";
|
||||
const PLATFORM_ICON_URLS = {
|
||||
tidal: tidalIcon,
|
||||
qobuz: qobuzIcon,
|
||||
amazon: amazonMusicIcon,
|
||||
} as const;
|
||||
type PlatformIconProps = {
|
||||
className?: string;
|
||||
}) => (<svg viewBox="0 0 24 24" className={`${className} fill-current`}>
|
||||
<path d="M4.022 4.5 0 8.516l3.997 3.99 3.997-3.984L4.022 4.5Zm7.956 0L7.994 8.522l4.003 3.984L16 8.484 11.978 4.5Zm8.007 0L24 8.528l-4.003 3.978L16 8.484 19.985 4.5Z"></path>
|
||||
<path d="m8.012 16.534 3.991 3.966L16 16.49l-4.003-3.984-3.985 4.028Z"></path>
|
||||
</svg>);
|
||||
export const QobuzIcon = ({ className = "w-4 h-4" }: {
|
||||
};
|
||||
function sanitizeClassName(className: string): string {
|
||||
return className
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.filter((part) => part !== "fill-current" && part !== "fill-muted-foreground" && !part.startsWith("text-"))
|
||||
.join(" ");
|
||||
}
|
||||
function hasRoundedClass(className: string): boolean {
|
||||
return className
|
||||
.split(/\s+/)
|
||||
.some((part) => part.startsWith("rounded"));
|
||||
}
|
||||
function getStatusClasses(className: string): string {
|
||||
if (className.includes("text-green-500")) {
|
||||
return "ring-2 ring-green-500 rounded-sm";
|
||||
}
|
||||
if (className.includes("text-red-500")) {
|
||||
return "ring-2 ring-red-500 rounded-sm opacity-70";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
function PlatformIcon({ src, alt, className = "w-4 h-4", defaultClassName = "" }: {
|
||||
src: string;
|
||||
alt: string;
|
||||
className?: string;
|
||||
}) => (<svg viewBox="0 0 24 24" className={`${className} fill-current`}>
|
||||
<path d="M21.744 9.815C19.836 1.261 8.393-1 3.55 6.64-.618 13.214 4 22 11.988 22c2.387 0 4.63-.83 6.394-2.304l2.252 2.252 1.224-1.224-2.252-2.253c1.983-2.407 2.823-5.586 2.138-8.656Zm-3.508 7.297L16.4 15.275c-.786-.787-2.017.432-1.224 1.225L17 18.326C10.29 23.656.5 16 5.16 7.667c3.502-6.264 13.172-4.348 14.707 2.574.529 2.385-.06 4.987-1.63 6.87Z"></path>
|
||||
<path d="M13.4 8.684a3.59 3.59 0 0 0-4.712 1.9 3.59 3.59 0 0 0 1.9 4.712 3.594 3.594 0 0 0 4.711-1.89 3.598 3.598 0 0 0-1.9-4.722Zm-.737 3.591a.727.727 0 0 1-.965.384.727.727 0 0 1-.384-.965.727.727 0 0 1 .965-.384.73.73 0 0 1 .384.965Z"></path>
|
||||
</svg>);
|
||||
export const AmazonIcon = ({ className = "w-4 h-4" }: {
|
||||
className?: string;
|
||||
}) => (<svg viewBox="0 0 24 24" className={`${className} fill-current`}>
|
||||
<path fillRule="evenodd" d="M15.62 11.13c-.15.1-.37.18-.64.18-.42 0-.82-.05-1.21-.18l-.22-.04c-.08 0-.1.04-.1.14v.25c0 .08.02.12.05.17.02.03.07.08.15.1.4.18.84.25 1.33.25.52 0 .91-.12 1.24-.37.32-.25.47-.57.47-.99 0-.3-.08-.52-.23-.72-.15-.17-.4-.34-.74-.47l-.7-.27c-.26-.1-.46-.2-.53-.3a.47.47 0 0 1-.15-.36c0-.38.27-.57.84-.57.32 0 .64.05.94.15l.2.04c.07 0 .12-.04.12-.14v-.25c0-.08-.03-.12-.05-.17-.03-.05-.08-.08-.15-.1-.37-.13-.74-.2-1.11-.2-.47 0-.87.12-1.16.35-.3.22-.45.54-.45.91 0 .57.32.99.97 1.24l.74.27c.24.1.4.17.5.27.09.1.12.2.12.35 0 .2-.08.37-.23.46Zm-3.88-3.55v3.28c-.42.28-.84.42-1.26.42-.27 0-.47-.07-.6-.22-.11-.15-.16-.37-.16-.7V7.59c0-.13-.05-.18-.18-.18h-.52c-.12 0-.17.05-.17.18v3.06c0 .42.1.77.32.99.22.22.55.35.97.35.56 0 1.13-.2 1.68-.6l.05.3c0 .07.02.1.07.12.02.03.07.03.15.03h.37c.12 0 .17-.05.17-.18V7.58c0-.13-.05-.18-.17-.18h-.52c-.15 0-.2.08-.2.18Zm-4.69 4.27h.52c.12 0 .17-.05.17-.17v-3.1c0-.41-.1-.73-.32-.95a1.25 1.25 0 0 0-.94-.35c-.57 0-1.16.2-1.73.62-.2-.42-.57-.62-1.11-.62-.55 0-1.1.2-1.64.57l-.04-.27c0-.08-.03-.1-.08-.13-.02-.02-.07-.02-.12-.02h-.4c-.12 0-.17.05-.17.17v4.1c0 .13.05.18.17.18h.52c.12 0 .17-.05.17-.18V8.37c.42-.25.84-.4 1.29-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.17.17h.52c.13 0 .18-.05.18-.17V8.37c.44-.27.86-.4 1.28-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.18.17Zm13.47 3.29a21.8 21.8 0 0 1-8.3 1.7c-3.96 0-7.8-1.08-10.88-2.89a.35.35 0 0 0-.15-.05c-.17 0-.27.2-.1.37a16.11 16.11 0 0 0 10.87 4.16c3.02 0 6.5-.94 8.9-2.72.42-.3.08-.74-.34-.57Zm-.08-6.74c.22-.26.57-.38 1.06-.38.25 0 .5.03.72.1l.15.02c.07 0 .12-.04.12-.17v-.25c0-.07-.02-.14-.05-.17a.54.54 0 0 0-.12-.1c-.32-.07-.64-.15-.94-.15-.7 0-1.21.2-1.6.62-.38.4-.57 1-.57 1.73 0 .74.17 1.31.54 1.7.37.4.89.6 1.58.6.37 0 .72-.05.99-.17.07-.03.12-.05.15-.1.02-.03.02-.1.02-.17v-.25c0-.13-.05-.17-.12-.17-.03 0-.07 0-.12.02-.28.07-.55.12-.8.12-.46 0-.81-.12-1.03-.37-.23-.24-.32-.64-.32-1.16v-.12c.02-.55.12-.94.34-1.19Z" clipRule="evenodd"></path>
|
||||
<path fillRule="evenodd" d="M21.55 17.46c1.29-1.09 1.64-3.33 1.36-3.68-.12-.15-.71-.3-1.45-.3-.8 0-1.73.18-2.45.67-.22.15-.17.35.05.32.76-.1 2.5-.3 2.82.1.3.4-.35 2.03-.65 2.74-.07.23.1.3.32.15ZM18.12 7.4h-.52c-.12 0-.17.05-.17.18v4.1c0 .12.05.17.17.17h.52c.12 0 .17-.05.17-.17v-4.1c0-.1-.05-.18-.17-.18Zm.15-1.68a.58.58 0 0 0-.42-.15c-.18 0-.3.05-.4.15a.5.5 0 0 0-.15.37c0 .15.05.3.15.37.1.1.22.15.4.15.17 0 .3-.05.4-.15a.5.5 0 0 0 .14-.37c0-.15-.02-.3-.12-.37Z" clipRule="evenodd"></path>
|
||||
</svg>);
|
||||
defaultClassName?: string;
|
||||
}) {
|
||||
const cleanedClassName = sanitizeClassName(className);
|
||||
const statusClasses = getStatusClasses(className);
|
||||
const imageClassName = [
|
||||
cleanedClassName || "w-4 h-4",
|
||||
"inline-block shrink-0 object-contain",
|
||||
!hasRoundedClass(cleanedClassName) ? defaultClassName : "",
|
||||
statusClasses,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
return <img src={src} alt={alt} className={imageClassName} loading="lazy" referrerPolicy="no-referrer"/>;
|
||||
}
|
||||
export function TidalIcon({ className = "w-4 h-4" }: PlatformIconProps) {
|
||||
return <PlatformIcon src={PLATFORM_ICON_URLS.tidal} alt="Tidal" className={className} defaultClassName="rounded-[4px]"/>;
|
||||
}
|
||||
export function QobuzIcon({ className = "w-4 h-4" }: PlatformIconProps) {
|
||||
return <PlatformIcon src={PLATFORM_ICON_URLS.qobuz} alt="Qobuz" className={className}/>;
|
||||
}
|
||||
export function AmazonIcon({ className = "w-4 h-4" }: PlatformIconProps) {
|
||||
return <PlatformIcon src={PLATFORM_ICON_URLS.amazon} alt="Amazon Music" className={className} defaultClassName="rounded-[4px]"/>;
|
||||
}
|
||||
export function TidalAvailabilityIcon({ className = "w-4 h-4" }: PlatformIconProps) {
|
||||
return <svg viewBox="0 0 24 24" className={`${className} fill-current`}>
|
||||
<path d="M4.022 4.5 0 8.516l3.997 3.99 3.997-3.984L4.022 4.5Zm7.956 0L7.994 8.522l4.003 3.984L16 8.484 11.978 4.5Zm8.007 0L24 8.528l-4.003 3.978L16 8.484 19.985 4.5Z"></path>
|
||||
<path d="m8.012 16.534 3.991 3.966L16 16.49l-4.003-3.984-3.985 4.028Z"></path>
|
||||
</svg>;
|
||||
}
|
||||
export function QobuzAvailabilityIcon({ className = "w-4 h-4" }: PlatformIconProps) {
|
||||
return <svg viewBox="0 0 24 24" className={`${className} fill-current`}>
|
||||
<path d="M21.744 9.815C19.836 1.261 8.393-1 3.55 6.64-.618 13.214 4 22 11.988 22c2.387 0 4.63-.83 6.394-2.304l2.252 2.252 1.224-1.224-2.252-2.253c1.983-2.407 2.823-5.586 2.138-8.656Zm-3.508 7.297L16.4 15.275c-.786-.787-2.017.432-1.224 1.225L17 18.326C10.29 23.656.5 16 5.16 7.667c3.502-6.264 13.172-4.348 14.707 2.574.529 2.385-.06 4.987-1.63 6.87Z"></path>
|
||||
<path d="M13.4 8.684a3.59 3.59 0 0 0-4.712 1.9 3.59 3.59 0 0 0 1.9 4.712 3.594 3.594 0 0 0 4.711-1.89 3.598 3.598 0 0 0-1.9-4.722Zm-.737 3.591a.727.727 0 0 1-.965.384.727.727 0 0 1-.384-.965.727.727 0 0 1 .965-.384.73.73 0 0 1 .384.965Z"></path>
|
||||
</svg>;
|
||||
}
|
||||
export function AmazonAvailabilityIcon({ className = "w-4 h-4" }: PlatformIconProps) {
|
||||
return <svg viewBox="0 0 24 24" className={`${className} fill-current`}>
|
||||
<path fillRule="evenodd" d="M15.62 11.13c-.15.1-.37.18-.64.18-.42 0-.82-.05-1.21-.18l-.22-.04c-.08 0-.1.04-.1.14v.25c0 .08.02.12.05.17.02.03.07.08.15.1.4.18.84.25 1.33.25.52 0 .91-.12 1.24-.37.32-.25.47-.57.47-.99 0-.3-.08-.52-.23-.72-.15-.17-.4-.34-.74-.47l-.7-.27c-.26-.1-.46-.2-.53-.3a.47.47 0 0 1-.15-.36c0-.38.27-.57.84-.57.32 0 .64.05.94.15l.2.04c.07 0 .12-.04.12-.14v-.25c0-.08-.03-.12-.05-.17-.03-.05-.08-.08-.15-.1-.37-.13-.74-.2-1.11-.2-.47 0-.87.12-1.16.35-.3.22-.45.54-.45.91 0 .57.32.99.97 1.24l.74.27c.24.1.4.17.5.27.09.1.12.2.12.35 0 .2-.08.37-.23.46Zm-3.88-3.55v3.28c-.42.28-.84.42-1.26.42-.27 0-.47-.07-.6-.22-.11-.15-.16-.37-.16-.7V7.59c0-.13-.05-.18-.18-.18h-.52c-.12 0-.17.05-.17.18v3.06c0 .42.1.77.32.99.22.22.55.35.97.35.56 0 1.13-.2 1.68-.6l.05.3c0 .07.02.1.07.12.02.03.07.03.15.03h.37c.12 0 .17-.05.17-.18V7.58c0-.13-.05-.18-.17-.18h-.52c-.15 0-.2.08-.2.18Zm-4.69 4.27h.52c.12 0 .17-.05.17-.17v-3.1c0-.41-.1-.73-.32-.95a1.25 1.25 0 0 0-.94-.35c-.57 0-1.16.2-1.73.62-.2-.42-.57-.62-1.11-.62-.55 0-1.1.2-1.64.57l-.04-.27c0-.08-.03-.1-.08-.13-.02-.02-.07-.02-.12-.02h-.4c-.12 0-.17.05-.17.17v4.1c0 .13.05.18.17.18h.52c.12 0 .17-.05.17-.18V8.37c.42-.25.84-.4 1.29-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.17.17h.52c.13 0 .18-.05.18-.17V8.37c.44-.27.86-.4 1.28-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.18.17Zm13.47 3.29a21.8 21.8 0 0 1-8.3 1.7c-3.96 0-7.8-1.08-10.88-2.89a.35.35 0 0 0-.15-.05c-.17 0-.27.2-.1.37a16.11 16.11 0 0 0 10.87 4.16c3.02 0 6.5-.94 8.9-2.72.42-.3.08-.74-.34-.57Zm-.08-6.74c.22-.26.57-.38 1.06-.38.25 0 .5.03.72.1l.15.02c.07 0 .12-.04.12-.17v-.25c0-.07-.02-.14-.05-.17a.54.54 0 0 0-.12-.1c-.32-.07-.64-.15-.94-.15-.7 0-1.21.2-1.6.62-.38.4-.57 1-.57 1.73 0 .74.17 1.31.54 1.7.37.4.89.6 1.58.6.37 0 .72-.05.99-.17.07-.03.12-.05.15-.1.02-.03.02-.1.02-.17v-.25c0-.13-.05-.17-.12-.17-.03 0-.07 0-.12.02-.28.07-.55.12-.8.12-.46 0-.81-.12-1.03-.37-.23-.24-.32-.64-.32-1.16v-.12c.02-.55.12-.94.34-1.19Z" clipRule="evenodd"></path>
|
||||
<path fillRule="evenodd" d="M21.55 17.46c1.29-1.09 1.64-3.33 1.36-3.68-.12-.15-.71-.3-1.45-.3-.8 0-1.73.18-2.45.67-.22.15-.17.35.05.32.76-.1 2.5-.3 2.82.1.3.4-.35 2.03-.65 2.74-.07.23.1.3.32.15ZM18.12 7.4h-.52c-.12 0-.17.05-.17.18v4.1c0 .12.05.17.17.17h.52c.12 0 .17-.05.17-.17v-4.1c0-.1-.05-.18-.17-.18Zm.15-1.68a.58.58 0 0 0-.42-.15c-.18 0-.3.05-.4.15a.5.5 0 0 0-.15.37c0 .15.05.3.15.37.1.1.22.15.4.15.17 0 .3-.05.4-.15a.5.5 0 0 0 .14-.37c0-.15-.02-.3-.12-.37Z" clipRule="evenodd"></path>
|
||||
</svg>;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Download, FolderOpen, ImageDown, FileText } from "lucide-react";
|
||||
import { Download, FolderOpen, ImageDown, FileText, XCircle } from "lucide-react";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { SearchAndSort } from "./SearchAndSort";
|
||||
import { TrackList } from "./TrackList";
|
||||
import { DownloadProgress } from "./DownloadProgress";
|
||||
import { getSettings } from "@/lib/settings";
|
||||
import { downloadCover } from "@/lib/api";
|
||||
import { useState } from "react";
|
||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||
import { joinPath, sanitizePath } from "@/lib/utils";
|
||||
import { parseTemplate, type TemplateData } from "@/lib/settings";
|
||||
import type { TrackMetadata, TrackAvailability } from "@/types/api";
|
||||
interface PlaylistInfoProps {
|
||||
playlistInfo: {
|
||||
@@ -54,9 +60,9 @@ interface PlaylistInfoProps {
|
||||
isBulkDownloadingLyrics?: boolean;
|
||||
onSearchChange: (value: string) => void;
|
||||
onSortChange: (value: string) => void;
|
||||
onToggleTrack: (isrc: string) => void;
|
||||
onToggleTrack: (id: string) => void;
|
||||
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
|
||||
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => void;
|
||||
onDownloadTrack: (id: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => void;
|
||||
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
|
||||
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
|
||||
onCheckAvailability?: (spotifyId: string) => void;
|
||||
@@ -78,13 +84,91 @@ interface PlaylistInfoProps {
|
||||
external_urls: string;
|
||||
}) => void;
|
||||
onTrackClick: (track: TrackMetadata) => void;
|
||||
onBack?: () => void;
|
||||
}
|
||||
export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onPageChange, onAlbumClick, onArtistClick, onTrackClick, }: PlaylistInfoProps) {
|
||||
export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onPageChange, onAlbumClick, onArtistClick, onTrackClick, onBack, }: PlaylistInfoProps) {
|
||||
const settings = getSettings();
|
||||
const [downloadingPlaylistCover, setDownloadingPlaylistCover] = useState(false);
|
||||
const handleDownloadPlaylistCover = async () => {
|
||||
if (!playlistInfo.cover)
|
||||
return;
|
||||
setDownloadingPlaylistCover(true);
|
||||
try {
|
||||
const os = settings.operatingSystem;
|
||||
let outputDir = settings.downloadPath;
|
||||
const playlistName = playlistInfo.owner.name;
|
||||
const placeholder = "__SLASH_PLACEHOLDER__";
|
||||
const templateData: TemplateData = {
|
||||
artist: "",
|
||||
album: "",
|
||||
album_artist: "",
|
||||
title: playlistName.replace(/\//g, placeholder),
|
||||
playlist: playlistName.replace(/\//g, placeholder),
|
||||
};
|
||||
if (settings.createPlaylistFolder && playlistName) {
|
||||
outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
|
||||
}
|
||||
if (settings.folderTemplate) {
|
||||
const folderPath = parseTemplate(settings.folderTemplate, templateData);
|
||||
if (folderPath) {
|
||||
const parts = folderPath.split("/").filter((p: string) => p.trim());
|
||||
for (const part of parts) {
|
||||
outputDir = joinPath(os, outputDir, sanitizePath(part.replace(new RegExp(placeholder, "g"), " "), os));
|
||||
}
|
||||
}
|
||||
}
|
||||
const response = await downloadCover({
|
||||
cover_url: playlistInfo.cover,
|
||||
track_name: playlistName,
|
||||
artist_name: "",
|
||||
album_name: "",
|
||||
album_artist: "",
|
||||
release_date: "",
|
||||
output_dir: outputDir,
|
||||
filename_format: "title",
|
||||
track_number: false,
|
||||
position: 0,
|
||||
disc_number: 0,
|
||||
});
|
||||
if (response.success) {
|
||||
if (response.already_exists)
|
||||
toast.info("Cover already exists");
|
||||
else
|
||||
toast.success("Separate playlist cover downloaded");
|
||||
}
|
||||
else {
|
||||
toast.error(response.error || "Failed to download cover");
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to download cover");
|
||||
}
|
||||
finally {
|
||||
setDownloadingPlaylistCover(false);
|
||||
}
|
||||
};
|
||||
return (<div className="space-y-6">
|
||||
<Card>
|
||||
<Card className="relative">
|
||||
{onBack && (<div className="absolute top-4 right-4 z-10">
|
||||
<Button variant="ghost" size="icon" onClick={onBack}>
|
||||
<XCircle className="h-5 w-5"/>
|
||||
</Button>
|
||||
</div>)}
|
||||
<CardContent className="px-6">
|
||||
<div className="flex gap-6 items-start">
|
||||
{playlistInfo.cover && (<img src={playlistInfo.cover} alt={playlistInfo.owner.name} className="w-48 h-48 rounded-md shadow-lg object-cover"/>)}
|
||||
{playlistInfo.cover && (<div className="relative group shrink-0 w-48 h-48">
|
||||
<img src={playlistInfo.cover} alt={playlistInfo.owner.name} className="w-48 h-48 rounded-md shadow-lg object-cover"/>
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity rounded-md">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="secondary" size="icon" className="h-9 w-9 shadow-lg" onClick={handleDownloadPlaylistCover} disabled={downloadingPlaylistCover}>
|
||||
{downloadingPlaylistCover ? <Spinner /> : <ImageDown className="h-4 w-4"/>}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent><p>Download Separate Playlist Cover</p></TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>)}
|
||||
<div className="flex-1 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">Playlist</p>
|
||||
@@ -97,10 +181,10 @@ export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, sel
|
||||
</div>
|
||||
<span>•</span>
|
||||
<span>
|
||||
{playlistInfo.tracks.total} {playlistInfo.tracks.total === 1 ? "song" : "songs"}
|
||||
{playlistInfo.tracks.total.toLocaleString()} {playlistInfo.tracks.total === 1 ? "track" : "tracks"}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>{playlistInfo.followers.total.toLocaleString()} followers</span>
|
||||
<span>{playlistInfo.followers.total.toLocaleString()} {playlistInfo.followers.total === 1 ? "follower" : "followers"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
@@ -110,7 +194,7 @@ export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, sel
|
||||
</Button>
|
||||
{selectedTracks.length > 0 && (<Button onClick={onDownloadSelected} variant="secondary" disabled={isDownloading}>
|
||||
{isDownloading && bulkDownloadType === "selected" ? (<Spinner />) : (<Download className="h-4 w-4"/>)}
|
||||
Download Selected ({selectedTracks.length})
|
||||
Download Selected ({selectedTracks.length.toLocaleString()})
|
||||
</Button>)}
|
||||
{onDownloadAllLyrics && (<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -129,13 +213,19 @@ export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, sel
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Download All Covers</p>
|
||||
<p>Download All Separate Covers</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>)}
|
||||
{downloadedTracks.size > 0 && (<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button onClick={onOpenFolder} variant="outline" size="icon">
|
||||
<FolderOpen className="h-4 w-4"/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Open Folder</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>)}
|
||||
{downloadedTracks.size > 0 && (<Button onClick={onOpenFolder} variant="outline">
|
||||
<FolderOpen className="h-4 w-4"/>
|
||||
Open Folder
|
||||
</Button>)}
|
||||
</div>
|
||||
{isDownloading && (<DownloadProgress progress={downloadProgress} currentTrack={currentDownloadInfo} onStop={onStopDownload}/>)}
|
||||
</div>
|
||||
|
||||
@@ -33,6 +33,7 @@ export function SearchAndSort({ searchQuery, sortBy, onSearchChange, onSortChang
|
||||
<SelectItem value="plays-desc">Plays (High)</SelectItem>
|
||||
<SelectItem value="downloaded">Downloaded</SelectItem>
|
||||
<SelectItem value="not-downloaded">Not Downloaded</SelectItem>
|
||||
<SelectItem value="failed">Failed Downloads</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useState, useEffect, useRef, useMemo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { InputWithContext } from "@/components/ui/input-with-context";
|
||||
import { CloudDownload, Info, XCircle, Link, Search, X, ChevronDown } from "lucide-react";
|
||||
import { CloudDownload, XCircle, Link, Search, X, ChevronDown, ArrowUpDown, } from "lucide-react";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
|
||||
import { FetchHistory } from "@/components/FetchHistory";
|
||||
@@ -9,6 +10,220 @@ import type { HistoryItem } from "@/components/FetchHistory";
|
||||
import { SearchSpotify, SearchSpotifyByType } from "../../wailsjs/go/main/App";
|
||||
import { backend } from "../../wailsjs/go/models";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTypingEffect } from "@/hooks/useTypingEffect";
|
||||
import { getSettings, type Settings } from "@/lib/settings";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
|
||||
const FETCH_PLACEHOLDERS = [
|
||||
"https://open.spotify.com/track/...",
|
||||
"https://open.spotify.com/album/...",
|
||||
"https://open.spotify.com/playlist/...",
|
||||
"https://open.spotify.com/artist/...",
|
||||
];
|
||||
const SEARCH_PLACEHOLDERS = [
|
||||
"Golden",
|
||||
"Taylor Swift",
|
||||
"The Weeknd",
|
||||
"Starboy",
|
||||
"Joji",
|
||||
"Die For You",
|
||||
];
|
||||
const REGIONS = [
|
||||
"AD",
|
||||
"AE",
|
||||
"AG",
|
||||
"AL",
|
||||
"AM",
|
||||
"AO",
|
||||
"AR",
|
||||
"AT",
|
||||
"AU",
|
||||
"AZ",
|
||||
"BA",
|
||||
"BB",
|
||||
"BD",
|
||||
"BE",
|
||||
"BF",
|
||||
"BG",
|
||||
"BH",
|
||||
"BI",
|
||||
"BJ",
|
||||
"BN",
|
||||
"BO",
|
||||
"BR",
|
||||
"BS",
|
||||
"BT",
|
||||
"BW",
|
||||
"BZ",
|
||||
"CA",
|
||||
"CD",
|
||||
"CG",
|
||||
"CH",
|
||||
"CI",
|
||||
"CL",
|
||||
"CM",
|
||||
"CO",
|
||||
"CR",
|
||||
"CV",
|
||||
"CW",
|
||||
"CY",
|
||||
"CZ",
|
||||
"DE",
|
||||
"DJ",
|
||||
"DK",
|
||||
"DM",
|
||||
"DO",
|
||||
"DZ",
|
||||
"EC",
|
||||
"EE",
|
||||
"EG",
|
||||
"ES",
|
||||
"ET",
|
||||
"FI",
|
||||
"FJ",
|
||||
"FM",
|
||||
"FR",
|
||||
"GA",
|
||||
"GB",
|
||||
"GD",
|
||||
"GE",
|
||||
"GH",
|
||||
"GM",
|
||||
"GN",
|
||||
"GQ",
|
||||
"GR",
|
||||
"GT",
|
||||
"GW",
|
||||
"GY",
|
||||
"HK",
|
||||
"HN",
|
||||
"HR",
|
||||
"HT",
|
||||
"HU",
|
||||
"ID",
|
||||
"IE",
|
||||
"IL",
|
||||
"IN",
|
||||
"IQ",
|
||||
"IS",
|
||||
"IT",
|
||||
"JM",
|
||||
"JO",
|
||||
"JP",
|
||||
"KE",
|
||||
"KG",
|
||||
"KH",
|
||||
"KI",
|
||||
"KM",
|
||||
"KN",
|
||||
"KR",
|
||||
"KW",
|
||||
"KZ",
|
||||
"LA",
|
||||
"LB",
|
||||
"LC",
|
||||
"LI",
|
||||
"LK",
|
||||
"LR",
|
||||
"LS",
|
||||
"LT",
|
||||
"LU",
|
||||
"LV",
|
||||
"LY",
|
||||
"MA",
|
||||
"MC",
|
||||
"MD",
|
||||
"ME",
|
||||
"MG",
|
||||
"MH",
|
||||
"MK",
|
||||
"ML",
|
||||
"MN",
|
||||
"MO",
|
||||
"MR",
|
||||
"MT",
|
||||
"MU",
|
||||
"MV",
|
||||
"MW",
|
||||
"MX",
|
||||
"MY",
|
||||
"MZ",
|
||||
"NA",
|
||||
"NE",
|
||||
"NG",
|
||||
"NI",
|
||||
"NL",
|
||||
"NO",
|
||||
"NP",
|
||||
"NR",
|
||||
"NZ",
|
||||
"OM",
|
||||
"PA",
|
||||
"PE",
|
||||
"PG",
|
||||
"PH",
|
||||
"PK",
|
||||
"PL",
|
||||
"PS",
|
||||
"PT",
|
||||
"PW",
|
||||
"PY",
|
||||
"QA",
|
||||
"RO",
|
||||
"RS",
|
||||
"RW",
|
||||
"SA",
|
||||
"SB",
|
||||
"SC",
|
||||
"SE",
|
||||
"SG",
|
||||
"SI",
|
||||
"SK",
|
||||
"SL",
|
||||
"SM",
|
||||
"SN",
|
||||
"SR",
|
||||
"ST",
|
||||
"SV",
|
||||
"SZ",
|
||||
"TD",
|
||||
"TG",
|
||||
"TH",
|
||||
"TJ",
|
||||
"TL",
|
||||
"TN",
|
||||
"TO",
|
||||
"TR",
|
||||
"TT",
|
||||
"TV",
|
||||
"TW",
|
||||
"TZ",
|
||||
"UA",
|
||||
"UG",
|
||||
"US",
|
||||
"UY",
|
||||
"UZ",
|
||||
"VC",
|
||||
"VE",
|
||||
"VN",
|
||||
"VU",
|
||||
"WS",
|
||||
"XK",
|
||||
"ZA",
|
||||
"ZM",
|
||||
"ZW",
|
||||
];
|
||||
const regionNames = new Intl.DisplayNames(["en"], { type: "region" });
|
||||
const getRegionName = (code: string) => {
|
||||
try {
|
||||
if (code === "XK")
|
||||
return "Kosovo";
|
||||
return regionNames.of(code) || code;
|
||||
}
|
||||
catch (e) {
|
||||
return code;
|
||||
}
|
||||
};
|
||||
type ResultTab = "tracks" | "albums" | "artists" | "playlists";
|
||||
const RECENT_SEARCHES_KEY = "spotiflac_recent_searches";
|
||||
const MAX_RECENT_SEARCHES = 8;
|
||||
@@ -25,10 +240,20 @@ interface SearchBarProps {
|
||||
hasResult: boolean;
|
||||
searchMode: boolean;
|
||||
onSearchModeChange: (isSearch: boolean) => void;
|
||||
region: string;
|
||||
onRegionChange: (region: string) => void;
|
||||
}
|
||||
export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, history, onHistorySelect, onHistoryRemove, hasResult, searchMode, onSearchModeChange, }: SearchBarProps) {
|
||||
export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, history, onHistorySelect, onHistoryRemove, hasResult, searchMode, onSearchModeChange, region, onRegionChange, }: SearchBarProps) {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [searchResults, setSearchResults] = useState<backend.SearchResponse | null>(null);
|
||||
const [showRegionSelector, setShowRegionSelector] = useState(() => getSettings().linkResolver === "songlink");
|
||||
const [resultFilter, setResultFilter] = useState("");
|
||||
const [sortOrders, setSortOrders] = useState<Record<ResultTab, string>>({
|
||||
tracks: "default",
|
||||
albums: "default",
|
||||
artists: "default",
|
||||
playlists: "default",
|
||||
});
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
const [lastSearchedQuery, setLastSearchedQuery] = useState("");
|
||||
@@ -40,7 +265,11 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist
|
||||
artists: false,
|
||||
playlists: false,
|
||||
});
|
||||
const [showInvalidUrlDialog, setShowInvalidUrlDialog] = useState(false);
|
||||
const [invalidUrl, setInvalidUrl] = useState("");
|
||||
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const placeholders = searchMode ? SEARCH_PLACEHOLDERS : FETCH_PLACEHOLDERS;
|
||||
const placeholderText = useTypingEffect(placeholders);
|
||||
useEffect(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem(RECENT_SEARCHES_KEY);
|
||||
@@ -52,6 +281,18 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist
|
||||
console.error("Failed to load recent searches:", error);
|
||||
}
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const syncRegionVisibility = (settings?: Partial<Settings>) => {
|
||||
const resolver = settings?.linkResolver ?? getSettings().linkResolver;
|
||||
setShowRegionSelector(resolver === "songlink");
|
||||
};
|
||||
syncRegionVisibility();
|
||||
const handleSettingsUpdate = (event: Event) => {
|
||||
syncRegionVisibility((event as CustomEvent<Partial<Settings>>).detail);
|
||||
};
|
||||
window.addEventListener("settingsUpdated", handleSettingsUpdate);
|
||||
return () => window.removeEventListener("settingsUpdated", handleSettingsUpdate);
|
||||
}, []);
|
||||
const saveRecentSearch = (query: string) => {
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed)
|
||||
@@ -93,8 +334,12 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist
|
||||
searchTimeoutRef.current = setTimeout(async () => {
|
||||
setIsSearching(true);
|
||||
try {
|
||||
const results = await SearchSpotify({ query: searchQuery, limit: SEARCH_LIMIT });
|
||||
const results = await SearchSpotify({
|
||||
query: searchQuery,
|
||||
limit: SEARCH_LIMIT,
|
||||
});
|
||||
setSearchResults(results);
|
||||
setResultFilter("");
|
||||
setLastSearchedQuery(searchQuery.trim());
|
||||
saveRecentSearch(searchQuery.trim());
|
||||
setHasMore({
|
||||
@@ -149,10 +394,18 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist
|
||||
if (!prev)
|
||||
return prev;
|
||||
const updated = new backend.SearchResponse({
|
||||
tracks: activeTab === "tracks" ? [...prev.tracks, ...moreResults] : prev.tracks,
|
||||
albums: activeTab === "albums" ? [...prev.albums, ...moreResults] : prev.albums,
|
||||
artists: activeTab === "artists" ? [...prev.artists, ...moreResults] : prev.artists,
|
||||
playlists: activeTab === "playlists" ? [...prev.playlists, ...moreResults] : prev.playlists,
|
||||
tracks: activeTab === "tracks"
|
||||
? [...prev.tracks, ...moreResults]
|
||||
: prev.tracks,
|
||||
albums: activeTab === "albums"
|
||||
? [...prev.albums, ...moreResults]
|
||||
: prev.albums,
|
||||
artists: activeTab === "artists"
|
||||
? [...prev.artists, ...moreResults]
|
||||
: prev.artists,
|
||||
playlists: activeTab === "playlists"
|
||||
? [...prev.playlists, ...moreResults]
|
||||
: prev.playlists,
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
@@ -169,6 +422,35 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist
|
||||
setIsLoadingMore(false);
|
||||
}
|
||||
};
|
||||
const isSpotifyUrl = (text: string) => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed)
|
||||
return true;
|
||||
const isUrl = /^(https?:\/\/|www\.)/i.test(trimmed) || /^spotify:/i.test(trimmed);
|
||||
if (!isUrl)
|
||||
return true;
|
||||
return (trimmed.includes("spotify.com") ||
|
||||
trimmed.includes("spotify.link") ||
|
||||
trimmed.startsWith("spotify:"));
|
||||
};
|
||||
const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
|
||||
if (searchMode)
|
||||
return;
|
||||
const pastedText = e.clipboardData.getData("text");
|
||||
if (pastedText && !isSpotifyUrl(pastedText)) {
|
||||
e.preventDefault();
|
||||
setInvalidUrl(pastedText);
|
||||
setShowInvalidUrlDialog(true);
|
||||
}
|
||||
};
|
||||
const handleFetchWithValidation = () => {
|
||||
if (!isSpotifyUrl(url)) {
|
||||
setInvalidUrl(url);
|
||||
setShowInvalidUrlDialog(true);
|
||||
return;
|
||||
}
|
||||
onFetch();
|
||||
};
|
||||
const handleResultClick = (externalUrl: string) => {
|
||||
onSearchModeChange(false);
|
||||
onFetchUrl(externalUrl);
|
||||
@@ -178,20 +460,107 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist
|
||||
const seconds = Math.floor((ms % 60000) / 1000);
|
||||
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
||||
};
|
||||
const hasAnyResults = searchResults && (searchResults.tracks.length > 0 ||
|
||||
searchResults.albums.length > 0 ||
|
||||
searchResults.artists.length > 0 ||
|
||||
searchResults.playlists.length > 0);
|
||||
const hasAnyResults = searchResults &&
|
||||
(searchResults.tracks.length > 0 ||
|
||||
searchResults.albums.length > 0 ||
|
||||
searchResults.artists.length > 0 ||
|
||||
searchResults.playlists.length > 0);
|
||||
const getTabCount = (tab: ResultTab): number => {
|
||||
if (!searchResults)
|
||||
return 0;
|
||||
switch (tab) {
|
||||
case "tracks": return searchResults.tracks.length;
|
||||
case "albums": return searchResults.albums.length;
|
||||
case "artists": return searchResults.artists.length;
|
||||
case "playlists": return searchResults.playlists.length;
|
||||
case "tracks":
|
||||
return searchResults.tracks.length;
|
||||
case "albums":
|
||||
return searchResults.albums.length;
|
||||
case "artists":
|
||||
return searchResults.artists.length;
|
||||
case "playlists":
|
||||
return searchResults.playlists.length;
|
||||
}
|
||||
};
|
||||
const sortedResults = useMemo(() => {
|
||||
if (!searchResults)
|
||||
return { tracks: [], albums: [], artists: [], playlists: [] };
|
||||
const filterStr = resultFilter.toLowerCase();
|
||||
let tracks = [...searchResults.tracks];
|
||||
if (filterStr) {
|
||||
tracks = tracks.filter(t => (t.name || '').toLowerCase().includes(filterStr) || (t.artists || '').toLowerCase().includes(filterStr));
|
||||
}
|
||||
const tSort = sortOrders.tracks;
|
||||
if (tSort !== 'default') {
|
||||
tracks.sort((a, b) => {
|
||||
if (tSort === 'title-asc')
|
||||
return (a.name || '').localeCompare(b.name || '');
|
||||
if (tSort === 'title-desc')
|
||||
return (b.name || '').localeCompare(a.name || '');
|
||||
if (tSort === 'artist-asc')
|
||||
return (a.artists || '').localeCompare(b.artists || '');
|
||||
if (tSort === 'artist-desc')
|
||||
return (b.artists || '').localeCompare(a.artists || '');
|
||||
if (tSort === 'duration-desc')
|
||||
return (b.duration_ms || 0) - (a.duration_ms || 0);
|
||||
if (tSort === 'duration-asc')
|
||||
return (a.duration_ms || 0) - (b.duration_ms || 0);
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
let albums = [...searchResults.albums];
|
||||
if (filterStr) {
|
||||
albums = albums.filter(a => (a.name || '').toLowerCase().includes(filterStr) || (a.artists || '').toLowerCase().includes(filterStr));
|
||||
}
|
||||
const alSort = sortOrders.albums;
|
||||
if (alSort !== 'default') {
|
||||
albums.sort((a, b) => {
|
||||
if (alSort === 'title-asc')
|
||||
return (a.name || '').localeCompare(b.name || '');
|
||||
if (alSort === 'title-desc')
|
||||
return (b.name || '').localeCompare(a.name || '');
|
||||
if (alSort === 'artist-asc')
|
||||
return (a.artists || '').localeCompare(b.artists || '');
|
||||
if (alSort === 'artist-desc')
|
||||
return (b.artists || '').localeCompare(a.artists || '');
|
||||
if (alSort === 'year-desc')
|
||||
return (b.release_date || '').localeCompare(a.release_date || '');
|
||||
if (alSort === 'year-asc')
|
||||
return (a.release_date || '').localeCompare(b.release_date || '');
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
let artists = [...searchResults.artists];
|
||||
if (filterStr) {
|
||||
artists = artists.filter(a => (a.name || '').toLowerCase().includes(filterStr));
|
||||
}
|
||||
const arSort = sortOrders.artists;
|
||||
if (arSort !== 'default') {
|
||||
artists.sort((a, b) => {
|
||||
if (arSort === 'name-asc')
|
||||
return (a.name || '').localeCompare(b.name || '');
|
||||
if (arSort === 'name-desc')
|
||||
return (b.name || '').localeCompare(a.name || '');
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
let playlists = [...searchResults.playlists];
|
||||
if (filterStr) {
|
||||
playlists = playlists.filter(p => (p.name || '').toLowerCase().includes(filterStr) || (p.owner || '').toLowerCase().includes(filterStr));
|
||||
}
|
||||
const pSort = sortOrders.playlists;
|
||||
if (pSort !== 'default') {
|
||||
playlists.sort((a, b) => {
|
||||
if (pSort === 'title-asc')
|
||||
return (a.name || '').localeCompare(b.name || '');
|
||||
if (pSort === 'title-desc')
|
||||
return (b.name || '').localeCompare(a.name || '');
|
||||
if (pSort === 'owner-asc')
|
||||
return (a.owner || '').localeCompare(b.owner || '');
|
||||
if (pSort === 'owner-desc')
|
||||
return (b.owner || '').localeCompare(a.owner || '');
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
return { tracks, albums, artists, playlists };
|
||||
}, [searchResults, sortOrders, resultFilter]);
|
||||
const tabs: {
|
||||
key: ResultTab;
|
||||
label: string;
|
||||
@@ -202,57 +571,52 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist
|
||||
{ key: "playlists", label: "Playlists" },
|
||||
];
|
||||
return (<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
<div className="flex items-center bg-muted rounded-md p-1">
|
||||
<button type="button" onClick={() => onSearchModeChange(false)} className={cn("flex items-center gap-1.5 px-2.5 py-1 rounded text-sm font-medium transition-colors cursor-pointer", !searchMode
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground")}>
|
||||
<Link className="h-3.5 w-3.5"/>
|
||||
URL
|
||||
</button>
|
||||
<button type="button" onClick={() => onSearchModeChange(true)} className={cn("flex items-center gap-1.5 px-2.5 py-1 rounded text-sm font-medium transition-colors cursor-pointer", searchMode
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground")}>
|
||||
<Search className="h-3.5 w-3.5"/>
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="h-4 w-4 text-muted-foreground cursor-help"/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
{!searchMode ? (<>
|
||||
<p>Supports track, album, playlist, and artist URLs</p>
|
||||
<p className="mt-1">Note: Playlist must be public (not private)</p>
|
||||
</>) : (<p>Search for tracks, albums, artists, or playlists</p>)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="outline" size="icon" className="shrink-0" onClick={() => onSearchModeChange(!searchMode)}>
|
||||
{searchMode ? (<Link className="h-4 w-4"/>) : (<Search className="h-4 w-4"/>)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{searchMode ? "Fetch Mode" : "Search Mode"}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
{!searchMode ? (<>
|
||||
<InputWithContext id="spotify-url" placeholder="https://open.spotify.com/..." value={url} onChange={(e) => onUrlChange(e.target.value)} onKeyDown={(e) => e.key === "Enter" && onFetch()} className="pr-8"/>
|
||||
{url && (<button type="button" className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" onClick={() => onUrlChange("")}>
|
||||
<XCircle className="h-4 w-4"/>
|
||||
</button>)}
|
||||
</>) : (<>
|
||||
<InputWithContext id="spotify-search" placeholder="Search tracks, albums, artists..." value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} className="pr-8"/>
|
||||
{searchQuery && (<button type="button" className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" onClick={() => {
|
||||
<div className="relative flex-1">
|
||||
{!searchMode ? (<>
|
||||
<InputWithContext id="spotify-url" placeholder={placeholderText} value={url} onChange={(e) => onUrlChange(e.target.value)} onPaste={handlePaste} onKeyDown={(e) => e.key === "Enter" && handleFetchWithValidation()} className="pr-8"/>
|
||||
{url && (<button type="button" className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" onClick={() => onUrlChange("")}>
|
||||
<XCircle className="h-4 w-4"/>
|
||||
</button>)}
|
||||
</>) : (<>
|
||||
<InputWithContext id="spotify-search" placeholder={placeholderText} value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} className="pr-8"/>
|
||||
{searchQuery && (<button type="button" className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" onClick={() => {
|
||||
setSearchQuery("");
|
||||
setSearchResults(null);
|
||||
setLastSearchedQuery("");
|
||||
setResultFilter("");
|
||||
}}>
|
||||
<XCircle className="h-4 w-4"/>
|
||||
</button>)}
|
||||
</>)}
|
||||
</div>
|
||||
<XCircle className="h-4 w-4"/>
|
||||
</button>)}
|
||||
</>)}
|
||||
</div>
|
||||
|
||||
{!searchMode && (<Button onClick={onFetch} disabled={loading}>
|
||||
{!searchMode && (<>
|
||||
{showRegionSelector && (<Select value={region} onValueChange={onRegionChange}>
|
||||
<SelectTrigger className="w-[70px] shrink-0">
|
||||
<SelectValue placeholder="Region"/>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-[300px]">
|
||||
{REGIONS.map((r) => (<SelectItem key={r} value={r} textValue={r}>
|
||||
{r}{" "}
|
||||
<span className="text-muted-foreground">
|
||||
({getRegionName(r)})
|
||||
</span>
|
||||
</SelectItem>))}
|
||||
</SelectContent>
|
||||
</Select>)}
|
||||
<Button onClick={handleFetchWithValidation} disabled={loading}>
|
||||
{loading ? (<>
|
||||
<Spinner />
|
||||
Fetching...
|
||||
@@ -260,15 +624,13 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist
|
||||
<CloudDownload className="h-4 w-4"/>
|
||||
Fetch
|
||||
</>)}
|
||||
</Button>)}
|
||||
</div>
|
||||
</Button>
|
||||
</>)}
|
||||
</div>
|
||||
|
||||
{!searchMode && !hasResult && (<FetchHistory history={history} onSelect={onHistorySelect} onRemove={onHistoryRemove}/>)}
|
||||
|
||||
|
||||
{searchMode && (<div className="space-y-4">
|
||||
|
||||
{!searchQuery && !searchResults && recentSearches.length > 0 && (<div className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">Recent Searches</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -294,8 +656,7 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist
|
||||
</div>)}
|
||||
|
||||
{!isSearching && hasAnyResults && (<>
|
||||
|
||||
<div className="flex gap-1 border-b">
|
||||
<div className="flex gap-1 border-b mb-4">
|
||||
{tabs.map((tab) => {
|
||||
const count = getTabCount(tab.key);
|
||||
if (count === 0)
|
||||
@@ -308,54 +669,106 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist
|
||||
})}
|
||||
</div>
|
||||
|
||||
|
||||
<div className="grid gap-2">
|
||||
|
||||
{activeTab === "tracks" && searchResults?.tracks.map((track) => (<button key={track.id} type="button" className="flex items-center gap-3 p-3 rounded-lg bg-card hover:bg-accent border cursor-pointer text-left transition-colors" onClick={() => handleResultClick(track.external_urls)}>
|
||||
{track.images ? (<img src={track.images} alt="" className="w-12 h-12 rounded object-cover shrink-0"/>) : (<div className="w-12 h-12 rounded bg-muted shrink-0"/>)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{track.name}</p>
|
||||
<p className="text-sm text-muted-foreground truncate">{track.artists}</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground shrink-0">
|
||||
{formatDuration(track.duration_ms || 0)}
|
||||
</span>
|
||||
</button>))}
|
||||
|
||||
|
||||
{activeTab === "albums" && searchResults?.albums.map((album) => (<button key={album.id} type="button" className="flex items-center gap-3 p-3 rounded-lg bg-card hover:bg-accent border cursor-pointer text-left transition-colors" onClick={() => handleResultClick(album.external_urls)}>
|
||||
{album.images ? (<img src={album.images} alt="" className="w-12 h-12 rounded object-cover shrink-0"/>) : (<div className="w-12 h-12 rounded bg-muted shrink-0"/>)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{album.name}</p>
|
||||
<p className="text-sm text-muted-foreground truncate">{album.artists}</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground shrink-0">
|
||||
{album.release_date || ""}
|
||||
</span>
|
||||
</button>))}
|
||||
|
||||
|
||||
{activeTab === "artists" && searchResults?.artists.map((artist) => (<button key={artist.id} type="button" className="flex items-center gap-3 p-3 rounded-lg bg-card hover:bg-accent border cursor-pointer text-left transition-colors" onClick={() => handleResultClick(artist.external_urls)}>
|
||||
{artist.images ? (<img src={artist.images} alt="" className="w-12 h-12 rounded-full object-cover shrink-0"/>) : (<div className="w-12 h-12 rounded-full bg-muted shrink-0"/>)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{artist.name}</p>
|
||||
<p className="text-sm text-muted-foreground">Artist</p>
|
||||
</div>
|
||||
</button>))}
|
||||
|
||||
|
||||
{activeTab === "playlists" && searchResults?.playlists.map((playlist) => (<button key={playlist.id} type="button" className="flex items-center gap-3 p-3 rounded-lg bg-card hover:bg-accent border cursor-pointer text-left transition-colors" onClick={() => handleResultClick(playlist.external_urls)}>
|
||||
{playlist.images ? (<img src={playlist.images} alt="" className="w-12 h-12 rounded object-cover shrink-0"/>) : (<div className="w-12 h-12 rounded bg-muted shrink-0"/>)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{playlist.name}</p>
|
||||
<p className="text-sm text-muted-foreground truncate">
|
||||
{playlist.owner || ""}
|
||||
</p>
|
||||
</div>
|
||||
</button>))}
|
||||
<div className="flex gap-2 mb-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground"/>
|
||||
<Input placeholder={`Search ${activeTab}...`} value={resultFilter} onChange={(e) => setResultFilter(e.target.value)} className="pl-10 pr-8"/>
|
||||
{resultFilter && (<button type="button" className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" onClick={() => setResultFilter("")}>
|
||||
<XCircle className="h-4 w-4"/>
|
||||
</button>)}
|
||||
</div>
|
||||
<Select value={sortOrders[activeTab]} onValueChange={(val) => setSortOrders(prev => ({ ...prev, [activeTab]: val }))}>
|
||||
<SelectTrigger className="w-[170px] bg-background gap-1.5">
|
||||
<ArrowUpDown className="h-4 w-4 text-muted-foreground"/>
|
||||
<SelectValue placeholder="Sort by"/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">Default</SelectItem>
|
||||
{activeTab === 'tracks' && (<>
|
||||
<SelectItem value="title-asc">Title (A-Z)</SelectItem>
|
||||
<SelectItem value="title-desc">Title (Z-A)</SelectItem>
|
||||
<SelectItem value="artist-asc">Artist (A-Z)</SelectItem>
|
||||
<SelectItem value="artist-desc">Artist (Z-A)</SelectItem>
|
||||
<SelectItem value="duration-desc">Duration (Longest)</SelectItem>
|
||||
<SelectItem value="duration-asc">Duration (Shortest)</SelectItem>
|
||||
</>)}
|
||||
{activeTab === 'albums' && (<>
|
||||
<SelectItem value="title-asc">Title (A-Z)</SelectItem>
|
||||
<SelectItem value="title-desc">Title (Z-A)</SelectItem>
|
||||
<SelectItem value="artist-asc">Artist (A-Z)</SelectItem>
|
||||
<SelectItem value="artist-desc">Artist (Z-A)</SelectItem>
|
||||
<SelectItem value="year-desc">Year (Newest)</SelectItem>
|
||||
<SelectItem value="year-asc">Year (Oldest)</SelectItem>
|
||||
</>)}
|
||||
{activeTab === 'artists' && (<>
|
||||
<SelectItem value="name-asc">Name (A-Z)</SelectItem>
|
||||
<SelectItem value="name-desc">Name (Z-A)</SelectItem>
|
||||
</>)}
|
||||
{activeTab === 'playlists' && (<>
|
||||
<SelectItem value="title-asc">Title (A-Z)</SelectItem>
|
||||
<SelectItem value="title-desc">Title (Z-A)</SelectItem>
|
||||
<SelectItem value="owner-asc">Owner (A-Z)</SelectItem>
|
||||
<SelectItem value="owner-desc">Owner (Z-A)</SelectItem>
|
||||
</>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
{activeTab === "tracks" &&
|
||||
sortedResults.tracks.map((track) => (<button key={track.id} type="button" className="flex items-center gap-3 p-3 rounded-lg bg-card hover:bg-accent border cursor-pointer text-left transition-colors" onClick={() => handleResultClick(track.external_urls)}>
|
||||
{track.images ? (<img src={track.images} alt="" className="w-12 h-12 rounded object-cover shrink-0"/>) : (<div className="w-12 h-12 rounded bg-muted shrink-0"/>)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<p className="font-medium truncate">{track.name}</p>
|
||||
{track.is_explicit && (<span className="flex items-center justify-center min-w-[16px] h-[16px] rounded bg-red-600 text-[10px] font-bold text-white leading-none shrink-0" title="Explicit">
|
||||
E
|
||||
</span>)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground truncate">
|
||||
{track.artists}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground shrink-0">
|
||||
{formatDuration(track.duration_ms || 0)}
|
||||
</span>
|
||||
</button>))}
|
||||
|
||||
{activeTab === "albums" &&
|
||||
sortedResults.albums.map((album) => (<button key={album.id} type="button" className="flex items-center gap-3 p-3 rounded-lg bg-card hover:bg-accent border cursor-pointer text-left transition-colors" onClick={() => handleResultClick(album.external_urls)}>
|
||||
{album.images ? (<img src={album.images} alt="" className="w-12 h-12 rounded object-cover shrink-0"/>) : (<div className="w-12 h-12 rounded bg-muted shrink-0"/>)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{album.name}</p>
|
||||
<p className="text-sm text-muted-foreground truncate">
|
||||
{album.artists}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground shrink-0">
|
||||
{album.release_date || ""}
|
||||
</span>
|
||||
</button>))}
|
||||
|
||||
{activeTab === "artists" &&
|
||||
sortedResults.artists.map((artist) => (<button key={artist.id} type="button" className="flex items-center gap-3 p-3 rounded-lg bg-card hover:bg-accent border cursor-pointer text-left transition-colors" onClick={() => handleResultClick(artist.external_urls)}>
|
||||
{artist.images ? (<img src={artist.images} alt="" className="w-12 h-12 rounded-full object-cover shrink-0"/>) : (<div className="w-12 h-12 rounded-full bg-muted shrink-0"/>)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{artist.name}</p>
|
||||
<p className="text-sm text-muted-foreground">Artist</p>
|
||||
</div>
|
||||
</button>))}
|
||||
|
||||
{activeTab === "playlists" &&
|
||||
sortedResults.playlists.map((playlist) => (<button key={playlist.id} type="button" className="flex items-center gap-3 p-3 rounded-lg bg-card hover:bg-accent border cursor-pointer text-left transition-colors" onClick={() => handleResultClick(playlist.external_urls)}>
|
||||
{playlist.images ? (<img src={playlist.images} alt="" className="w-12 h-12 rounded object-cover shrink-0"/>) : (<div className="w-12 h-12 rounded bg-muted shrink-0"/>)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{playlist.name}</p>
|
||||
<p className="text-sm text-muted-foreground truncate">
|
||||
{playlist.owner || ""}
|
||||
</p>
|
||||
</div>
|
||||
</button>))}
|
||||
</div>
|
||||
|
||||
|
||||
{hasMore[activeTab] && (<div className="flex justify-center pt-2">
|
||||
<Button variant="outline" onClick={handleLoadMore} disabled={isLoadingMore}>
|
||||
{isLoadingMore ? (<>
|
||||
@@ -369,5 +782,36 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist
|
||||
</div>)}
|
||||
</>)}
|
||||
</div>)}
|
||||
|
||||
<Dialog open={showInvalidUrlDialog} onOpenChange={setShowInvalidUrlDialog}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Invalid URL</DialogTitle>
|
||||
<DialogDescription>
|
||||
Only Spotify links are allowed in Fetch mode.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{invalidUrl && (<div className="p-3 bg-muted rounded-md border text-xs font-mono break-all opacity-70">
|
||||
{invalidUrl}
|
||||
</div>)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => {
|
||||
setShowInvalidUrlDialog(false);
|
||||
setInvalidUrl("");
|
||||
}}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={() => {
|
||||
onSearchModeChange(true);
|
||||
setShowInvalidUrlDialog(false);
|
||||
setInvalidUrl("");
|
||||
}}>
|
||||
Switch to Search
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>);
|
||||
}
|
||||
|
||||
@@ -4,41 +4,33 @@ import { Button } from "@/components/ui/button";
|
||||
import { InputWithContext } from "@/components/ui/input-with-context";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { FolderOpen, Save, RotateCcw, Info } from "lucide-react";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
|
||||
import { FolderOpen, Save, RotateCcw, Info, ArrowRight, MonitorCog, FolderCog, Router, FolderLock } from "lucide-react";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { getSettings, getSettingsWithDefaults, saveSettings, resetToDefaultSettings, applyThemeMode, applyFont, FONT_OPTIONS, FOLDER_PRESETS, FILENAME_PRESETS, TEMPLATE_VARIABLES, type Settings as SettingsType, type FontFamily, type FolderPreset, type FilenamePreset } from "@/lib/settings";
|
||||
import { getSettings, getSettingsWithDefaults, saveSettings, resetToDefaultSettings, applyThemeMode, applyFont, FONT_OPTIONS, FOLDER_PRESETS, FILENAME_PRESETS, TEMPLATE_VARIABLES, type Settings as SettingsType, type FontFamily, type FolderPreset, type FilenamePreset, } from "@/lib/settings";
|
||||
import { themes, applyTheme } from "@/lib/themes";
|
||||
import { SelectFolder } from "../../wailsjs/go/main/App";
|
||||
import { SelectFolder, OpenConfigFolder } from "../../wailsjs/go/main/App";
|
||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||
const TidalIcon = () => (<svg viewBox="0 0 24 24" className="inline-block w-[1.1em] h-[1.1em] mr-2 fill-muted-foreground">
|
||||
<path d="M4.022 4.5 0 8.516l3.997 3.99 3.997-3.984L4.022 4.5Zm7.956 0L7.994 8.522l4.003 3.984L16 8.484 11.978 4.5Zm8.007 0L24 8.528l-4.003 3.978L16 8.484 19.985 4.5Z"></path>
|
||||
<path d="m8.012 16.534 3.991 3.966L16 16.49l-4.003-3.984-3.985 4.028Z"></path>
|
||||
</svg>);
|
||||
const QobuzIcon = () => (<svg viewBox="0 0 24 24" className="inline-block w-[1.1em] h-[1.1em] mr-2 fill-muted-foreground">
|
||||
<path d="M21.744 9.815C19.836 1.261 8.393-1 3.55 6.64-.618 13.214 4 22 11.988 22c2.387 0 4.63-.83 6.394-2.304l2.252 2.252 1.224-1.224-2.252-2.253c1.983-2.407 2.823-5.586 2.138-8.656Zm-3.508 7.297L16.4 15.275c-.786-.787-2.017.432-1.224 1.225L17 18.326C10.29 23.656.5 16 5.16 7.667c3.502-6.264 13.172-4.348 14.707 2.574.529 2.385-.06 4.987-1.63 6.87Z"></path>
|
||||
<path d="M13.4 8.684a3.59 3.59 0 0 0-4.712 1.9 3.59 3.59 0 0 0 1.9 4.712 3.594 3.594 0 0 0 4.711-1.89 3.598 3.598 0 0 0-1.9-4.722Zm-.737 3.591a.727.727 0 0 1-.965.384.727.727 0 0 1-.384-.965.727.727 0 0 1 .965-.384.73.73 0 0 1 .384.965Z"></path>
|
||||
</svg>);
|
||||
const AmazonIcon = () => (<svg viewBox="0 0 24 24" className="inline-block w-[1.1em] h-[1.1em] mr-2 fill-muted-foreground">
|
||||
<path fillRule="evenodd" d="M15.62 11.13c-.15.1-.37.18-.64.18-.42 0-.82-.05-1.21-.18l-.22-.04c-.08 0-.1.04-.1.14v.25c0 .08.02.12.05.17.02.03.07.08.15.1.4.18.84.25 1.33.25.52 0 .91-.12 1.24-.37.32-.25.47-.57.47-.99 0-.3-.08-.52-.23-.72-.15-.17-.4-.34-.74-.47l-.7-.27c-.26-.1-.46-.2-.53-.3a.47.47 0 0 1-.15-.36c0-.38.27-.57.84-.57.32 0 .64.05.94.15l.2.04c.07 0 .12-.04.12-.14v-.25c0-.08-.03-.12-.05-.17-.03-.05-.08-.08-.15-.1-.37-.13-.74-.2-1.11-.2-.47 0-.87.12-1.16.35-.3.22-.45.54-.45.91 0 .57.32.99.97 1.24l.74.27c.24.1.4.17.5.27.09.1.12.2.12.35 0 .2-.08.37-.23.46Zm-3.88-3.55v3.28c-.42.28-.84.42-1.26.42-.27 0-.47-.07-.6-.22-.11-.15-.16-.37-.16-.7V7.59c0-.13-.05-.18-.18-.18h-.52c-.12 0-.17.05-.17.18v3.06c0 .42.1.77.32.99.22.22.55.35.97.35.56 0 1.13-.2 1.68-.6l.05.3c0 .07.02.1.07.12.02.03.07.03.15.03h.37c.12 0 .17-.05.17-.18V7.58c0-.13-.05-.18-.17-.18h-.52c-.15 0-.2.08-.2.18Zm-4.69 4.27h.52c.12 0 .17-.05.17-.17v-3.1c0-.41-.1-.73-.32-.95a1.25 1.25 0 0 0-.94-.35c-.57 0-1.16.2-1.73.62-.2-.42-.57-.62-1.11-.62-.55 0-1.1.2-1.64.57l-.04-.27c0-.08-.03-.1-.08-.13-.02-.02-.07-.02-.12-.02h-.4c-.12 0-.17.05-.17.17v4.1c0 .13.05.18.17.18h.52c.12 0 .17-.05.17-.18V8.37c.42-.25.84-.4 1.29-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.17.17h.52c.13 0 .18-.05.18-.17V8.37c.44-.27.86-.4 1.28-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.18.17Zm13.47 3.29a21.8 21.8 0 0 1-8.3 1.7c-3.96 0-7.8-1.08-10.88-2.89a.35.35 0 0 0-.15-.05c-.17 0-.27.2-.1.37a16.11 16.11 0 0 0 10.87 4.16c3.02 0 6.5-.94 8.9-2.72.42-.3.08-.74-.34-.57Zm-.08-6.74c.22-.26.57-.38 1.06-.38.25 0 .5.03.72.1l.15.02c.07 0 .12-.04.12-.17v-.25c0-.07-.02-.14-.05-.17a.54.54 0 0 0-.12-.1c-.32-.07-.64-.15-.94-.15-.7 0-1.21.2-1.6.62-.38.4-.57 1-.57 1.73 0 .74.17 1.31.54 1.7.37.4.89.6 1.58.6.37 0 .72-.05.99-.17.07-.03.12-.05.15-.1.02-.03.02-.1.02-.17v-.25c0-.13-.05-.17-.12-.17-.03 0-.07 0-.12.02-.28.07-.55.12-.8.12-.46 0-.81-.12-1.03-.37-.23-.24-.32-.64-.32-1.16v-.12c.02-.55.12-.94.34-1.19Z" clipRule="evenodd"></path>
|
||||
<path fillRule="evenodd" d="M21.55 17.46c1.29-1.09 1.64-3.33 1.36-3.68-.12-.15-.71-.3-1.45-.3-.8 0-1.73.18-2.45.67-.22.15-.17.35.05.32.76-.1 2.5-.3 2.82.1.3.4-.35 2.03-.65 2.74-.07.23.1.3.32.15ZM18.12 7.4h-.52c-.12 0-.17.05-.17.18v4.1c0 .12.05.17.17.17h.52c.12 0 .17-.05.17-.17v-4.1c0-.1-.05-.18-.17-.18Zm.15-1.68a.58.58 0 0 0-.42-.15c-.18 0-.3.05-.4.15a.5.5 0 0 0-.15.37c0 .15.05.3.15.37.1.1.22.15.4.15.17 0 .3-.05.4-.15a.5.5 0 0 0 .14-.37c0-.15-.02-.3-.12-.37Z" clipRule="evenodd"></path>
|
||||
</svg>);
|
||||
import { ApiStatusTab } from "./ApiStatusTab";
|
||||
import { AmazonIcon, QobuzIcon, TidalIcon } from "./PlatformIcons";
|
||||
import songlinkIcon from "@/assets/icons/songlink.ico";
|
||||
import songstatsIcon from "@/assets/icons/songstats.png";
|
||||
interface SettingsPageProps {
|
||||
onUnsavedChangesChange?: (hasUnsavedChanges: boolean) => void;
|
||||
onResetRequest?: (resetFn: () => void) => void;
|
||||
}
|
||||
export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: SettingsPageProps) {
|
||||
export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: SettingsPageProps) {
|
||||
const [savedSettings, setSavedSettings] = useState<SettingsType>(getSettings());
|
||||
const [tempSettings, setTempSettings] = useState<SettingsType>(savedSettings);
|
||||
const [isDark, setIsDark] = useState(document.documentElement.classList.contains('dark'));
|
||||
const [isDark, setIsDark] = useState(document.documentElement.classList.contains("dark"));
|
||||
const [showResetConfirm, setShowResetConfirm] = useState(false);
|
||||
const hasUnsavedChanges = JSON.stringify(savedSettings) !== JSON.stringify(tempSettings);
|
||||
const resetToSaved = useCallback(() => {
|
||||
const freshSavedSettings = getSettings();
|
||||
flushSync(() => {
|
||||
setTempSettings(freshSavedSettings);
|
||||
setIsDark(document.documentElement.classList.contains('dark'));
|
||||
setIsDark(document.documentElement.classList.contains("dark"));
|
||||
});
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
@@ -67,7 +59,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
|
||||
applyTheme(tempSettings.theme);
|
||||
applyFont(tempSettings.fontFamily);
|
||||
setTimeout(() => {
|
||||
setIsDark(document.documentElement.classList.contains('dark'));
|
||||
setIsDark(document.documentElement.classList.contains("dark"));
|
||||
}, 0);
|
||||
}, [tempSettings.themeMode, tempSettings.theme, tempSettings.fontFamily]);
|
||||
useEffect(() => {
|
||||
@@ -76,13 +68,13 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
|
||||
const settingsWithDefaults = await getSettingsWithDefaults();
|
||||
setSavedSettings(settingsWithDefaults);
|
||||
setTempSettings(settingsWithDefaults);
|
||||
saveSettings(settingsWithDefaults);
|
||||
await saveSettings(settingsWithDefaults);
|
||||
}
|
||||
};
|
||||
loadDefaults();
|
||||
}, []);
|
||||
const handleSave = () => {
|
||||
saveSettings(tempSettings);
|
||||
const handleSave = async () => {
|
||||
await saveSettings(tempSettings);
|
||||
setSavedSettings(tempSettings);
|
||||
toast.success("Settings saved");
|
||||
onUnsavedChangesChange?.(false);
|
||||
@@ -109,250 +101,605 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
|
||||
toast.error(`Error selecting folder: ${error}`);
|
||||
}
|
||||
};
|
||||
return (<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold">Settings</h1>
|
||||
const handleTidalQualityChange = async (value: "LOSSLESS" | "HI_RES_LOSSLESS") => {
|
||||
setTempSettings((prev) => ({ ...prev, tidalQuality: value }));
|
||||
};
|
||||
const handleQobuzQualityChange = (value: "6" | "7" | "27") => {
|
||||
setTempSettings((prev) => ({ ...prev, qobuzQuality: value }));
|
||||
};
|
||||
const handleAutoQualityChange = async (value: "16" | "24") => {
|
||||
setTempSettings((prev) => ({ ...prev, autoQuality: value }));
|
||||
};
|
||||
const [activeTab, setActiveTab] = useState<"general" | "files" | "api">("general");
|
||||
return (<div className="space-y-4 h-full flex flex-col">
|
||||
<div className="flex items-center justify-between shrink-0">
|
||||
<h1 className="text-2xl font-bold">Settings</h1>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={async () => {
|
||||
try {
|
||||
await OpenConfigFolder();
|
||||
}
|
||||
catch (e) {
|
||||
toast.error(`Failed to open config folder: ${e}`);
|
||||
}
|
||||
}} className="gap-1.5">
|
||||
<FolderLock className="h-4 w-4"/>
|
||||
Open Config Folder
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setShowResetConfirm(true)} className="gap-1.5">
|
||||
<RotateCcw className="h-4 w-4"/>
|
||||
Reset to Default
|
||||
</Button>
|
||||
<Button onClick={handleSave} className="gap-1.5">
|
||||
<Save className="h-4 w-4"/>
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
|
||||
<div className="space-y-4">
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="download-path">Download Path</Label>
|
||||
<div className="flex gap-2">
|
||||
<InputWithContext id="download-path" value={tempSettings.downloadPath} onChange={(e) => setTempSettings((prev) => ({ ...prev, downloadPath: e.target.value }))} placeholder="C:\Users\YourUsername\Music"/>
|
||||
<Button type="button" onClick={handleBrowseFolder} className="gap-1.5">
|
||||
<FolderOpen className="h-4 w-4"/>
|
||||
Browse
|
||||
</Button>
|
||||
<div className="flex gap-2 border-b shrink-0">
|
||||
<Button variant={activeTab === "general" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("general")} className="rounded-b-none gap-2">
|
||||
<MonitorCog className="h-4 w-4"/>
|
||||
General
|
||||
</Button>
|
||||
<Button variant={activeTab === "files" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("files")} className="rounded-b-none gap-2">
|
||||
<FolderCog className="h-4 w-4"/>
|
||||
File Management
|
||||
</Button>
|
||||
<Button variant={activeTab === "api" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("api")} className="rounded-b-none gap-2">
|
||||
<Router className="h-4 w-4"/>
|
||||
Status
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto pt-4">
|
||||
{activeTab === "general" && (<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="download-path">Download Path</Label>
|
||||
<div className="flex gap-2">
|
||||
<InputWithContext id="download-path" value={tempSettings.downloadPath} onChange={(e) => setTempSettings((prev) => ({
|
||||
...prev,
|
||||
downloadPath: e.target.value,
|
||||
}))} placeholder="C:\Users\YourUsername\Music"/>
|
||||
<Button type="button" onClick={handleBrowseFolder} className="gap-1.5">
|
||||
<FolderOpen className="h-4 w-4"/>
|
||||
Browse
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="theme-mode">Mode</Label>
|
||||
<Select value={tempSettings.themeMode} onValueChange={(value: "auto" | "light" | "dark") => setTempSettings((prev) => ({ ...prev, themeMode: value }))}>
|
||||
<SelectTrigger id="theme-mode">
|
||||
<SelectValue placeholder="Select theme mode"/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">Auto</SelectItem>
|
||||
<SelectItem value="light">Light</SelectItem>
|
||||
<SelectItem value="dark">Dark</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="theme">Accent</Label>
|
||||
<Select value={tempSettings.theme} onValueChange={(value) => setTempSettings((prev) => ({ ...prev, theme: value }))}>
|
||||
<SelectTrigger id="theme">
|
||||
<SelectValue placeholder="Select a theme"/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{themes.map((theme) => (<SelectItem key={theme.name} value={theme.name}>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded-full border border-border" style={{
|
||||
backgroundColor: isDark
|
||||
? theme.cssVars.dark.primary
|
||||
: theme.cssVars.light.primary,
|
||||
}}/>
|
||||
{theme.label}
|
||||
</span>
|
||||
</SelectItem>))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="font">Font</Label>
|
||||
<Select value={tempSettings.fontFamily} onValueChange={(value: FontFamily) => setTempSettings((prev) => ({ ...prev, fontFamily: value }))}>
|
||||
<SelectTrigger id="font">
|
||||
<SelectValue placeholder="Select a font"/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FONT_OPTIONS.map((font) => (<SelectItem key={font.value} value={font.value}>
|
||||
<span style={{ fontFamily: font.fontFamily }}>
|
||||
{font.label}
|
||||
</span>
|
||||
</SelectItem>))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 pt-2">
|
||||
<Switch id="sfx-enabled" checked={tempSettings.sfxEnabled} onCheckedChange={(checked) => setTempSettings((prev) => ({
|
||||
...prev,
|
||||
sfxEnabled: checked,
|
||||
}))}/>
|
||||
<Label htmlFor="sfx-enabled" className="cursor-pointer text-sm font-normal">
|
||||
Sound Effects
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="theme-mode">Mode</Label>
|
||||
<Select value={tempSettings.themeMode} onValueChange={(value: "auto" | "light" | "dark") => setTempSettings((prev) => ({ ...prev, themeMode: value }))}>
|
||||
<SelectTrigger id="theme-mode">
|
||||
<SelectValue placeholder="Select theme mode"/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">Auto</SelectItem>
|
||||
<SelectItem value="light">Light</SelectItem>
|
||||
<SelectItem value="dark">Dark</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="link-resolver">Link Resolver</Label>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<Select value={tempSettings.linkResolver} onValueChange={(value: "songstats" | "songlink") => setTempSettings((prev) => ({
|
||||
...prev,
|
||||
linkResolver: value,
|
||||
}))}>
|
||||
<SelectTrigger id="link-resolver" className="h-9 w-fit min-w-[140px]">
|
||||
<SelectValue placeholder="Select a link resolver"/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="songlink">
|
||||
<span className="flex items-center gap-2">
|
||||
<img src={songlinkIcon} alt="Songlink" className="h-4 w-4 shrink-0 rounded-[3px] object-contain" loading="lazy"/>
|
||||
Songlink
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="songstats">
|
||||
<span className="flex items-center gap-2">
|
||||
<img src={songstatsIcon} alt="Songstats" className="h-4 w-4 shrink-0 rounded-[3px] object-contain" loading="lazy"/>
|
||||
Songstats
|
||||
</span>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="theme">Accent</Label>
|
||||
<Select value={tempSettings.theme} onValueChange={(value) => setTempSettings((prev) => ({ ...prev, theme: value }))}>
|
||||
<SelectTrigger id="theme">
|
||||
<SelectValue placeholder="Select a theme"/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{themes.map((theme) => (<SelectItem key={theme.name} value={theme.name}>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded-full border border-border" style={{
|
||||
backgroundColor: isDark ? theme.cssVars.dark.primary : theme.cssVars.light.primary
|
||||
}}/>
|
||||
{theme.label}
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch id="allow-link-resolver-fallback" checked={tempSettings.allowResolverFallback} onCheckedChange={(checked) => setTempSettings((prev) => ({
|
||||
...prev,
|
||||
allowResolverFallback: checked,
|
||||
}))}/>
|
||||
<Label htmlFor="allow-link-resolver-fallback" className="text-sm font-normal cursor-pointer">
|
||||
Allow Fallback
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="downloader">Source</Label>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Select value={tempSettings.downloader} onValueChange={(value: any) => setTempSettings((prev) => ({
|
||||
...prev,
|
||||
downloader: value,
|
||||
}))}>
|
||||
<SelectTrigger id="downloader" className="h-9 w-fit">
|
||||
<SelectValue placeholder="Select a source"/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">Auto</SelectItem>
|
||||
<SelectItem value="tidal">
|
||||
<span className="flex items-center gap-2">
|
||||
<TidalIcon />
|
||||
Tidal
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="qobuz">
|
||||
<span className="flex items-center gap-2">
|
||||
<QobuzIcon />
|
||||
Qobuz
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="amazon">
|
||||
<span className="flex items-center gap-2">
|
||||
<AmazonIcon />
|
||||
Amazon Music
|
||||
</span>
|
||||
</SelectItem>
|
||||
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{tempSettings.downloader === "auto" && (<>
|
||||
<Select value={tempSettings.autoOrder || "tidal-qobuz-amazon"} onValueChange={(value: any) => setTempSettings((prev) => ({
|
||||
...prev,
|
||||
autoOrder: value,
|
||||
}))}>
|
||||
<SelectTrigger className="h-9 w-fit min-w-[140px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
||||
<SelectItem value="tidal-qobuz-amazon">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<TidalIcon className="fill-current"/>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||
<QobuzIcon className="fill-current"/>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||
<AmazonIcon className="fill-current"/>
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="tidal-amazon-qobuz">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<TidalIcon className="fill-current"/>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||
<AmazonIcon className="fill-current"/>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||
<QobuzIcon className="fill-current"/>
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="qobuz-tidal-amazon">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<QobuzIcon className="fill-current"/>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||
<TidalIcon className="fill-current"/>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||
<AmazonIcon className="fill-current"/>
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="qobuz-amazon-tidal">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<QobuzIcon className="fill-current"/>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||
<AmazonIcon className="fill-current"/>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||
<TidalIcon className="fill-current"/>
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="amazon-tidal-qobuz">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<AmazonIcon className="fill-current"/>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||
<TidalIcon className="fill-current"/>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||
<QobuzIcon className="fill-current"/>
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="amazon-qobuz-tidal">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<AmazonIcon className="fill-current"/>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||
<QobuzIcon className="fill-current"/>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||
<TidalIcon className="fill-current"/>
|
||||
</span>
|
||||
</SelectItem>
|
||||
|
||||
|
||||
<SelectItem value="tidal-qobuz">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<TidalIcon className="fill-current"/>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||
<QobuzIcon className="fill-current"/>
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="tidal-amazon">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<TidalIcon className="fill-current"/>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||
<AmazonIcon className="fill-current"/>
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="qobuz-tidal">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<QobuzIcon className="fill-current"/>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||
<TidalIcon className="fill-current"/>
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="qobuz-amazon">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<QobuzIcon className="fill-current"/>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||
<AmazonIcon className="fill-current"/>
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="amazon-tidal">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<AmazonIcon className="fill-current"/>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||
<TidalIcon className="fill-current"/>
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="amazon-qobuz">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<AmazonIcon className="fill-current"/>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||
<QobuzIcon className="fill-current"/>
|
||||
</span>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={tempSettings.autoQuality || "16"} onValueChange={handleAutoQualityChange}>
|
||||
<SelectTrigger className="h-9 w-fit">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="16">16-bit/44.1kHz</SelectItem>
|
||||
<SelectItem value="24">24-bit/48kHz</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</>)}
|
||||
|
||||
{tempSettings.downloader === "tidal" && (<Select value={tempSettings.tidalQuality} onValueChange={handleTidalQualityChange}>
|
||||
<SelectTrigger className="h-9 w-fit">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="LOSSLESS">16-bit/44.1kHz</SelectItem>
|
||||
<SelectItem value="HI_RES_LOSSLESS">
|
||||
24-bit/48kHz
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>)}
|
||||
|
||||
{tempSettings.downloader === "qobuz" && (<Select value={tempSettings.qobuzQuality} onValueChange={handleQobuzQualityChange}>
|
||||
<SelectTrigger className="h-9 w-fit">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="6">16-bit/44.1kHz</SelectItem>
|
||||
<SelectItem value="27">24-bit/48kHz - 192kHz</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>)}
|
||||
|
||||
{tempSettings.downloader === "amazon" && (<div className="h-9 px-3 flex items-center text-sm font-medium border border-input rounded-md bg-muted/30 text-muted-foreground whitespace-nowrap cursor-default">
|
||||
16-bit - 24-bit/44.1kHz - 192kHz
|
||||
</div>)}
|
||||
|
||||
</div>
|
||||
|
||||
{((tempSettings.downloader === "tidal" &&
|
||||
tempSettings.tidalQuality === "HI_RES_LOSSLESS") ||
|
||||
(tempSettings.downloader === "qobuz" &&
|
||||
tempSettings.qobuzQuality === "27") ||
|
||||
(tempSettings.downloader === "auto" &&
|
||||
tempSettings.autoQuality === "24")) && (<div className="flex items-center gap-3 pt-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch id="allow-fallback" checked={tempSettings.allowFallback} onCheckedChange={(checked) => setTempSettings((prev) => ({
|
||||
...prev,
|
||||
allowFallback: checked,
|
||||
}))}/>
|
||||
<Label htmlFor="allow-fallback" className="text-sm font-normal cursor-pointer">
|
||||
Allow Quality Fallback (16-bit)
|
||||
</Label>
|
||||
</div>
|
||||
</div>)}
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-6"/>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch id="embed-lyrics" checked={tempSettings.embedLyrics} onCheckedChange={(checked) => setTempSettings((prev) => ({
|
||||
...prev,
|
||||
embedLyrics: checked,
|
||||
}))}/>
|
||||
<Label htmlFor="embed-lyrics" className="cursor-pointer text-sm font-normal">
|
||||
Embed Lyrics
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch id="embed-max-quality-cover" checked={tempSettings.embedMaxQualityCover} onCheckedChange={(checked) => setTempSettings((prev) => ({
|
||||
...prev,
|
||||
embedMaxQualityCover: checked,
|
||||
}))}/>
|
||||
<Label htmlFor="embed-max-quality-cover" className="cursor-pointer text-sm font-normal">
|
||||
Embed Max Quality Cover
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch id="embed-genre" checked={tempSettings.embedGenre} onCheckedChange={(checked) => setTempSettings((prev) => ({
|
||||
...prev,
|
||||
embedGenre: checked,
|
||||
}))}/>
|
||||
<Label htmlFor="embed-genre" className="cursor-pointer text-sm font-normal">
|
||||
Embed Genre
|
||||
</Label>
|
||||
</div>
|
||||
{tempSettings.embedGenre && (<div className="flex items-center gap-3">
|
||||
<Switch id="use-single-genre" checked={tempSettings.useSingleGenre} onCheckedChange={(checked) => setTempSettings((prev) => ({
|
||||
...prev,
|
||||
useSingleGenre: checked,
|
||||
}))}/>
|
||||
<Label htmlFor="use-single-genre" className="text-sm cursor-pointer font-normal">
|
||||
Use Single Genre
|
||||
</Label>
|
||||
</div>)}
|
||||
</div>
|
||||
</div>
|
||||
</div>)}
|
||||
|
||||
{activeTab === "files" && (<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-sm">Folder Structure</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help"/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p className="text-xs whitespace-nowrap">
|
||||
Variables:{" "}
|
||||
{TEMPLATE_VARIABLES.map((v) => v.key).join(", ")}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Select value={tempSettings.folderPreset} onValueChange={(value: FolderPreset) => {
|
||||
const preset = FOLDER_PRESETS[value];
|
||||
setTempSettings((prev) => ({
|
||||
...prev,
|
||||
folderPreset: value,
|
||||
folderTemplate: value === "custom"
|
||||
? prev.folderTemplate || preset.template
|
||||
: preset.template,
|
||||
}));
|
||||
}}>
|
||||
<SelectTrigger className="h-9 w-fit">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(FOLDER_PRESETS).map(([key, { label }]) => (<SelectItem key={key} value={key}>
|
||||
{label}
|
||||
</SelectItem>))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{tempSettings.folderPreset === "custom" && (<InputWithContext value={tempSettings.folderTemplate} onChange={(e) => setTempSettings((prev) => ({
|
||||
...prev,
|
||||
folderTemplate: e.target.value,
|
||||
}))} placeholder="{artist}/{album}" className="h-9 text-sm flex-1"/>)}
|
||||
</div>
|
||||
{tempSettings.folderTemplate && (<p className="text-xs text-muted-foreground">
|
||||
Preview:{" "}
|
||||
<span className="font-mono">
|
||||
{tempSettings.folderTemplate
|
||||
.replace(/\{artist\}/g, tempSettings.separator === "comma" ? "Kendrick Lamar, SZA" : "Kendrick Lamar; SZA")
|
||||
.replace(/\{album\}/g, "Black Panther")
|
||||
.replace(/\{album_artist\}/g, "Kendrick Lamar")
|
||||
.replace(/\{title\}/g, "All The Stars")
|
||||
.replace(/\{track\}/g, "01")
|
||||
.replace(/\{disc\}/g, "1")
|
||||
.replace(/\{year\}/g, "2018")
|
||||
.replace(/\{date\}/g, "2018-02-09")}
|
||||
/
|
||||
</span>
|
||||
</SelectItem>))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</p>)}
|
||||
</div>
|
||||
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="font">Font</Label>
|
||||
<Select value={tempSettings.fontFamily} onValueChange={(value: FontFamily) => setTempSettings((prev) => ({ ...prev, fontFamily: value }))}>
|
||||
<SelectTrigger id="font">
|
||||
<SelectValue placeholder="Select a font"/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FONT_OPTIONS.map((font) => (<SelectItem key={font.value} value={font.value}>
|
||||
<span style={{ fontFamily: font.fontFamily }}>{font.label}</span>
|
||||
</SelectItem>))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch id="create-playlist-folder" checked={tempSettings.createPlaylistFolder} onCheckedChange={(checked) => setTempSettings((prev) => ({
|
||||
...prev,
|
||||
createPlaylistFolder: checked,
|
||||
}))}/>
|
||||
<Label htmlFor="create-playlist-folder" className="text-sm cursor-pointer font-normal">
|
||||
Playlist Folder
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Label htmlFor="sfx-enabled" className="cursor-pointer text-sm">Sound Effects</Label>
|
||||
<Switch id="sfx-enabled" checked={tempSettings.sfxEnabled} onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, sfxEnabled: checked }))}/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch id="create-m3u8-file" checked={tempSettings.createM3u8File} onCheckedChange={(checked) => setTempSettings((prev) => ({
|
||||
...prev,
|
||||
createM3u8File: checked,
|
||||
}))}/>
|
||||
<Label htmlFor="create-m3u8-file" className="text-sm cursor-pointer font-normal">
|
||||
Create M3U8 Playlist File
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch id="use-first-artist-only" checked={tempSettings.useFirstArtistOnly} onCheckedChange={(checked) => setTempSettings((prev) => ({
|
||||
...prev,
|
||||
useFirstArtistOnly: checked,
|
||||
}))}/>
|
||||
<Label htmlFor="use-first-artist-only" className="text-sm cursor-pointer font-normal">
|
||||
Use First Artist Only
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-sm">Filename Format</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help"/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p className="text-xs whitespace-nowrap">
|
||||
Variables:{" "}
|
||||
{TEMPLATE_VARIABLES.map((v) => v.key).join(", ")}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Select value={tempSettings.filenamePreset} onValueChange={(value: FilenamePreset) => {
|
||||
const preset = FILENAME_PRESETS[value];
|
||||
setTempSettings((prev) => ({
|
||||
...prev,
|
||||
filenamePreset: value,
|
||||
filenameTemplate: value === "custom"
|
||||
? prev.filenameTemplate || preset.template
|
||||
: preset.template,
|
||||
}));
|
||||
}}>
|
||||
<SelectTrigger className="h-9 w-fit">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(FILENAME_PRESETS).map(([key, { label }]) => (<SelectItem key={key} value={key}>
|
||||
{label}
|
||||
</SelectItem>))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{tempSettings.filenamePreset === "custom" && (<InputWithContext value={tempSettings.filenameTemplate} onChange={(e) => setTempSettings((prev) => ({
|
||||
...prev,
|
||||
filenameTemplate: e.target.value,
|
||||
}))} placeholder="{track}. {title}" className="h-9 text-sm flex-1"/>)}
|
||||
</div>
|
||||
<div className="space-y-2 pt-2">
|
||||
<Label className="text-sm">Separator</Label>
|
||||
<div className="flex gap-2">
|
||||
<Select value={tempSettings.separator} onValueChange={(value: "comma" | "semicolon") => setTempSettings((prev) => ({
|
||||
...prev,
|
||||
separator: value,
|
||||
}))}>
|
||||
<SelectTrigger className="h-9 w-fit">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="comma">Comma (,)</SelectItem>
|
||||
<SelectItem value="semicolon">Semicolon (;)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tempSettings.filenameTemplate && (<p className="text-xs text-muted-foreground">
|
||||
Preview:{" "}
|
||||
<span className="font-mono">
|
||||
{tempSettings.filenameTemplate
|
||||
.replace(/\{artist\}/g, tempSettings.separator === "comma" ? "Kendrick Lamar, SZA" : "Kendrick Lamar; SZA")
|
||||
.replace(/\{album_artist\}/g, "Kendrick Lamar")
|
||||
.replace(/\{album\}/g, "Black Panther")
|
||||
.replace(/\{title\}/g, "All The Stars")
|
||||
.replace(/\{track\}/g, "01")
|
||||
.replace(/\{disc\}/g, "1")
|
||||
.replace(/\{year\}/g, "2018")
|
||||
.replace(/\{date\}/g, "2018-02-09")}
|
||||
.flac
|
||||
</span>
|
||||
</p>)}
|
||||
|
||||
</div>
|
||||
</div>)}
|
||||
|
||||
<div className="space-y-4">
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="downloader" className="text-sm">Source</Label>
|
||||
<div className="flex gap-2">
|
||||
<Select value={tempSettings.downloader} onValueChange={(value: "auto" | "tidal" | "qobuz" | "amazon") => setTempSettings((prev) => ({ ...prev, downloader: value }))}>
|
||||
<SelectTrigger id="downloader" className="h-9 w-fit">
|
||||
<SelectValue placeholder="Select a source"/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">Auto</SelectItem>
|
||||
<SelectItem value="tidal">
|
||||
<span className="flex items-center"><TidalIcon />Tidal</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="qobuz">
|
||||
<span className="flex items-center"><QobuzIcon />Qobuz</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="amazon">
|
||||
<span className="flex items-center"><AmazonIcon />Amazon Music</span>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{tempSettings.downloader === "tidal" && (<Select value={tempSettings.tidalQuality} onValueChange={(value: "LOSSLESS" | "HI_RES_LOSSLESS") => setTempSettings((prev) => ({ ...prev, tidalQuality: value }))}>
|
||||
<SelectTrigger className="h-9 w-fit">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="LOSSLESS">Lossless (16-bit/CD Quality)</SelectItem>
|
||||
<SelectItem value="HI_RES_LOSSLESS">Hi-Res Lossless (24-bit/48kHz+)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>)}
|
||||
|
||||
{tempSettings.downloader === "qobuz" && (<Select value={tempSettings.qobuzQuality} onValueChange={(value: "6" | "7" | "27") => setTempSettings((prev) => ({ ...prev, qobuzQuality: value }))}>
|
||||
<SelectTrigger className="h-9 w-fit">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="6">FLAC 16-bit (CD Quality)</SelectItem>
|
||||
<SelectItem value="7">FLAC 24-bit</SelectItem>
|
||||
<SelectItem value="27">Hi-Res (24-bit/96kHz+)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>)}
|
||||
|
||||
{tempSettings.downloader === "amazon" && (<Select value={tempSettings.amazonQuality} onValueChange={(value: "HI_RES") => setTempSettings((prev) => ({ ...prev, amazonQuality: value }))}>
|
||||
<SelectTrigger className="h-9 w-fit">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="HI_RES">Hi-Res (24-bit/96kHz+)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Label htmlFor="embed-lyrics" className="cursor-pointer text-sm">Embed Lyrics</Label>
|
||||
<Switch id="embed-lyrics" checked={tempSettings.embedLyrics} onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, embedLyrics: checked }))}/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Label htmlFor="embed-max-quality-cover" className="cursor-pointer text-sm">Embed Max Quality Cover</Label>
|
||||
<Switch id="embed-max-quality-cover" checked={tempSettings.embedMaxQualityCover} onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, embedMaxQualityCover: checked }))}/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t"/>
|
||||
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-sm">Folder Structure</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help"/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p className="text-xs whitespace-nowrap">Variables: {TEMPLATE_VARIABLES.map(v => v.key).join(", ")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Select value={tempSettings.folderPreset} onValueChange={(value: FolderPreset) => {
|
||||
const preset = FOLDER_PRESETS[value];
|
||||
setTempSettings(prev => ({
|
||||
...prev,
|
||||
folderPreset: value,
|
||||
folderTemplate: value === "custom" ? (prev.folderTemplate || preset.template) : preset.template
|
||||
}));
|
||||
}}>
|
||||
<SelectTrigger className="h-9 w-fit">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(FOLDER_PRESETS).map(([key, { label }]) => (<SelectItem key={key} value={key}>{label}</SelectItem>))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{tempSettings.folderPreset === "custom" && (<InputWithContext value={tempSettings.folderTemplate} onChange={(e) => setTempSettings(prev => ({ ...prev, folderTemplate: e.target.value }))} placeholder="{artist}/{album}" className="h-9 text-sm flex-1"/>)}
|
||||
</div>
|
||||
{tempSettings.folderTemplate && (<p className="text-xs text-muted-foreground">
|
||||
Preview: <span className="font-mono">{tempSettings.folderTemplate.replace(/\{artist\}/g, "Kendrick Lamar, SZA").replace(/\{album\}/g, "Black Panther").replace(/\{album_artist\}/g, "Kendrick Lamar").replace(/\{year\}/g, "2018")}/</span>
|
||||
</p>)}
|
||||
</div>
|
||||
|
||||
<div className="border-t"/>
|
||||
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-sm">Filename Format</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help"/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p className="text-xs whitespace-nowrap">Variables: {TEMPLATE_VARIABLES.map(v => v.key).join(", ")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Select value={tempSettings.filenamePreset} onValueChange={(value: FilenamePreset) => {
|
||||
const preset = FILENAME_PRESETS[value];
|
||||
setTempSettings(prev => ({
|
||||
...prev,
|
||||
filenamePreset: value,
|
||||
filenameTemplate: value === "custom" ? (prev.filenameTemplate || preset.template) : preset.template
|
||||
}));
|
||||
}}>
|
||||
<SelectTrigger className="h-9 w-fit">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(FILENAME_PRESETS).map(([key, { label }]) => (<SelectItem key={key} value={key}>{label}</SelectItem>))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{tempSettings.filenamePreset === "custom" && (<InputWithContext value={tempSettings.filenameTemplate} onChange={(e) => setTempSettings(prev => ({ ...prev, filenameTemplate: e.target.value }))} placeholder="{track}. {title}" className="h-9 text-sm flex-1"/>)}
|
||||
</div>
|
||||
{tempSettings.filenameTemplate && (<p className="text-xs text-muted-foreground">
|
||||
Preview: <span className="font-mono">{tempSettings.filenameTemplate.replace(/\{artist\}/g, "Kendrick Lamar, SZA").replace(/\{album_artist\}/g, "Kendrick Lamar").replace(/\{title\}/g, "All The Stars").replace(/\{track\}/g, "01").replace(/\{disc\}/g, "1").replace(/\{year\}/g, "2018")}.flac</span>
|
||||
</p>)}
|
||||
</div>
|
||||
</div>
|
||||
{activeTab === "api" && (<ApiStatusTab />)}
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex gap-2 justify-between pt-4 border-t">
|
||||
<Button variant="outline" onClick={() => setShowResetConfirm(true)} className="gap-1.5">
|
||||
<RotateCcw className="h-4 w-4"/>
|
||||
Reset to Default
|
||||
</Button>
|
||||
<Button onClick={handleSave} className="gap-1.5">
|
||||
<Save className="h-4 w-4"/>
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
<Dialog open={showResetConfirm} onOpenChange={setShowResetConfirm}>
|
||||
<DialogContent className="max-w-md [&>button]:hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Reset to Default?</DialogTitle>
|
||||
<DialogDescription>
|
||||
This will reset all settings to their default values. Your custom configurations will be lost.
|
||||
This will reset all settings to their default values. Your custom
|
||||
configurations will be lost.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowResetConfirm(false)}>Cancel</Button>
|
||||
<Button variant="outline" onClick={() => setShowResetConfirm(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleReset}>Reset</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,128 +1,200 @@
|
||||
import { useRef, useState, type RefObject } from "react";
|
||||
import { HomeIcon } from "@/components/ui/home";
|
||||
import { HistoryIcon } from "@/components/ui/history-icon";
|
||||
import { SettingsIcon } from "@/components/ui/settings";
|
||||
import { ActivityIcon } from "@/components/ui/activity";
|
||||
import { ActivityIcon, type ActivityIconHandle } from "@/components/ui/activity";
|
||||
import { TerminalIcon } from "@/components/ui/terminal";
|
||||
import { FileMusicIcon } from "@/components/ui/file-music";
|
||||
import { FilePenIcon } from "@/components/ui/file-pen";
|
||||
import { GithubIcon } from "@/components/ui/github";
|
||||
import { BlocksIcon } from "@/components/ui/blocks";
|
||||
import { FileMusicIcon, type FileMusicIconHandle } from "@/components/ui/file-music";
|
||||
import { FilePenIcon, type FilePenIconHandle } from "@/components/ui/file-pen";
|
||||
import { CoffeeIcon } from "@/components/ui/coffee";
|
||||
import { BadgeAlertIcon } from "@/components/ui/badge-alert";
|
||||
import { GithubIcon } from "@/components/ui/github";
|
||||
import { BlocksIcon } from "@/components/ui/blocks-icon";
|
||||
import { AudioLinesIcon, type AudioLinesIconHandle } from "@/components/ui/audio-lines";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { openExternal } from "@/lib/utils";
|
||||
export type PageType = "main" | "settings" | "debug" | "audio-analysis" | "audio-converter" | "file-manager";
|
||||
export type PageType = "main" | "settings" | "debug" | "audio-analysis" | "audio-converter" | "audio-resampler" | "file-manager" | "about" | "history";
|
||||
interface SidebarProps {
|
||||
currentPage: PageType;
|
||||
onPageChange: (page: PageType) => void;
|
||||
}
|
||||
export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
||||
return (<div className="fixed left-0 top-0 h-full w-14 bg-card border-r border-border flex flex-col items-center py-14 z-30">
|
||||
<div className="flex flex-col gap-2 flex-1">
|
||||
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant={currentPage === "main" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "main" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("main")}>
|
||||
<HomeIcon size={20}/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Home</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant={currentPage === "settings" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "settings" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("settings")}>
|
||||
<SettingsIcon size={20}/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Settings</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant={currentPage === "audio-analysis" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "audio-analysis" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("audio-analysis")}>
|
||||
<ActivityIcon size={20}/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Audio Quality Analyzer</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant={currentPage === "audio-converter" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "audio-converter" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("audio-converter")}>
|
||||
<FileMusicIcon size={20}/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Audio Converter</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant={currentPage === "file-manager" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "file-manager" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("file-manager")}>
|
||||
<FilePenIcon size={20}/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>File Manager</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant={currentPage === "debug" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "debug" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("debug")}>
|
||||
<TerminalIcon size={20}/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Debug Logs</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="mt-auto flex flex-col gap-2">
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-10 w-10 hover:bg-primary/10 hover:text-primary" onClick={() => openExternal("https://github.com/afkarxyz/SpotiFLAC/issues/new?title=%5BBug%20Report%5D%20/%20%5BFeature%20Request%5D&body=%3C%21--%20WARNING%3A%20Issues%20that%20do%20not%20follow%20this%20template%20will%20be%20closed%20without%20review.%20Fill%20out%20the%20relevant%20section%20and%20delete%20the%20other.%20--%3E%0A%0A%23%23%23%20%5BBug%20Report%5D%0A%0A%23%23%23%23%20Problem%0A%3E%20Type%20here%0A%0A%23%23%23%23%20Type%0ATrack%20/%20Album%20/%20Playlist%20/%20Artist%0A%0A%23%23%23%23%20Spotify%20URL%0A%3E%20Type%20here%0A%0A%23%23%23%23%20Version%0ASpotiFLAC%20v%0A%0A%23%23%23%23%20OS%0AWindows%20/%20Linux%20/%20macOS%0A%0A%23%23%23%23%20Additional%20Context%0A%3E%20Type%20here%20or%20send%20screenshot%0A%0A---%0A%0A%23%23%23%20%5BFeature%20Request%5D%0A%0A%23%23%23%23%20Description%0A%3E%20Type%20here%0A%0A%23%23%23%23%20Use%20Case%0A%3E%20Type%20here%0A%0A%23%23%23%23%20Additional%20Context%0A%3E%20Type%20here%20or%20send%20screenshot")}>
|
||||
<GithubIcon size={20}/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Report Bug or Feature Request</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-10 w-10 hover:bg-primary/10 hover:text-primary" onClick={() => openExternal("https://exyezed.cc/")}>
|
||||
<BlocksIcon size={20}/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Other Projects</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-10 w-10 hover:bg-primary/10 hover:text-primary" onClick={() => openExternal("https://ko-fi.com/afkarxyz")}>
|
||||
<CoffeeIcon size={20}/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Every coffee helps me keep going</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>);
|
||||
interface AnimatedIconHandle {
|
||||
startAnimation: () => void;
|
||||
stopAnimation: () => void;
|
||||
}
|
||||
export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
||||
const [isIssuesDialogOpen, setIsIssuesDialogOpen] = useState(false);
|
||||
const [hasIssueAgreement, setHasIssueAgreement] = useState(false);
|
||||
const analyzerIconRef = useRef<ActivityIconHandle>(null);
|
||||
const resamplerIconRef = useRef<AudioLinesIconHandle>(null);
|
||||
const converterIconRef = useRef<FileMusicIconHandle>(null);
|
||||
const fileManagerIconRef = useRef<FilePenIconHandle>(null);
|
||||
const handleIssuesDialogChange = (open: boolean) => {
|
||||
setIsIssuesDialogOpen(open);
|
||||
if (!open) {
|
||||
setHasIssueAgreement(false);
|
||||
}
|
||||
};
|
||||
const handleOpenIssues = () => {
|
||||
openExternal("https://github.com/afkarxyz/SpotiFLAC/issues");
|
||||
handleIssuesDialogChange(false);
|
||||
};
|
||||
const getAnimatedItemHandlers = <T extends AnimatedIconHandle>(iconRef: RefObject<T | null>) => ({
|
||||
onMouseEnter: () => iconRef.current?.startAnimation(),
|
||||
onMouseLeave: () => iconRef.current?.stopAnimation(),
|
||||
onFocus: () => iconRef.current?.startAnimation(),
|
||||
onBlur: () => iconRef.current?.stopAnimation(),
|
||||
});
|
||||
return (<div className="fixed left-0 top-0 h-full w-14 bg-card border-r border-border flex flex-col items-center py-14 z-30">
|
||||
<div className="flex flex-col gap-2 flex-1">
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant={currentPage === "main" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "main" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("main")}>
|
||||
<HomeIcon size={20}/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Home</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant={currentPage === "history" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "history" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("history")}>
|
||||
<HistoryIcon size={20}/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>History</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant={currentPage === "settings" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "settings" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("settings")}>
|
||||
<SettingsIcon size={20}/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Settings</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant={currentPage === "debug" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "debug" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("debug")}>
|
||||
<TerminalIcon size={20} loop={true}/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Debug Logs</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<DropdownMenu>
|
||||
<Tooltip delayDuration={0}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant={["audio-analysis", "audio-converter", "audio-resampler", "file-manager"].includes(currentPage) ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${["audio-analysis", "audio-converter", "audio-resampler", "file-manager"].includes(currentPage) ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`}>
|
||||
<BlocksIcon size={20} loop={true}/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
</DropdownMenuTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Tools</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent side="right" sideOffset={14} className="min-w-[200px] ml-2">
|
||||
<DropdownMenuItem onClick={() => onPageChange("audio-analysis")} className="gap-3 cursor-pointer py-2 px-3" {...getAnimatedItemHandlers(analyzerIconRef)}>
|
||||
<ActivityIcon ref={analyzerIconRef} size={16}/>
|
||||
<span>Audio Quality Analyzer</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onPageChange("audio-resampler")} className="gap-3 cursor-pointer py-2 px-3" {...getAnimatedItemHandlers(resamplerIconRef)}>
|
||||
<AudioLinesIcon ref={resamplerIconRef} size={16}/>
|
||||
<span>Audio Resampler</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onPageChange("audio-converter")} className="gap-3 cursor-pointer py-2 px-3" {...getAnimatedItemHandlers(converterIconRef)}>
|
||||
<FileMusicIcon ref={converterIconRef} size={16}/>
|
||||
<span>Audio Converter</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onPageChange("file-manager")} className="gap-3 cursor-pointer py-2 px-3" {...getAnimatedItemHandlers(fileManagerIconRef)}>
|
||||
<FilePenIcon ref={fileManagerIconRef} size={16}/>
|
||||
<span>File Manager</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex flex-col gap-2">
|
||||
<Dialog open={isIssuesDialogOpen} onOpenChange={handleIssuesDialogChange}>
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-10 w-10 hover:bg-primary/10 hover:text-primary" onClick={() => setIsIssuesDialogOpen(true)}>
|
||||
<GithubIcon size={20}/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Report Bugs or Request Features</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DialogContent className="max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Before Opening GitHub Issues</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 text-sm">
|
||||
<div className="rounded-lg border border-amber-500/40 bg-amber-500/10 p-4">
|
||||
<p className="font-semibold text-amber-900 dark:text-amber-200">Important</p>
|
||||
<p className="mt-1 text-amber-950/90 dark:text-amber-100/90">
|
||||
Search existing issues first and use the issue template when opening a new report or request.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label className="flex cursor-pointer items-center gap-3 rounded-lg border p-4">
|
||||
<Checkbox className="shrink-0" checked={hasIssueAgreement} onCheckedChange={(checked) => setHasIssueAgreement(checked === true)}/>
|
||||
<span className="leading-5 text-foreground/90">
|
||||
I understand that I should use the issue template and avoid duplicate issues.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="sm:justify-between gap-2">
|
||||
<Button variant="outline" onClick={() => handleIssuesDialogChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button disabled={!hasIssueAgreement} onClick={handleOpenIssues}>
|
||||
Open Issues
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant={currentPage === "about" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "about" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("about")}>
|
||||
<BadgeAlertIcon size={20}/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>About</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-10 w-10 hover:bg-primary/10 hover:text-primary" onClick={() => openExternal("https://ko-fi.com/afkarxyz")}>
|
||||
<CoffeeIcon size={20} loop={true}/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Support me on Ko-fi</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,486 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useRef, useState, forwardRef, useImperativeHandle } from "react";
|
||||
import type { SpectrumData } from "@/types/api";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { loadAudioAnalysisPreferences, saveAudioAnalysisPreferences, type AnalyzerColorScheme, type AnalyzerFreqScale, type AnalyzerWindowFunction, } from "@/lib/audio-analysis-preferences";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select";
|
||||
export interface SpectrumVisualizationHandle {
|
||||
getCanvasDataURL: () => string | null;
|
||||
}
|
||||
type ColorScheme = AnalyzerColorScheme;
|
||||
type FreqScale = AnalyzerFreqScale;
|
||||
type WindowFunction = AnalyzerWindowFunction;
|
||||
export interface SpectrogramRenderOptions {
|
||||
spectrumData: SpectrumData;
|
||||
sampleRate: number;
|
||||
duration: number;
|
||||
freqScale: FreqScale;
|
||||
colorScheme: ColorScheme;
|
||||
fileName?: string;
|
||||
shouldCancel?: () => boolean;
|
||||
}
|
||||
interface SpectrumVisualizationProps {
|
||||
sampleRate: number;
|
||||
bitsPerSample: number;
|
||||
duration: number;
|
||||
spectrumData?: SpectrumData;
|
||||
fileName?: string;
|
||||
onReAnalyze?: (fftSize: number, windowFunction: string) => void;
|
||||
isAnalyzingSpectrum?: boolean;
|
||||
spectrumProgress?: {
|
||||
percent: number;
|
||||
message: string;
|
||||
};
|
||||
}
|
||||
export function SpectrumVisualization({ sampleRate, bitsPerSample, duration, spectrumData, }: SpectrumVisualizationProps) {
|
||||
const MARGIN = { top: 50, right: 120, bottom: 70, left: 90 };
|
||||
const CANVAS_W = 1100;
|
||||
const CANVAS_H = 600;
|
||||
const MAX_RENDER_HEIGHT = 1080;
|
||||
function clamp01(value: number): number {
|
||||
return Math.max(0, Math.min(1, value));
|
||||
}
|
||||
function spekColorMap(t: number): [
|
||||
number,
|
||||
number,
|
||||
number
|
||||
] {
|
||||
const colors: Array<[
|
||||
number,
|
||||
number,
|
||||
number
|
||||
]> = [
|
||||
[0, 0, 0],
|
||||
[0, 0, 25],
|
||||
[0, 0, 50],
|
||||
[0, 0, 80],
|
||||
[20, 0, 120],
|
||||
[50, 0, 150],
|
||||
[80, 0, 180],
|
||||
[120, 0, 120],
|
||||
[150, 0, 80],
|
||||
[180, 0, 40],
|
||||
[210, 0, 0],
|
||||
[240, 30, 0],
|
||||
[255, 60, 0],
|
||||
[255, 100, 0],
|
||||
[255, 140, 0],
|
||||
[255, 180, 0],
|
||||
[255, 210, 0],
|
||||
[255, 235, 0],
|
||||
[255, 250, 50],
|
||||
[255, 255, 100],
|
||||
[255, 255, 150],
|
||||
[255, 255, 200],
|
||||
[255, 255, 255],
|
||||
];
|
||||
const scaled = t * (colors.length - 1);
|
||||
const idx = Math.floor(scaled);
|
||||
const fraction = scaled - idx;
|
||||
if (idx >= colors.length - 1) {
|
||||
return colors[colors.length - 1];
|
||||
}
|
||||
const c1 = colors[idx];
|
||||
const c2 = colors[idx + 1];
|
||||
return [
|
||||
Math.round(c1[0] + (c2[0] - c1[0]) * fraction),
|
||||
Math.round(c1[1] + (c2[1] - c1[1]) * fraction),
|
||||
Math.round(c1[2] + (c2[2] - c1[2]) * fraction),
|
||||
];
|
||||
}
|
||||
function viridisColorMap(t: number): [
|
||||
number,
|
||||
number,
|
||||
number
|
||||
] {
|
||||
const colors: Array<[
|
||||
number,
|
||||
number,
|
||||
number
|
||||
]> = [
|
||||
[68, 1, 84],
|
||||
[70, 20, 100],
|
||||
[72, 40, 120],
|
||||
[67, 62, 133],
|
||||
[62, 74, 137],
|
||||
[55, 89, 140],
|
||||
[49, 104, 142],
|
||||
[43, 117, 142],
|
||||
[38, 130, 142],
|
||||
[35, 144, 140],
|
||||
[31, 158, 137],
|
||||
[42, 171, 129],
|
||||
[53, 183, 121],
|
||||
[81, 194, 105],
|
||||
[109, 205, 89],
|
||||
[144, 214, 67],
|
||||
[180, 222, 44],
|
||||
[216, 227, 41],
|
||||
[253, 231, 37],
|
||||
];
|
||||
const scaled = t * (colors.length - 1);
|
||||
const idx = Math.floor(scaled);
|
||||
const fraction = scaled - idx;
|
||||
if (idx >= colors.length - 1) {
|
||||
return colors[colors.length - 1];
|
||||
}
|
||||
const c1 = colors[idx];
|
||||
const c2 = colors[idx + 1];
|
||||
return [
|
||||
Math.floor(c1[0] + (c2[0] - c1[0]) * fraction),
|
||||
Math.floor(c1[1] + (c2[1] - c1[1]) * fraction),
|
||||
Math.floor(c1[2] + (c2[2] - c1[2]) * fraction),
|
||||
];
|
||||
}
|
||||
function hotColorMap(t: number): [
|
||||
number,
|
||||
number,
|
||||
number
|
||||
] {
|
||||
if (t < 0.33) {
|
||||
return [Math.floor(t * 3 * 255), 0, 0];
|
||||
}
|
||||
if (t < 0.66) {
|
||||
return [255, Math.floor((t - 0.33) * 3 * 255), 0];
|
||||
}
|
||||
return [255, 255, Math.floor((t - 0.66) * 3 * 255)];
|
||||
}
|
||||
function coolColorMap(t: number): [
|
||||
number,
|
||||
number,
|
||||
number
|
||||
] {
|
||||
return [Math.floor(t * 255), Math.floor((1 - t) * 255), 255];
|
||||
}
|
||||
function getColorValues(norm: number, scheme: ColorScheme): [
|
||||
number,
|
||||
number,
|
||||
number
|
||||
] {
|
||||
const value = clamp01(norm);
|
||||
switch (scheme) {
|
||||
case "spek":
|
||||
return spekColorMap(value);
|
||||
case "viridis":
|
||||
return viridisColorMap(value);
|
||||
case "hot":
|
||||
return hotColorMap(value);
|
||||
case "cool":
|
||||
return coolColorMap(value);
|
||||
case "grayscale":
|
||||
default: {
|
||||
const gray = Math.floor(value * 255);
|
||||
return [gray, gray, gray];
|
||||
}
|
||||
}
|
||||
}
|
||||
function getColorString(norm: number, scheme: ColorScheme): string {
|
||||
const [r, g, b] = getColorValues(norm, scheme);
|
||||
return `rgb(${r},${g},${b})`;
|
||||
}
|
||||
function addAxisLabels(ctx: CanvasRenderingContext2D, plotWidth: number, plotHeight: number, sampleRate: number, duration: number, freqScale: FreqScale, fileName?: string) {
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.font = "12px Segoe UI";
|
||||
ctx.textAlign = "center";
|
||||
const widthFactor = plotWidth / 1000;
|
||||
let timeStep: number;
|
||||
if (duration <= 10) {
|
||||
timeStep = widthFactor >= 1.8 ? 0.25 : (widthFactor >= 1.3 ? 0.5 : 0.5);
|
||||
}
|
||||
else if (duration <= 30) {
|
||||
timeStep = widthFactor >= 1.8 ? 0.5 : (widthFactor >= 1.3 ? 1 : 1);
|
||||
}
|
||||
else if (duration <= 120) {
|
||||
timeStep = widthFactor >= 1.8 ? 3 : (widthFactor >= 1.3 ? 4 : 5);
|
||||
}
|
||||
else if (duration <= 600) {
|
||||
timeStep = widthFactor >= 1.8 ? 10 : (widthFactor >= 1.3 ? 15 : 20);
|
||||
}
|
||||
else {
|
||||
timeStep = widthFactor >= 1.8 ? 20 : (widthFactor >= 1.3 ? 30 : 40);
|
||||
}
|
||||
if (duration > 0) {
|
||||
for (let time = 0; time <= duration + 1e-9; time += timeStep) {
|
||||
const timeProgress = time / duration;
|
||||
const x = MARGIN.left + timeProgress * (plotWidth - 1);
|
||||
const y = CANVAS_H - MARGIN.bottom + 20;
|
||||
ctx.strokeStyle = "#ffffff";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, MARGIN.top + plotHeight);
|
||||
ctx.lineTo(x, MARGIN.top + plotHeight + 5);
|
||||
ctx.stroke();
|
||||
let label: string;
|
||||
if (timeStep >= 60) {
|
||||
const minutes = Math.floor(time / 60);
|
||||
const seconds = time % 60;
|
||||
label = seconds === 0 ? `${minutes}m` : `${minutes}m${seconds}s`;
|
||||
}
|
||||
else {
|
||||
label = `${time}s`;
|
||||
}
|
||||
ctx.fillText(label, x, y);
|
||||
}
|
||||
}
|
||||
ctx.textAlign = "right";
|
||||
const maxFreq = sampleRate / 2;
|
||||
if (freqScale === "log2") {
|
||||
const heightFactor = plotHeight / 500;
|
||||
const minFreq = 20;
|
||||
const frequencies: number[] = [];
|
||||
const octaveStep = heightFactor >= 1.5 ? 1 : (heightFactor >= 1.0 ? 1 : 2);
|
||||
let octaveCount = 0;
|
||||
for (let freq = minFreq; freq <= maxFreq; freq *= 2) {
|
||||
if (octaveCount % octaveStep === 0) {
|
||||
frequencies.push(freq);
|
||||
}
|
||||
octaveCount++;
|
||||
}
|
||||
for (const freq of frequencies) {
|
||||
const freqNormalized = Math.log2(freq / minFreq) / Math.log2(maxFreq / minFreq);
|
||||
const y = MARGIN.top + plotHeight * (1 - freqNormalized);
|
||||
ctx.strokeStyle = "#ffffff";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(MARGIN.left - 5, y);
|
||||
ctx.lineTo(MARGIN.left, y);
|
||||
ctx.stroke();
|
||||
const label = freq >= 1000 ? `${(freq / 1000).toFixed(1)}k` : `${freq}`;
|
||||
ctx.fillText(label, MARGIN.left - 10, y + 4);
|
||||
}
|
||||
}
|
||||
else {
|
||||
const heightFactor = plotHeight / 500;
|
||||
let freqStep: number;
|
||||
if (maxFreq <= 8000) {
|
||||
freqStep = heightFactor >= 1.8 ? 250 : (heightFactor >= 1.3 ? 400 : 500);
|
||||
}
|
||||
else if (maxFreq <= 16000) {
|
||||
freqStep = heightFactor >= 1.8 ? 500 : (heightFactor >= 1.3 ? 800 : 1000);
|
||||
}
|
||||
else if (maxFreq <= 24000) {
|
||||
freqStep = heightFactor >= 1.8 ? 1000 : (heightFactor >= 1.3 ? 1500 : 2000);
|
||||
}
|
||||
else {
|
||||
freqStep = heightFactor >= 1.8 ? 2000 : (heightFactor >= 1.3 ? 2500 : 4000);
|
||||
}
|
||||
for (let freq = 0; freq <= maxFreq; freq += freqStep) {
|
||||
const y = MARGIN.top + plotHeight - (freq / maxFreq) * plotHeight + 4;
|
||||
const x = MARGIN.left - 15;
|
||||
ctx.strokeStyle = "#ffffff";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(MARGIN.left - 5, y - 4);
|
||||
ctx.lineTo(MARGIN.left, y - 4);
|
||||
ctx.stroke();
|
||||
let label: string;
|
||||
if (freq === 0) {
|
||||
label = "0";
|
||||
}
|
||||
else if (freq >= 1000) {
|
||||
label = freq % 1000 === 0 ? `${freq / 1000}k` : `${(freq / 1000).toFixed(1)}k`;
|
||||
}
|
||||
else {
|
||||
label = `${freq}`;
|
||||
}
|
||||
ctx.fillText(label, x, y);
|
||||
}
|
||||
}
|
||||
ctx.textAlign = "center";
|
||||
ctx.font = "14px Segoe UI";
|
||||
ctx.fillText("Time (seconds)", CANVAS_W / 2, CANVAS_H - 15);
|
||||
ctx.save();
|
||||
ctx.translate(25, CANVAS_H / 2);
|
||||
ctx.rotate(-Math.PI / 2);
|
||||
ctx.fillText("Frequency (Hz)", 0, 0);
|
||||
ctx.restore();
|
||||
ctx.font = "12px Segoe UI";
|
||||
if (fileName) {
|
||||
ctx.textAlign = "left";
|
||||
ctx.fillText(fileName, MARGIN.left + 15, 25);
|
||||
}
|
||||
ctx.textAlign = "right";
|
||||
ctx.fillText(`Sample Rate: ${sampleRate} Hz`, CANVAS_W - 20, 25);
|
||||
}
|
||||
function drawColorBar(ctx: CanvasRenderingContext2D, plotHeight: number, colorScheme: ColorScheme) {
|
||||
const colorBarWidth = 20;
|
||||
const colorBarX = CANVAS_W - MARGIN.right + 30;
|
||||
const colorBarY = MARGIN.top;
|
||||
const gradient = ctx.createLinearGradient(0, colorBarY + plotHeight, 0, colorBarY);
|
||||
for (let i = 0; i <= 100; i++) {
|
||||
const value = i / 100;
|
||||
gradient.addColorStop(value, getColorString(value, colorScheme));
|
||||
}
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fillRect(colorBarX, colorBarY, colorBarWidth, plotHeight);
|
||||
ctx.strokeStyle = "#ffffff";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(colorBarX, colorBarY, colorBarWidth, plotHeight);
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.font = "10px Segoe UI";
|
||||
ctx.textAlign = "left";
|
||||
ctx.fillText("High", colorBarX + colorBarWidth + 5, colorBarY + 12);
|
||||
ctx.fillText("Low", colorBarX + colorBarWidth + 5, colorBarY + plotHeight - 5);
|
||||
}
|
||||
async function renderSpectrogram(ctx: CanvasRenderingContext2D, spectrum: SpectrumData, sampleRate: number, duration: number, freqScale: FreqScale, colorScheme: ColorScheme, fileName: string | undefined, shouldCancel: () => boolean) {
|
||||
const plotWidth = CANVAS_W - MARGIN.left - MARGIN.right;
|
||||
const plotHeight = CANVAS_H - MARGIN.top - MARGIN.bottom;
|
||||
ctx.fillStyle = "#000000";
|
||||
ctx.fillRect(0, 0, CANVAS_W, CANVAS_H);
|
||||
const spectrogramData = spectrum.time_slices;
|
||||
const numTimeFrames = spectrogramData.length;
|
||||
const numFreqBins = spectrogramData[0]?.magnitudes.length ?? 0;
|
||||
if (numTimeFrames === 0 || numFreqBins === 0) {
|
||||
return;
|
||||
}
|
||||
let minMag = Number.POSITIVE_INFINITY;
|
||||
let maxMag = Number.NEGATIVE_INFINITY;
|
||||
const sampleStep = numTimeFrames > 10000 ? Math.floor(numTimeFrames / 5000) : 1;
|
||||
for (let i = 0; i < numTimeFrames; i += sampleStep) {
|
||||
const frame = spectrogramData[i].magnitudes;
|
||||
for (const mag of frame) {
|
||||
if (Number.isFinite(mag)) {
|
||||
if (mag < minMag)
|
||||
minMag = mag;
|
||||
if (mag > maxMag)
|
||||
maxMag = mag;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!Number.isFinite(minMag) || !Number.isFinite(maxMag)) {
|
||||
minMag = -120;
|
||||
maxMag = 0;
|
||||
}
|
||||
const magRange = maxMag - minMag;
|
||||
const safeMagRange = magRange > 0 ? magRange : 1;
|
||||
const highResImageData = ctx.createImageData(plotWidth, MAX_RENDER_HEIGHT);
|
||||
const highResData = highResImageData.data;
|
||||
const CHUNK_SIZE = 50;
|
||||
for (let xStart = 0; xStart < plotWidth; xStart += CHUNK_SIZE) {
|
||||
if (shouldCancel()) {
|
||||
return;
|
||||
}
|
||||
const xEnd = Math.min(xStart + CHUNK_SIZE, plotWidth);
|
||||
for (let x = xStart; x < xEnd; x++) {
|
||||
const timeProgress = x / (plotWidth - 1);
|
||||
const exactTimePos = timeProgress * (numTimeFrames - 1);
|
||||
const timeIdx = Math.floor(exactTimePos);
|
||||
const timeIdx2 = Math.min(timeIdx + 1, numTimeFrames - 1);
|
||||
const timeFrac = exactTimePos - timeIdx;
|
||||
const frame1 = spectrogramData[timeIdx]?.magnitudes ?? spectrogramData[0].magnitudes;
|
||||
const frame2 = spectrogramData[timeIdx2]?.magnitudes ?? frame1;
|
||||
for (let y = 0; y < MAX_RENDER_HEIGHT; y++) {
|
||||
let freqProgress = (MAX_RENDER_HEIGHT - 1 - y) / (MAX_RENDER_HEIGHT - 1);
|
||||
if (freqScale === "log2") {
|
||||
const minFreq = 20;
|
||||
const maxFreq = sampleRate / 2;
|
||||
const octaves = Math.log2(maxFreq / minFreq);
|
||||
const octave = freqProgress * octaves;
|
||||
const freq = minFreq * Math.pow(2, octave);
|
||||
freqProgress = freq / maxFreq;
|
||||
}
|
||||
const exactFreqPos = freqProgress * (numFreqBins - 1);
|
||||
const freqIdx = Math.floor(exactFreqPos);
|
||||
const freqIdx2 = Math.min(freqIdx + 1, numFreqBins - 1);
|
||||
const freqFrac = exactFreqPos - freqIdx;
|
||||
let magnitude: number;
|
||||
if (timeFrac === 0 && freqFrac === 0) {
|
||||
magnitude = frame1[freqIdx] ?? 0;
|
||||
}
|
||||
else {
|
||||
const mag11 = frame1[freqIdx] ?? 0;
|
||||
const mag12 = frame1[freqIdx2] ?? 0;
|
||||
const mag21 = frame2[freqIdx] ?? 0;
|
||||
const mag22 = frame2[freqIdx2] ?? 0;
|
||||
const magT1 = mag11 * (1 - freqFrac) + mag12 * freqFrac;
|
||||
const magT2 = mag21 * (1 - freqFrac) + mag22 * freqFrac;
|
||||
magnitude = magT1 * (1 - timeFrac) + magT2 * timeFrac;
|
||||
}
|
||||
const normalizedMag = clamp01((magnitude - minMag) / safeMagRange);
|
||||
const [r, g, b] = getColorValues(normalizedMag, colorScheme);
|
||||
const pixelIdx = (y * plotWidth + x) * 4;
|
||||
highResData[pixelIdx] = r;
|
||||
highResData[pixelIdx + 1] = g;
|
||||
highResData[pixelIdx + 2] = b;
|
||||
highResData[pixelIdx + 3] = 255;
|
||||
}
|
||||
}
|
||||
if (xStart + CHUNK_SIZE < plotWidth) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
}
|
||||
}
|
||||
if (shouldCancel()) {
|
||||
return;
|
||||
}
|
||||
const finalImageData = ctx.createImageData(plotWidth, plotHeight);
|
||||
const finalData = finalImageData.data;
|
||||
for (let y = 0; y < plotHeight; y++) {
|
||||
for (let x = 0; x < plotWidth; x++) {
|
||||
const highResY = Math.round((y / plotHeight) * MAX_RENDER_HEIGHT);
|
||||
const highResIdx = (highResY * plotWidth + x) * 4;
|
||||
const finalIdx = (y * plotWidth + x) * 4;
|
||||
if (highResIdx < highResData.length) {
|
||||
finalData[finalIdx] = highResData[highResIdx];
|
||||
finalData[finalIdx + 1] = highResData[highResIdx + 1];
|
||||
finalData[finalIdx + 2] = highResData[highResIdx + 2];
|
||||
finalData[finalIdx + 3] = highResData[highResIdx + 3];
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.putImageData(finalImageData, MARGIN.left, MARGIN.top);
|
||||
addAxisLabels(ctx, plotWidth, plotHeight, sampleRate, duration, freqScale, fileName);
|
||||
drawColorBar(ctx, plotHeight, colorScheme);
|
||||
}
|
||||
export async function renderSpectrogramToCanvas(canvas: HTMLCanvasElement, options: SpectrogramRenderOptions): Promise<void> {
|
||||
canvas.width = CANVAS_W;
|
||||
canvas.height = CANVAS_H;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) {
|
||||
throw new Error("Cannot get 2D canvas context");
|
||||
}
|
||||
await renderSpectrogram(ctx, options.spectrumData, options.sampleRate, options.duration, options.freqScale, options.colorScheme, options.fileName, options.shouldCancel ?? (() => false));
|
||||
}
|
||||
export async function createSpectrogramDataURL(options: SpectrogramRenderOptions): Promise<string> {
|
||||
const canvas = document.createElement("canvas");
|
||||
await renderSpectrogramToCanvas(canvas, options);
|
||||
return canvas.toDataURL("image/png");
|
||||
}
|
||||
const COLOR_SCHEMES: {
|
||||
value: ColorScheme;
|
||||
label: string;
|
||||
gradient: string;
|
||||
}[] = [
|
||||
{ value: "spek", label: "Spek", gradient: "linear-gradient(to right, #0f0040, #1e0080, #4000ff, #8000ff, #ff0080, #ff4000, #ff8000, #ffff00)" },
|
||||
{ value: "viridis", label: "Viridis", gradient: "linear-gradient(to right, #440154, #31688e, #35b779, #fde725)" },
|
||||
{ value: "hot", label: "Hot", gradient: "linear-gradient(to right, #000000, #ff0000, #ffff00, #ffffff)" },
|
||||
{ value: "cool", label: "Cool", gradient: "linear-gradient(to right, #000080, #0000ff, #00ffff, #ffffff)" },
|
||||
{ value: "grayscale", label: "Grayscale", gradient: "linear-gradient(to right, #000000, #ffffff)" },
|
||||
];
|
||||
export const SpectrumVisualization = forwardRef<SpectrumVisualizationHandle, SpectrumVisualizationProps>(({ sampleRate, duration, spectrumData, fileName, onReAnalyze, isAnalyzingSpectrum, spectrumProgress, }, ref) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const preferencesRef = useRef(loadAudioAnalysisPreferences());
|
||||
useImperativeHandle(ref, () => ({
|
||||
getCanvasDataURL: () => {
|
||||
if (!canvasRef.current)
|
||||
return null;
|
||||
return canvasRef.current.toDataURL("image/png");
|
||||
},
|
||||
}));
|
||||
const [freqScale, setFreqScale] = useState<FreqScale>(preferencesRef.current.freqScale);
|
||||
const [colorScheme, setColorScheme] = useState<ColorScheme>(preferencesRef.current.colorScheme);
|
||||
const [fftSize, setFftSize] = useState<string>(() => String(preferencesRef.current.fftSize));
|
||||
const [windowFunction, setWindowFunction] = useState<WindowFunction>(preferencesRef.current.windowFunction);
|
||||
useEffect(() => {
|
||||
if (spectrumData?.freq_bins) {
|
||||
setFftSize(String((spectrumData.freq_bins - 1) * 2));
|
||||
}
|
||||
}, [spectrumData]);
|
||||
useEffect(() => {
|
||||
saveAudioAnalysisPreferences({
|
||||
colorScheme,
|
||||
freqScale,
|
||||
fftSize: Number(fftSize),
|
||||
windowFunction,
|
||||
});
|
||||
}, [colorScheme, freqScale, fftSize, windowFunction]);
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas)
|
||||
@@ -15,179 +488,115 @@ export function SpectrumVisualization({ sampleRate, bitsPerSample, duration, spe
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx)
|
||||
return;
|
||||
const width = canvas.width;
|
||||
const height = canvas.height;
|
||||
const marginLeft = 70;
|
||||
const marginRight = 70;
|
||||
const marginTop = 30;
|
||||
const marginBottom = 65;
|
||||
const plotWidth = width - marginLeft - marginRight;
|
||||
const plotHeight = height - marginTop - marginBottom;
|
||||
ctx.fillStyle = "#000000";
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
const nyquistFreq = sampleRate / 2;
|
||||
let canceled = false;
|
||||
const shouldCancel = () => canceled;
|
||||
if (spectrumData) {
|
||||
drawRealSpectrum(ctx, marginLeft, marginTop, plotWidth, plotHeight, spectrumData);
|
||||
}
|
||||
drawAxesAndLabels(ctx, marginLeft, marginTop, plotWidth, plotHeight, nyquistFreq, duration, sampleRate);
|
||||
drawColorBar(ctx, marginLeft + plotWidth + 15, marginTop, 20, plotHeight);
|
||||
}, [sampleRate, bitsPerSample, duration, spectrumData]);
|
||||
const drawRealSpectrum = (ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, spectrum: SpectrumData) => {
|
||||
const timeSlices = spectrum.time_slices;
|
||||
if (timeSlices.length === 0)
|
||||
return;
|
||||
const freqBins = timeSlices[0].magnitudes.length;
|
||||
const nyquistFreq = spectrum.max_freq;
|
||||
let minDB = 0;
|
||||
let maxDB = -200;
|
||||
timeSlices.forEach((slice) => {
|
||||
slice.magnitudes.forEach((db) => {
|
||||
if (db > maxDB)
|
||||
maxDB = db;
|
||||
if (db < minDB && db > -200)
|
||||
minDB = db;
|
||||
void renderSpectrogramToCanvas(canvas, {
|
||||
spectrumData,
|
||||
sampleRate,
|
||||
duration,
|
||||
freqScale,
|
||||
colorScheme,
|
||||
fileName,
|
||||
shouldCancel,
|
||||
});
|
||||
});
|
||||
minDB = Math.max(minDB, maxDB - 90);
|
||||
const dbRange = maxDB - minDB;
|
||||
const sliceWidth = Math.ceil(width / timeSlices.length);
|
||||
for (let t = 0; t < timeSlices.length; t++) {
|
||||
const slice = timeSlices[t];
|
||||
const xPos = x + (t / timeSlices.length) * width;
|
||||
for (let f = 0; f < freqBins && f < slice.magnitudes.length; f++) {
|
||||
const db = slice.magnitudes[f];
|
||||
const freq = (f / freqBins) * nyquistFreq;
|
||||
const freqRatio = freq / nyquistFreq;
|
||||
const yPos = y + height - (freqRatio * height);
|
||||
const nextFreq = ((f + 1) / freqBins) * nyquistFreq;
|
||||
const nextFreqRatio = nextFreq / nyquistFreq;
|
||||
const nextYPos = y + height - (nextFreqRatio * height);
|
||||
const binHeight = Math.max(1, Math.abs(yPos - nextYPos) + 1);
|
||||
const intensity = Math.max(0, Math.min(1, (db - minDB) / dbRange));
|
||||
const color = getSpekColor(intensity);
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(xPos, nextYPos, sliceWidth, binHeight);
|
||||
}
|
||||
}
|
||||
};
|
||||
const getSpekColor = (intensity: number): string => {
|
||||
if (intensity < 0.08) {
|
||||
const t = intensity / 0.08;
|
||||
return `rgb(0, 0, ${Math.floor(t * 80)})`;
|
||||
}
|
||||
else if (intensity < 0.18) {
|
||||
const t = (intensity - 0.08) / 0.10;
|
||||
return `rgb(${Math.floor(t * 50)}, ${Math.floor(t * 30)}, ${Math.floor(80 + t * 175)})`;
|
||||
}
|
||||
else if (intensity < 0.28) {
|
||||
const t = (intensity - 0.18) / 0.10;
|
||||
return `rgb(${Math.floor(50 + t * 150)}, ${Math.floor(30 - t * 30)}, ${Math.floor(255 - t * 55)})`;
|
||||
}
|
||||
else if (intensity < 0.40) {
|
||||
const t = (intensity - 0.28) / 0.12;
|
||||
return `rgb(${Math.floor(200 + t * 55)}, 0, ${Math.floor(200 - t * 200)})`;
|
||||
}
|
||||
else if (intensity < 0.52) {
|
||||
const t = (intensity - 0.40) / 0.12;
|
||||
return `rgb(255, ${Math.floor(t * 100)}, 0)`;
|
||||
}
|
||||
else if (intensity < 0.65) {
|
||||
const t = (intensity - 0.52) / 0.13;
|
||||
return `rgb(255, ${Math.floor(100 + t * 80)}, 0)`;
|
||||
}
|
||||
else if (intensity < 0.78) {
|
||||
const t = (intensity - 0.65) / 0.13;
|
||||
return `rgb(255, ${Math.floor(180 + t * 55)}, ${Math.floor(t * 30)})`;
|
||||
}
|
||||
else if (intensity < 0.90) {
|
||||
const t = (intensity - 0.78) / 0.12;
|
||||
return `rgb(255, ${Math.floor(235 + t * 20)}, ${Math.floor(30 + t * 100)})`;
|
||||
}
|
||||
else {
|
||||
const t = (intensity - 0.90) / 0.10;
|
||||
return `rgb(255, 255, ${Math.floor(130 + t * 125)})`;
|
||||
ctx.fillStyle = "#000000";
|
||||
ctx.fillRect(0, 0, CANVAS_W, CANVAS_H);
|
||||
ctx.fillStyle = "#444444";
|
||||
ctx.font = "16px Arial";
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillText("No spectrum data", CANVAS_W / 2, CANVAS_H / 2);
|
||||
}
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
}, [spectrumData, sampleRate, duration, freqScale, colorScheme, fileName]);
|
||||
const handleReAnalyze = (newFftSize: string, newWindowFunc: string) => {
|
||||
setFftSize(newFftSize);
|
||||
setWindowFunction(newWindowFunc as WindowFunction);
|
||||
if (onReAnalyze) {
|
||||
onReAnalyze(parseInt(newFftSize, 10), newWindowFunc);
|
||||
}
|
||||
};
|
||||
const drawAxesAndLabels = (ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, nyquistFreq: number, duration: number, sampleRate: number) => {
|
||||
ctx.fillStyle = "#CCCCCC";
|
||||
ctx.font = "12px Arial";
|
||||
ctx.textAlign = "right";
|
||||
ctx.textBaseline = "middle";
|
||||
const freqLabels = generateFreqLabels(nyquistFreq);
|
||||
freqLabels.forEach(freq => {
|
||||
if (freq <= nyquistFreq) {
|
||||
const freqRatio = freq / nyquistFreq;
|
||||
const yPos = y + height - (freqRatio * height);
|
||||
const label = freq >= 1000 ? `${freq / 1000}k` : `${freq}`;
|
||||
ctx.fillText(label, x - 8, yPos);
|
||||
}
|
||||
});
|
||||
ctx.fillText("0", x - 8, y + height);
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "top";
|
||||
const timeStep = getTimeStep(duration);
|
||||
for (let t = 0; t <= duration; t += timeStep) {
|
||||
const xPos = x + (t / duration) * width;
|
||||
ctx.fillText(`${Math.round(t)}s`, xPos, y + height + 8);
|
||||
}
|
||||
ctx.fillStyle = "#FFFFFF";
|
||||
ctx.font = "13px Arial";
|
||||
ctx.save();
|
||||
ctx.translate(12, y + height / 2);
|
||||
ctx.rotate(-Math.PI / 2);
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillText("Frequency (Hz)", 0, 0);
|
||||
ctx.restore();
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillText("Time (seconds)", x + width / 2, y + height + 35);
|
||||
ctx.textAlign = "right";
|
||||
ctx.fillStyle = "#CCCCCC";
|
||||
ctx.font = "12px Arial";
|
||||
ctx.fillText(`Sample Rate: ${sampleRate} Hz`, x + width - 5, y - 3);
|
||||
};
|
||||
const generateFreqLabels = (nyquistFreq: number): number[] => {
|
||||
if (nyquistFreq <= 24000) {
|
||||
return [2000, 4000, 6000, 8000, 10000, 12000, 14000, 16000, 18000, 20000, 22000];
|
||||
}
|
||||
else if (nyquistFreq <= 48000) {
|
||||
return [5000, 10000, 15000, 20000, 25000, 30000, 35000, 40000, 45000];
|
||||
}
|
||||
else if (nyquistFreq <= 96000) {
|
||||
return [10000, 20000, 30000, 40000, 50000, 60000, 70000, 80000, 90000];
|
||||
}
|
||||
else {
|
||||
return [20000, 40000, 60000, 80000, 100000, 120000, 140000, 160000, 180000];
|
||||
}
|
||||
};
|
||||
const getTimeStep = (duration: number): number => {
|
||||
if (duration <= 60)
|
||||
return 15;
|
||||
if (duration <= 120)
|
||||
return 30;
|
||||
if (duration <= 300)
|
||||
return 30;
|
||||
if (duration <= 600)
|
||||
return 60;
|
||||
return 60;
|
||||
};
|
||||
const drawColorBar = (ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number) => {
|
||||
for (let i = 0; i < height; i++) {
|
||||
const intensity = 1 - (i / height);
|
||||
const color = getSpekColor(intensity);
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(x, y + i, width, 1);
|
||||
}
|
||||
ctx.strokeStyle = "#666666";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(x, y, width, height);
|
||||
ctx.fillStyle = "#FFFFFF";
|
||||
ctx.font = "11px Arial";
|
||||
ctx.textAlign = "left";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText("High", x + width + 5, y + 10);
|
||||
ctx.fillText("Low", x + width + 5, y + height - 10);
|
||||
};
|
||||
return (<div className="border border-white/10 rounded-lg overflow-hidden bg-black shadow-xl">
|
||||
<canvas ref={canvasRef} width={1200} height={600} className="w-full h-auto" style={{ imageRendering: "auto" }}/>
|
||||
</div>);
|
||||
}
|
||||
const spectrumPercent = Math.round(Math.max(0, Math.min(100, spectrumProgress?.percent ?? 0)));
|
||||
return (<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-3 sm:gap-4 p-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="whitespace-nowrap text-sm font-medium">Color Scheme:</Label>
|
||||
<Select value={colorScheme} onValueChange={(v) => setColorScheme(v as ColorScheme)} disabled={isAnalyzingSpectrum}>
|
||||
<SelectTrigger className="h-8 w-[130px] text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{COLOR_SCHEMES.map((scheme) => (<SelectItem key={scheme.value} value={scheme.value}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-4 w-4 rounded-sm border opacity-90" style={{ backgroundImage: scheme.gradient }}/>
|
||||
<span>{scheme.label}</span>
|
||||
</div>
|
||||
</SelectItem>))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="h-6 w-px bg-border hidden sm:block mx-1"></div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="whitespace-nowrap text-sm font-medium">Freq Scale:</Label>
|
||||
<Select value={freqScale} onValueChange={(v) => setFreqScale(v as FreqScale)} disabled={isAnalyzingSpectrum}>
|
||||
<SelectTrigger className="h-8 w-[95px] text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="linear">Linear</SelectItem>
|
||||
<SelectItem value="log2">Log2</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="whitespace-nowrap text-sm font-medium">FFT Size:</Label>
|
||||
<Select value={fftSize} onValueChange={(v) => handleReAnalyze(v, windowFunction)} disabled={isAnalyzingSpectrum}>
|
||||
<SelectTrigger className="h-8 w-[90px] text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="512">512</SelectItem>
|
||||
<SelectItem value="1024">1024</SelectItem>
|
||||
<SelectItem value="2048">2048</SelectItem>
|
||||
<SelectItem value="4096">4096</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="whitespace-nowrap text-sm font-medium">Window:</Label>
|
||||
<Select value={windowFunction} onValueChange={(v) => handleReAnalyze(fftSize, v)} disabled={isAnalyzingSpectrum}>
|
||||
<SelectTrigger className="h-8 w-[120px] text-sm capitalize">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="hann">Hann</SelectItem>
|
||||
<SelectItem value="hamming">Hamming</SelectItem>
|
||||
<SelectItem value="blackman">Blackman</SelectItem>
|
||||
<SelectItem value="rectangular">Rectangular</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative border border-white/10 rounded-lg overflow-hidden bg-black shadow-xl">
|
||||
{isAnalyzingSpectrum && (<div className="absolute inset-0 z-10 grid place-items-center bg-black/60 backdrop-blur-sm">
|
||||
<div className="w-full max-w-xs space-y-2 px-4">
|
||||
<div className="flex items-center justify-between text-sm text-foreground/90">
|
||||
<span>Processing...</span>
|
||||
<span className="tabular-nums">{spectrumPercent}%</span>
|
||||
</div>
|
||||
<Progress value={spectrumPercent} className="h-2 w-full"/>
|
||||
</div>
|
||||
</div>)}
|
||||
<canvas ref={canvasRef} width={CANVAS_W} height={CANVAS_H} className="w-full h-auto" style={{ imageRendering: "auto" }}/>
|
||||
</div>
|
||||
</div>);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,31 @@
|
||||
import { X, Minus, Maximize } from "lucide-react";
|
||||
import { X, Minus, Maximize, SlidersHorizontal, Info, Globe } from "lucide-react";
|
||||
import { WindowMinimise, WindowToggleMaximise, Quit } from "../../wailsjs/runtime/runtime";
|
||||
import { Menubar, MenubarContent, MenubarMenu, MenubarItem, MenubarTrigger, MenubarLabel, MenubarSeparator } from "@/components/ui/menubar";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { getSettings, updateSettings } from "@/lib/settings";
|
||||
import { openExternal } from "@/lib/utils";
|
||||
import { useState, useEffect } from "react";
|
||||
export function TitleBar() {
|
||||
const [useSpotFetchAPI, setUseSpotFetchAPI] = useState(false);
|
||||
useEffect(() => {
|
||||
const settings = getSettings();
|
||||
if (settings) {
|
||||
setUseSpotFetchAPI(settings.useSpotFetchAPI || false);
|
||||
}
|
||||
const handleSettingsUpdate = (event: any) => {
|
||||
const updatedSettings = event.detail;
|
||||
if (updatedSettings && typeof updatedSettings.useSpotFetchAPI !== 'undefined') {
|
||||
setUseSpotFetchAPI(updatedSettings.useSpotFetchAPI);
|
||||
}
|
||||
};
|
||||
window.addEventListener('settingsUpdated', handleSettingsUpdate);
|
||||
return () => window.removeEventListener('settingsUpdated', handleSettingsUpdate);
|
||||
}, []);
|
||||
const handleSpotFetchAPIToggle = () => {
|
||||
const newValue = !useSpotFetchAPI;
|
||||
setUseSpotFetchAPI(newValue);
|
||||
updateSettings({ useSpotFetchAPI: newValue });
|
||||
};
|
||||
const handleMinimize = () => {
|
||||
WindowMinimise();
|
||||
};
|
||||
@@ -11,11 +36,44 @@ export function TitleBar() {
|
||||
Quit();
|
||||
};
|
||||
return (<>
|
||||
|
||||
|
||||
<div className="fixed top-0 left-14 right-0 h-10 z-40 bg-background/80 backdrop-blur-sm" style={{ "--wails-draggable": "drag" } as React.CSSProperties} onDoubleClick={handleMaximize}/>
|
||||
|
||||
|
||||
<div className="fixed top-1.5 right-2 z-50 flex h-7 gap-0.5">
|
||||
|
||||
|
||||
<div className="fixed top-1.5 right-2 z-50 flex h-7 gap-0.5 items-center">
|
||||
<Menubar className="border-none bg-transparent shadow-none px-0 mr-1" style={{ "--wails-draggable": "no-drag" } as React.CSSProperties}>
|
||||
<MenubarMenu>
|
||||
<MenubarTrigger className="cursor-pointer w-8 h-7 p-0 flex items-center justify-center hover:bg-muted transition-colors rounded data-[state=open]:bg-muted">
|
||||
<SlidersHorizontal className="w-3.5 h-3.5"/>
|
||||
</MenubarTrigger>
|
||||
<MenubarContent align="end" className="min-w-[200px]">
|
||||
<div className="flex items-center gap-1.5 px-2 py-1.5">
|
||||
<MenubarLabel className="p-0">SpotFetch API</MenubarLabel>
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="w-3.5 h-3.5 cursor-help text-muted-foreground"/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" className="max-w-xs">
|
||||
<p className="font-semibold mb-2">Spotify Blocked Countries:</p>
|
||||
<p className="text-xs">Afghanistan, Antarctica, Central African Republic, China, Cuba, Eritrea, Iran, Myanmar, North Korea, Russia, Somalia, South Sudan, Sudan, Syria, Turkmenistan, Yemen</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<MenubarSeparator />
|
||||
<MenubarItem onClick={handleSpotFetchAPIToggle} className="justify-between">
|
||||
<span>Use SpotFetch API</span>
|
||||
<span className="ml-4">{useSpotFetchAPI ? "✓" : ""}</span>
|
||||
</MenubarItem>
|
||||
<MenubarSeparator />
|
||||
<MenubarItem onClick={() => openExternal("https://afkarxyz.qzz.io")} className="gap-2">
|
||||
<Globe className="w-4 h-4 opacity-70"/>
|
||||
<span>Website</span>
|
||||
</MenubarItem>
|
||||
</MenubarContent>
|
||||
</MenubarMenu>
|
||||
</Menubar>
|
||||
<button onClick={handleMinimize} className="w-8 h-7 flex items-center justify-center hover:bg-muted transition-colors rounded" style={{ "--wails-draggable": "no-drag" } as React.CSSProperties} aria-label="Minimize">
|
||||
<Minus className="w-3.5 h-3.5"/>
|
||||
</button>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Download, FolderOpen, CheckCircle, XCircle, FileText, FileCheck, Globe, ImageDown } from "lucide-react";
|
||||
import { Download, FolderOpen, CheckCircle, XCircle, FileText, FileCheck, Globe, ImageDown, Play, Pause } from "lucide-react";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
|
||||
import type { TrackMetadata, TrackAvailability } from "@/types/api";
|
||||
import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons";
|
||||
import { TidalAvailabilityIcon, QobuzAvailabilityIcon, AmazonAvailabilityIcon } from "./PlatformIcons";
|
||||
import { usePreview } from "@/hooks/usePreview";
|
||||
interface TrackInfoProps {
|
||||
track: TrackMetadata & {
|
||||
album_name: string;
|
||||
@@ -25,13 +26,15 @@ interface TrackInfoProps {
|
||||
downloadedCover?: boolean;
|
||||
failedCover?: boolean;
|
||||
skippedCover?: boolean;
|
||||
onDownload: (isrc: string, name: string, artists: string, albumName?: string, spotifyId?: string, playlistName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => void;
|
||||
onDownload: (id: string, name: string, artists: string, albumName?: string, spotifyId?: string, playlistName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => void;
|
||||
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
|
||||
onCheckAvailability?: (spotifyId: string, isrc?: string) => void;
|
||||
onCheckAvailability?: (spotifyId: string) => void;
|
||||
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName?: string, playlistName?: string, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
|
||||
onOpenFolder: () => void;
|
||||
onBack?: () => void;
|
||||
}
|
||||
export function TrackInfo({ track, isDownloading, downloadingTrack, isDownloaded, isFailed, isSkipped, downloadingLyricsTrack, downloadedLyrics, failedLyrics, skippedLyrics, checkingAvailability, availability, downloadingCover, downloadedCover, failedCover, skippedCover, onDownload, onDownloadLyrics, onCheckAvailability, onDownloadCover, onOpenFolder, }: TrackInfoProps) {
|
||||
export function TrackInfo({ track, isDownloading, downloadingTrack, isDownloaded, isFailed, isSkipped, downloadingLyricsTrack, downloadedLyrics, failedLyrics, skippedLyrics, checkingAvailability, availability, downloadingCover, downloadedCover, failedCover, skippedCover, onDownload, onDownloadLyrics, onCheckAvailability, onDownloadCover, onOpenFolder, onBack, }: TrackInfoProps) {
|
||||
const { playPreview, loadingPreview, playingTrack } = usePreview();
|
||||
const formatDuration = (ms: number) => {
|
||||
const minutes = Math.floor(ms / 60000);
|
||||
const seconds = Math.floor((ms % 60000) / 1000);
|
||||
@@ -43,97 +46,119 @@ export function TrackInfo({ track, isDownloading, downloadingTrack, isDownloaded
|
||||
return plays;
|
||||
return num.toLocaleString();
|
||||
};
|
||||
return (<Card>
|
||||
<CardContent className="px-6">
|
||||
<div className="flex gap-6 items-start">
|
||||
<div className="shrink-0">
|
||||
{track.images && (<div className="relative w-48 h-48 rounded-md shadow-lg overflow-hidden">
|
||||
<img src={track.images} alt={track.name} className="w-full h-full object-cover"/>
|
||||
<div className="absolute bottom-1 right-1 bg-black/80 text-white px-1.5 py-0.5 text-xs font-medium rounded">
|
||||
{formatDuration(track.duration_ms)}
|
||||
</div>
|
||||
</div>)}
|
||||
</div>
|
||||
<div className="flex-1 space-y-4 min-w-0">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-3xl font-bold wrap-break-word">{track.name}</h1>
|
||||
{isSkipped ? (<FileCheck className="h-6 w-6 text-yellow-500 shrink-0"/>) : isDownloaded ? (<CheckCircle className="h-6 w-6 text-green-500 shrink-0"/>) : isFailed ? (<XCircle className="h-6 w-6 text-red-500 shrink-0"/>) : null}
|
||||
</div>
|
||||
<p className="text-lg text-muted-foreground">{track.artists}</p>
|
||||
return (<Card className="relative">
|
||||
{onBack && (<div className="absolute top-4 right-4 z-10">
|
||||
<Button variant="ghost" size="icon" onClick={onBack}>
|
||||
<XCircle className="h-5 w-5"/>
|
||||
</Button>
|
||||
</div>)}
|
||||
<CardContent className="px-6">
|
||||
<div className="flex gap-6 items-start">
|
||||
<div className="shrink-0">
|
||||
{track.images && (<div className="relative w-48 h-48 rounded-md shadow-lg overflow-hidden">
|
||||
<img src={track.images} alt={track.name} className="w-full h-full object-cover"/>
|
||||
<div className="absolute bottom-1 right-1 bg-black/80 text-white px-1.5 py-0.5 text-xs font-medium rounded">
|
||||
{formatDuration(track.duration_ms)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div className="space-y-1">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Album</p>
|
||||
<p className="font-medium truncate">{track.album_name}</p>
|
||||
</div>
|
||||
{track.plays && (<div>
|
||||
<p className="text-xs text-muted-foreground">Total Plays</p>
|
||||
<p className="font-medium">{formatPlays(track.plays)}</p>
|
||||
</div>)}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Release Date</p>
|
||||
<p className="font-medium">{track.release_date}</p>
|
||||
</div>
|
||||
{track.copyright && (<div>
|
||||
<p className="text-xs text-muted-foreground">Copyright</p>
|
||||
<p className="font-medium truncate" title={track.copyright}>
|
||||
{track.copyright}
|
||||
</p>
|
||||
</div>)}
|
||||
</div>
|
||||
</div>
|
||||
{track.isrc && (<div className="flex gap-2 flex-wrap">
|
||||
<Button onClick={() => onDownload(track.isrc, track.name, track.artists, track.album_name, track.spotify_id, undefined, track.duration_ms, track.track_number, track.album_artist, track.release_date, track.images, track.track_number, track.disc_number, track.total_tracks, track.total_discs, track.copyright, track.publisher)} disabled={isDownloading || downloadingTrack === track.isrc}>
|
||||
{downloadingTrack === track.isrc ? (<Spinner />) : (<>
|
||||
<Download className="h-4 w-4"/>
|
||||
Download
|
||||
</>)}
|
||||
</Button>
|
||||
{track.spotify_id && onDownloadLyrics && (<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button onClick={() => onDownloadLyrics(track.spotify_id!, track.name, track.artists, track.album_name, track.album_artist, track.release_date, track.disc_number)} variant="outline" disabled={downloadingLyricsTrack === track.spotify_id}>
|
||||
{downloadingLyricsTrack === track.spotify_id ? (<Spinner />) : skippedLyrics ? (<FileCheck className="h-4 w-4 text-yellow-500"/>) : downloadedLyrics ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : failedLyrics ? (<XCircle className="h-4 w-4 text-red-500"/>) : (<FileText className="h-4 w-4"/>)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Download Lyric</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>)}
|
||||
{track.images && onDownloadCover && (<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button onClick={() => onDownloadCover(track.images, track.name, track.artists, track.album_name, undefined, undefined, track.spotify_id, track.album_artist, track.release_date, track.disc_number)} variant="outline" disabled={downloadingCover}>
|
||||
{downloadingCover ? (<Spinner />) : skippedCover ? (<FileCheck className="h-4 w-4 text-yellow-500"/>) : downloadedCover ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : failedCover ? (<XCircle className="h-4 w-4 text-red-500"/>) : (<ImageDown className="h-4 w-4"/>)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Download Cover</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>)}
|
||||
{track.spotify_id && onCheckAvailability && (<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button onClick={() => onCheckAvailability(track.spotify_id!, track.isrc)} variant="outline" disabled={checkingAvailability}>
|
||||
{checkingAvailability ? (<Spinner />) : availability ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : (<Globe className="h-4 w-4"/>)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{availability ? (<div className="flex items-center gap-2">
|
||||
<TidalIcon className={`w-4 h-4 ${availability.tidal ? "text-green-500" : "text-red-500"}`}/>
|
||||
<QobuzIcon className={`w-4 h-4 ${availability.qobuz ? "text-green-500" : "text-red-500"}`}/>
|
||||
<AmazonIcon className={`w-4 h-4 ${availability.amazon ? "text-green-500" : "text-red-500"}`}/>
|
||||
</div>) : (<p>Check Availability</p>)}
|
||||
</TooltipContent>
|
||||
</Tooltip>)}
|
||||
{isDownloaded && (<Button onClick={onOpenFolder} variant="outline">
|
||||
<FolderOpen className="h-4 w-4"/>
|
||||
Open Folder
|
||||
</Button>)}
|
||||
</div>)}
|
||||
</div>
|
||||
</div>)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>);
|
||||
<div className="flex-1 space-y-4 min-w-0">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-3xl font-bold wrap-break-word">{track.name}</h1>
|
||||
{track.is_explicit && (<span className="inline-flex items-center justify-center bg-red-600 text-white text-[10px] h-4 w-4 rounded shrink-0" title="Explicit">E</span>)}
|
||||
{isSkipped ? (<FileCheck className="h-6 w-6 text-yellow-500 shrink-0"/>) : isDownloaded ? (<CheckCircle className="h-6 w-6 text-green-500 shrink-0"/>) : isFailed ? (<XCircle className="h-6 w-6 text-red-500 shrink-0"/>) : null}
|
||||
</div>
|
||||
<p className="text-lg text-muted-foreground">{track.artists}</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div className="space-y-1">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Album</p>
|
||||
<p className="font-medium truncate">{track.album_name}</p>
|
||||
</div>
|
||||
{track.plays && (<div>
|
||||
<p className="text-xs text-muted-foreground">Total Plays</p>
|
||||
<p className="font-medium">{formatPlays(track.plays)}</p>
|
||||
</div>)}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Release Date</p>
|
||||
<p className="font-medium">{track.release_date}</p>
|
||||
</div>
|
||||
{track.copyright && (<div>
|
||||
<p className="text-xs text-muted-foreground">Copyright</p>
|
||||
<p className="font-medium truncate" title={track.copyright}>
|
||||
{track.copyright}
|
||||
</p>
|
||||
</div>)}
|
||||
</div>
|
||||
</div>
|
||||
{track.spotify_id && (<div className="flex gap-2 flex-wrap">
|
||||
<Button onClick={() => onDownload(track.spotify_id || "", track.name, track.artists, track.album_name, track.spotify_id, undefined, track.duration_ms, track.track_number, track.album_artist, track.release_date, track.images, track.track_number, track.disc_number, track.total_tracks, track.total_discs, track.copyright, track.publisher)} disabled={isDownloading || downloadingTrack === track.spotify_id}>
|
||||
{downloadingTrack === track.spotify_id ? (<Spinner />) : (<>
|
||||
<Download className="h-4 w-4"/>
|
||||
Download
|
||||
</>)}
|
||||
</Button>
|
||||
{track.spotify_id && (<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button onClick={() => playPreview(track.spotify_id!, track.name)} variant="outline" size="icon" disabled={loadingPreview === track.spotify_id}>
|
||||
{loadingPreview === track.spotify_id ? (<Spinner />) : playingTrack === track.spotify_id ? (<Pause className="h-4 w-4"/>) : (<Play className="h-4 w-4"/>)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{playingTrack === track.spotify_id ? "Stop Preview" : "Play Preview"}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>)}
|
||||
{track.spotify_id && onDownloadLyrics && (<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button onClick={() => onDownloadLyrics(track.spotify_id!, track.name, track.artists, track.album_name, track.album_artist, track.release_date, track.disc_number)} variant="outline" size="icon" disabled={downloadingLyricsTrack === track.spotify_id}>
|
||||
{downloadingLyricsTrack === track.spotify_id ? (<Spinner />) : skippedLyrics ? (<FileCheck className="h-4 w-4 text-yellow-500"/>) : downloadedLyrics ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : failedLyrics ? (<XCircle className="h-4 w-4 text-red-500"/>) : (<FileText className="h-4 w-4"/>)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Download Separate Lyric</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>)}
|
||||
{track.images && onDownloadCover && (<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button onClick={() => onDownloadCover(track.images, track.name, track.artists, track.album_name, undefined, undefined, track.spotify_id, track.album_artist, track.release_date, track.disc_number)} variant="outline" size="icon" disabled={downloadingCover}>
|
||||
{downloadingCover ? (<Spinner />) : skippedCover ? (<FileCheck className="h-4 w-4 text-yellow-500"/>) : downloadedCover ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : failedCover ? (<XCircle className="h-4 w-4 text-red-500"/>) : (<ImageDown className="h-4 w-4"/>)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Download Separate Cover</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>)}
|
||||
{track.spotify_id && onCheckAvailability && (<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button onClick={() => onCheckAvailability(track.spotify_id!)} variant="outline" size="icon" disabled={checkingAvailability}>
|
||||
{checkingAvailability ? (<Spinner />) : availability ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : (<Globe className="h-4 w-4"/>)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{availability ? (<div className="flex items-center gap-2">
|
||||
<TidalAvailabilityIcon className={`w-4 h-4 ${availability.tidal ? "text-green-500" : "text-red-500"}`}/>
|
||||
<QobuzAvailabilityIcon className={`w-4 h-4 ${availability.qobuz ? "text-green-500" : "text-red-500"}`}/>
|
||||
<AmazonAvailabilityIcon className={`w-4 h-4 ${availability.amazon ? "text-green-500" : "text-red-500"}`}/>
|
||||
</div>) : (<p>Check Availability</p>)}
|
||||
</TooltipContent>
|
||||
</Tooltip>)}
|
||||
{isDownloaded && (<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button onClick={onOpenFolder} variant="outline" size="icon">
|
||||
<FolderOpen className="h-4 w-4"/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Open Folder</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>)}
|
||||
</div>)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Download, CheckCircle, XCircle, FileCheck, FileText, Globe, ImageDown } from "lucide-react";
|
||||
import { Download, CheckCircle, XCircle, FileCheck, FileText, Globe, ImageDown, Play, Pause } from "lucide-react";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
|
||||
import { Pagination, PaginationContent, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, } from "@/components/ui/pagination";
|
||||
import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, } from "@/components/ui/pagination";
|
||||
import type { TrackMetadata, TrackAvailability } from "@/types/api";
|
||||
import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons";
|
||||
import { TidalAvailabilityIcon, QobuzAvailabilityIcon, AmazonAvailabilityIcon } from "./PlatformIcons";
|
||||
import { usePreview } from "@/hooks/usePreview";
|
||||
interface TrackListProps {
|
||||
tracks: TrackMetadata[];
|
||||
searchQuery: string;
|
||||
@@ -32,11 +33,11 @@ interface TrackListProps {
|
||||
failedCovers?: Set<string>;
|
||||
skippedCovers?: Set<string>;
|
||||
downloadingCoverTrack?: string | null;
|
||||
onToggleTrack: (isrc: string) => void;
|
||||
onToggleTrack: (id: string) => void;
|
||||
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
|
||||
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => void;
|
||||
onDownloadTrack: (id: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => void;
|
||||
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
|
||||
onCheckAvailability?: (spotifyId: string, isrc?: string) => void;
|
||||
onCheckAvailability?: (spotifyId: string) => void;
|
||||
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
|
||||
onPageChange: (page: number) => void;
|
||||
onAlbumClick?: (album: {
|
||||
@@ -52,6 +53,7 @@ interface TrackListProps {
|
||||
onTrackClick?: (track: TrackMetadata) => void;
|
||||
}
|
||||
export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, currentPage, itemsPerPage, showCheckboxes = false, hideAlbumColumn = false, folderName, isArtistDiscography = false, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onCheckAvailability, onDownloadCover, onPageChange, onAlbumClick, onArtistClick, onTrackClick, }: TrackListProps) {
|
||||
const { playPreview, loadingPreview, playingTrack } = usePreview();
|
||||
let filteredTracks = tracks.filter((track) => {
|
||||
if (!searchQuery)
|
||||
return true;
|
||||
@@ -102,25 +104,61 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa
|
||||
}
|
||||
else if (sortBy === "downloaded") {
|
||||
filteredTracks = [...filteredTracks].sort((a, b) => {
|
||||
const aDownloaded = downloadedTracks.has(a.isrc);
|
||||
const bDownloaded = downloadedTracks.has(b.isrc);
|
||||
const aDownloaded = a.spotify_id ? downloadedTracks.has(a.spotify_id) : false;
|
||||
const bDownloaded = b.spotify_id ? downloadedTracks.has(b.spotify_id) : false;
|
||||
return (bDownloaded ? 1 : 0) - (aDownloaded ? 1 : 0);
|
||||
});
|
||||
}
|
||||
else if (sortBy === "not-downloaded") {
|
||||
filteredTracks = [...filteredTracks].sort((a, b) => {
|
||||
const aDownloaded = downloadedTracks.has(a.isrc);
|
||||
const bDownloaded = downloadedTracks.has(b.isrc);
|
||||
const aDownloaded = a.spotify_id ? downloadedTracks.has(a.spotify_id) : false;
|
||||
const bDownloaded = b.spotify_id ? downloadedTracks.has(b.spotify_id) : false;
|
||||
return (aDownloaded ? 1 : 0) - (bDownloaded ? 1 : 0);
|
||||
});
|
||||
}
|
||||
else if (sortBy === "failed") {
|
||||
filteredTracks = [...filteredTracks].sort((a, b) => {
|
||||
const aFailed = a.spotify_id ? failedTracks.has(a.spotify_id) : false;
|
||||
const bFailed = b.spotify_id ? failedTracks.has(b.spotify_id) : false;
|
||||
return (bFailed ? 1 : 0) - (aFailed ? 1 : 0);
|
||||
});
|
||||
}
|
||||
const totalPages = Math.ceil(filteredTracks.length / itemsPerPage);
|
||||
const startIndex = (currentPage - 1) * itemsPerPage;
|
||||
const endIndex = startIndex + itemsPerPage;
|
||||
const paginatedTracks = filteredTracks.slice(startIndex, endIndex);
|
||||
const tracksWithIsrc = filteredTracks.filter((track) => track.isrc);
|
||||
const allSelected = tracksWithIsrc.length > 0 &&
|
||||
tracksWithIsrc.every((track) => selectedTracks.includes(track.isrc));
|
||||
const getPaginationPages = (current: number, total: number): (number | 'ellipsis')[] => {
|
||||
if (total <= 10) {
|
||||
return Array.from({ length: total }, (_, i) => i + 1);
|
||||
}
|
||||
const pages: (number | 'ellipsis')[] = [];
|
||||
pages.push(1);
|
||||
if (current <= 7) {
|
||||
for (let i = 2; i <= 10; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
pages.push('ellipsis');
|
||||
pages.push(total);
|
||||
}
|
||||
else if (current >= total - 7) {
|
||||
pages.push('ellipsis');
|
||||
for (let i = total - 9; i <= total; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
}
|
||||
else {
|
||||
pages.push('ellipsis');
|
||||
pages.push(current - 1);
|
||||
pages.push(current);
|
||||
pages.push(current + 1);
|
||||
pages.push('ellipsis');
|
||||
pages.push(total);
|
||||
}
|
||||
return pages;
|
||||
};
|
||||
const tracksWithId = filteredTracks.filter((track) => track.spotify_id);
|
||||
const allSelected = tracksWithId.length > 0 &&
|
||||
tracksWithId.every((track) => selectedTracks.includes(track.spotify_id!));
|
||||
const formatDuration = (ms: number) => {
|
||||
const minutes = Math.floor(ms / 60000);
|
||||
const seconds = Math.floor((ms % 60000) / 1000);
|
||||
@@ -135,192 +173,206 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa
|
||||
return num.toLocaleString();
|
||||
};
|
||||
return (<div className="space-y-4">
|
||||
<div className="rounded-md border">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
{showCheckboxes && (<th className="h-12 px-4 text-left align-middle w-12">
|
||||
<Checkbox checked={allSelected} onCheckedChange={() => onToggleSelectAll(filteredTracks)}/>
|
||||
</th>)}
|
||||
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground w-12">
|
||||
#
|
||||
</th>
|
||||
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground">
|
||||
Title
|
||||
</th>
|
||||
{!hideAlbumColumn && (<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground hidden md:table-cell">
|
||||
Album
|
||||
</th>)}
|
||||
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground hidden lg:table-cell w-24">
|
||||
Duration
|
||||
</th>
|
||||
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground hidden xl:table-cell w-32">
|
||||
Plays
|
||||
</th>
|
||||
<th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground w-32">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paginatedTracks.map((track, index) => (<tr key={index} className="border-b transition-colors hover:bg-muted/50">
|
||||
{showCheckboxes && (<td className="p-4 align-middle">
|
||||
{track.isrc && (<Checkbox checked={selectedTracks.includes(track.isrc)} onCheckedChange={() => onToggleTrack(track.isrc)}/>)}
|
||||
</td>)}
|
||||
<td className="p-4 align-middle text-sm text-muted-foreground">
|
||||
<div className="flex flex-col items-center gap-0.5">
|
||||
<span>{startIndex + index + 1}</span>
|
||||
{track.status && (track.status === "UP" || track.status === "DOWN" || track.status === "NEW") && (<span className={`text-xs ${track.status === "UP"
|
||||
<div className="rounded-md border">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
{showCheckboxes && (<th className="h-12 px-4 text-left align-middle w-12">
|
||||
<Checkbox checked={allSelected} onCheckedChange={() => onToggleSelectAll(filteredTracks)}/>
|
||||
</th>)}
|
||||
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground w-12">
|
||||
#
|
||||
</th>
|
||||
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground">
|
||||
Title
|
||||
</th>
|
||||
{!hideAlbumColumn && (<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground hidden md:table-cell">
|
||||
Album
|
||||
</th>)}
|
||||
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground hidden lg:table-cell w-24">
|
||||
Duration
|
||||
</th>
|
||||
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground hidden xl:table-cell w-32">
|
||||
Plays
|
||||
</th>
|
||||
<th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground w-32">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paginatedTracks.map((track, index) => (<tr key={index} className="border-b transition-colors hover:bg-muted/50">
|
||||
{showCheckboxes && (<td className="p-4 align-middle">
|
||||
{track.spotify_id && (<Checkbox checked={selectedTracks.includes(track.spotify_id)} onCheckedChange={() => onToggleTrack(track.spotify_id!)}/>)}
|
||||
</td>)}
|
||||
<td className="p-4 align-middle text-sm text-muted-foreground">
|
||||
<div className="flex flex-col items-center gap-0.5">
|
||||
<span>{startIndex + index + 1}</span>
|
||||
{track.status && (track.status === "UP" || track.status === "DOWN" || track.status === "NEW") && (<span className={`text-xs ${track.status === "UP"
|
||||
? "text-green-500"
|
||||
: track.status === "DOWN"
|
||||
? "text-red-500"
|
||||
: track.status === "NEW"
|
||||
? "text-blue-500"
|
||||
: ""}`}>
|
||||
{track.status === "NEW" ? "●" : track.status === "UP" ? "▲" : "▼"}
|
||||
</span>)}
|
||||
{track.status === "NEW" ? "●" : track.status === "UP" ? "▲" : "▼"}
|
||||
</span>)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-4 align-middle">
|
||||
<div className="flex items-center gap-3">
|
||||
{track.images && (<img src={track.images} alt={track.name} className="w-10 h-10 rounded object-cover"/>)}
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center gap-2">
|
||||
{onTrackClick ? (<span className="font-medium cursor-pointer hover:underline" onClick={() => onTrackClick(track)}>
|
||||
{track.name}
|
||||
</span>) : (<span className="font-medium">{track.name}</span>)}
|
||||
{track.is_explicit && (<span className="inline-flex items-center justify-center bg-red-600 text-white text-[10px] h-4 w-4 rounded shrink-0" title="Explicit">E</span>)}
|
||||
|
||||
{track.spotify_id && skippedTracks.has(track.spotify_id) ? (<FileCheck className="h-4 w-4 text-yellow-500 shrink-0"/>) : track.spotify_id && downloadedTracks.has(track.spotify_id) ? (<CheckCircle className="h-4 w-4 text-green-500 shrink-0"/>) : track.spotify_id && failedTracks.has(track.spotify_id) ? (<XCircle className="h-4 w-4 text-red-500 shrink-0"/>) : null}
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-4 align-middle">
|
||||
<div className="flex items-center gap-3">
|
||||
{track.images && (<img src={track.images} alt={track.name} className="w-10 h-10 rounded object-cover"/>)}
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center gap-2">
|
||||
{onTrackClick ? (<span className="font-medium cursor-pointer hover:underline" onClick={() => onTrackClick(track)}>
|
||||
{track.name}
|
||||
</span>) : (<span className="font-medium">{track.name}</span>)}
|
||||
{skippedTracks.has(track.isrc) ? (<FileCheck className="h-4 w-4 text-yellow-500 shrink-0"/>) : downloadedTracks.has(track.isrc) ? (<CheckCircle className="h-4 w-4 text-green-500 shrink-0"/>) : failedTracks.has(track.isrc) ? (<XCircle className="h-4 w-4 text-red-500 shrink-0"/>) : null}
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{track.artists_data && track.artists_data.length > 0 ? ((() => {
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{track.artists_data && track.artists_data.length > 0 ? ((() => {
|
||||
const artistNames = track.artists.split(", ").map(name => name.trim());
|
||||
return artistNames.map((name, i) => {
|
||||
const artistData = track.artists_data![i];
|
||||
const hasArtistData = artistData && artistData.id && artistData.external_urls;
|
||||
return (<span key={artistData?.id || i}>
|
||||
{onArtistClick && hasArtistData ? (<span className="cursor-pointer hover:underline" onClick={() => onArtistClick({
|
||||
{onArtistClick && hasArtistData ? (<span className="cursor-pointer hover:underline" onClick={() => onArtistClick({
|
||||
id: artistData.id,
|
||||
name: name,
|
||||
external_urls: artistData.external_urls,
|
||||
})}>
|
||||
{name}
|
||||
</span>) : (name)}
|
||||
{i < artistNames.length - 1 && ", "}
|
||||
</span>);
|
||||
{name}
|
||||
</span>) : (name)}
|
||||
{i < artistNames.length - 1 && ", "}
|
||||
</span>);
|
||||
});
|
||||
})()) : onArtistClick && track.artist_id && track.artist_url ? (<span className="cursor-pointer hover:underline" onClick={() => onArtistClick({
|
||||
id: track.artist_id!,
|
||||
name: track.artists,
|
||||
external_urls: track.artist_url!,
|
||||
})}>
|
||||
{track.artists}
|
||||
</span>) : (track.artists)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
{!hideAlbumColumn && (<td className="p-4 align-middle text-sm text-muted-foreground hidden md:table-cell">
|
||||
{onAlbumClick && track.album_id && track.album_url ? (<span className="cursor-pointer hover:underline" onClick={() => onAlbumClick({
|
||||
{track.artists}
|
||||
</span>) : (track.artists)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
{!hideAlbumColumn && (<td className="p-4 align-middle text-sm text-muted-foreground hidden md:table-cell">
|
||||
{onAlbumClick && track.album_id && track.album_url ? (<span className="cursor-pointer hover:underline" onClick={() => onAlbumClick({
|
||||
id: track.album_id!,
|
||||
name: track.album_name,
|
||||
external_urls: track.album_url!,
|
||||
})}>
|
||||
{track.album_name}
|
||||
</span>) : (track.album_name)}
|
||||
</td>)}
|
||||
<td className="p-4 align-middle text-sm text-muted-foreground hidden lg:table-cell">
|
||||
{formatDuration(track.duration_ms)}
|
||||
</td>
|
||||
<td className="p-4 align-middle text-sm text-muted-foreground hidden xl:table-cell">
|
||||
{track.plays ? formatPlays(track.plays) : ""}
|
||||
</td>
|
||||
<td className="p-4 align-middle text-center">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
{track.isrc && (<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button onClick={() => onDownloadTrack(track.isrc, track.name, track.artists, track.album_name, track.spotify_id, folderName, track.duration_ms, startIndex + index + 1, track.album_artist, track.release_date, track.images, track.track_number, track.disc_number, track.total_tracks, track.total_discs, track.copyright, track.publisher)} size="sm" disabled={isDownloading || downloadingTrack === track.isrc}>
|
||||
{downloadingTrack === track.isrc ? (<Spinner />) : skippedTracks.has(track.isrc) ? (<FileCheck className="h-4 w-4"/>) : downloadedTracks.has(track.isrc) ? (<CheckCircle className="h-4 w-4"/>) : failedTracks.has(track.isrc) ? (<XCircle className="h-4 w-4"/>) : (<Download className="h-4 w-4"/>)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{downloadingTrack === track.isrc ? (<p>Downloading...</p>) : skippedTracks.has(track.isrc) ? (<p>Already exists</p>) : downloadedTracks.has(track.isrc) ? (<p>Downloaded</p>) : failedTracks.has(track.isrc) ? (<p>Failed</p>) : (<p>Download Track</p>)}
|
||||
</TooltipContent>
|
||||
</Tooltip>)}
|
||||
{track.spotify_id && onDownloadLyrics && (<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button onClick={() => onDownloadLyrics(track.spotify_id!, track.name, track.artists, track.album_name, folderName, isArtistDiscography, startIndex + index + 1, track.album_artist, track.release_date, track.disc_number)} size="sm" variant="outline" disabled={downloadingLyricsTrack === track.spotify_id}>
|
||||
{downloadingLyricsTrack === track.spotify_id ? (<Spinner />) : skippedLyrics?.has(track.spotify_id) ? (<FileCheck className="h-4 w-4 text-yellow-500"/>) : downloadedLyrics?.has(track.spotify_id) ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : failedLyrics?.has(track.spotify_id) ? (<XCircle className="h-4 w-4 text-red-500"/>) : (<FileText className="h-4 w-4"/>)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Download Lyric</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>)}
|
||||
{track.images && onDownloadCover && (<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button onClick={() => {
|
||||
{track.album_name}
|
||||
</span>) : (track.album_name)}
|
||||
</td>)}
|
||||
<td className="p-4 align-middle text-sm text-muted-foreground hidden lg:table-cell">
|
||||
{formatDuration(track.duration_ms)}
|
||||
</td>
|
||||
<td className="p-4 align-middle text-sm text-muted-foreground hidden xl:table-cell">
|
||||
{track.plays ? formatPlays(track.plays) : ""}
|
||||
</td>
|
||||
<td className="p-4 align-middle text-center">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
{track.spotify_id && (<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button onClick={() => onDownloadTrack(track.spotify_id!, track.name, track.artists, track.album_name, track.spotify_id, folderName, track.duration_ms, startIndex + index + 1, track.album_artist, track.release_date, track.images, track.track_number, track.disc_number, track.total_tracks, track.total_discs, track.copyright, track.publisher)} size="icon" disabled={isDownloading || downloadingTrack === track.spotify_id}>
|
||||
{downloadingTrack === track.spotify_id ? (<Spinner />) : skippedTracks.has(track.spotify_id) ? (<FileCheck className="h-4 w-4"/>) : downloadedTracks.has(track.spotify_id) ? (<CheckCircle className="h-4 w-4"/>) : failedTracks.has(track.spotify_id) ? (<XCircle className="h-4 w-4"/>) : (<Download className="h-4 w-4"/>)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{downloadingTrack === track.spotify_id ? (<p>Downloading...</p>) : skippedTracks.has(track.spotify_id) ? (<p>Already exists</p>) : downloadedTracks.has(track.spotify_id) ? (<p>Downloaded</p>) : failedTracks.has(track.spotify_id) ? (<p>Failed</p>) : (<p>Download Track</p>)}
|
||||
</TooltipContent>
|
||||
</Tooltip>)}
|
||||
{track.spotify_id && (<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button onClick={() => playPreview(track.spotify_id!, track.name)} size="icon" variant="outline" disabled={loadingPreview === track.spotify_id}>
|
||||
{loadingPreview === track.spotify_id ? (<Spinner />) : playingTrack === track.spotify_id ? (<Pause className="h-4 w-4"/>) : (<Play className="h-4 w-4"/>)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{playingTrack === track.spotify_id ? "Stop Preview" : "Play Preview"}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>)}
|
||||
{track.spotify_id && onDownloadLyrics && (<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button onClick={() => onDownloadLyrics(track.spotify_id!, track.name, track.artists, track.album_name, folderName, isArtistDiscography, startIndex + index + 1, track.album_artist, track.release_date, track.disc_number)} size="icon" variant="outline" disabled={downloadingLyricsTrack === track.spotify_id}>
|
||||
{downloadingLyricsTrack === track.spotify_id ? (<Spinner />) : skippedLyrics?.has(track.spotify_id) ? (<FileCheck className="h-4 w-4 text-yellow-500"/>) : downloadedLyrics?.has(track.spotify_id) ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : failedLyrics?.has(track.spotify_id) ? (<XCircle className="h-4 w-4 text-red-500"/>) : (<FileText className="h-4 w-4"/>)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Download Separate Lyric</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>)}
|
||||
{track.images && onDownloadCover && (<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button onClick={() => {
|
||||
const trackId = track.spotify_id || `${track.name}-${track.artists}`;
|
||||
onDownloadCover(track.images, track.name, track.artists, track.album_name, folderName, isArtistDiscography, startIndex + index + 1, trackId, track.album_artist, track.release_date, track.disc_number);
|
||||
}} size="sm" variant="outline" disabled={downloadingCoverTrack === (track.spotify_id || `${track.name}-${track.artists}`)}>
|
||||
{downloadingCoverTrack === (track.spotify_id || `${track.name}-${track.artists}`) ? (<Spinner />) : skippedCovers?.has(track.spotify_id || `${track.name}-${track.artists}`) ? (<FileCheck className="h-4 w-4 text-yellow-500"/>) : downloadedCovers?.has(track.spotify_id || `${track.name}-${track.artists}`) ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : failedCovers?.has(track.spotify_id || `${track.name}-${track.artists}`) ? (<XCircle className="h-4 w-4 text-red-500"/>) : (<ImageDown className="h-4 w-4"/>)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Download Cover</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>)}
|
||||
{track.spotify_id && onCheckAvailability && (<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button onClick={() => onCheckAvailability(track.spotify_id!, track.isrc)} size="sm" variant="outline" disabled={checkingAvailabilityTrack === track.spotify_id}>
|
||||
{checkingAvailabilityTrack === track.spotify_id ? (<Spinner />) : availabilityMap?.has(track.spotify_id) ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : (<Globe className="h-4 w-4"/>)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{availabilityMap?.has(track.spotify_id) ? (<div className="flex items-center gap-2">
|
||||
<TidalIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.tidal ? "text-green-500" : "text-red-500"}`}/>
|
||||
<QobuzIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.qobuz ? "text-green-500" : "text-red-500"}`}/>
|
||||
<AmazonIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.amazon ? "text-green-500" : "text-red-500"}`}/>
|
||||
</div>) : (<p>Check Availability</p>)}
|
||||
</TooltipContent>
|
||||
</Tooltip>)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}} size="icon" variant="outline" disabled={downloadingCoverTrack === (track.spotify_id || `${track.name}-${track.artists}`)}>
|
||||
{downloadingCoverTrack === (track.spotify_id || `${track.name}-${track.artists}`) ? (<Spinner />) : skippedCovers?.has(track.spotify_id || `${track.name}-${track.artists}`) ? (<FileCheck className="h-4 w-4 text-yellow-500"/>) : downloadedCovers?.has(track.spotify_id || `${track.name}-${track.artists}`) ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : failedCovers?.has(track.spotify_id || `${track.name}-${track.artists}`) ? (<XCircle className="h-4 w-4 text-red-500"/>) : (<ImageDown className="h-4 w-4"/>)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Download Separate Cover</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>)}
|
||||
{track.spotify_id && onCheckAvailability && (<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button onClick={() => onCheckAvailability(track.spotify_id!)} size="icon" variant="outline" disabled={checkingAvailabilityTrack === track.spotify_id}>
|
||||
{checkingAvailabilityTrack === track.spotify_id ? (<Spinner />) : availabilityMap?.has(track.spotify_id) ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : (<Globe className="h-4 w-4"/>)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{availabilityMap?.has(track.spotify_id) ? (<div className="flex items-center gap-2">
|
||||
<TidalAvailabilityIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.tidal ? "text-green-500" : "text-red-500"}`}/>
|
||||
<QobuzAvailabilityIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.qobuz ? "text-green-500" : "text-red-500"}`}/>
|
||||
<AmazonAvailabilityIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.amazon ? "text-green-500" : "text-red-500"}`}/>
|
||||
</div>) : (<p>Check Availability</p>)}
|
||||
</TooltipContent>
|
||||
</Tooltip>)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious href="#" onClick={(e) => {
|
||||
{totalPages > 1 && (<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious href="#" onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (currentPage > 1)
|
||||
onPageChange(currentPage - 1);
|
||||
}} className={currentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}/>
|
||||
</PaginationItem>
|
||||
</PaginationItem>
|
||||
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (<PaginationItem key={page}>
|
||||
<PaginationLink href="#" onClick={(e) => {
|
||||
{getPaginationPages(currentPage, totalPages).map((page, index) => (page === 'ellipsis' ? (<PaginationItem key={`ellipsis-${index}`}>
|
||||
<PaginationEllipsis />
|
||||
</PaginationItem>) : (<PaginationItem key={page}>
|
||||
<PaginationLink href="#" onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onPageChange(page);
|
||||
}} isActive={currentPage === page} className="cursor-pointer">
|
||||
{page}
|
||||
</PaginationLink>
|
||||
</PaginationItem>))}
|
||||
{page}
|
||||
</PaginationLink>
|
||||
</PaginationItem>)))}
|
||||
|
||||
<PaginationItem>
|
||||
<PaginationNext href="#" onClick={(e) => {
|
||||
<PaginationItem>
|
||||
<PaginationNext href="#" onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (currentPage < totalPages)
|
||||
onPageChange(currentPage + 1);
|
||||
}} className={currentPage === totalPages
|
||||
? "pointer-events-none opacity-50"
|
||||
: "cursor-pointer"}/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>)}
|
||||
</div>);
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>)}
|
||||
</div>);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
import { motion, useAnimation } from "motion/react";
|
||||
import type { HTMLAttributes } from "react";
|
||||
import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
export interface AudioLinesIconHandle {
|
||||
startAnimation: () => void;
|
||||
stopAnimation: () => void;
|
||||
}
|
||||
interface AudioLinesIconProps extends HTMLAttributes<HTMLDivElement> {
|
||||
size?: number;
|
||||
}
|
||||
const AudioLinesIcon = forwardRef<AudioLinesIconHandle, AudioLinesIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
|
||||
const controls = useAnimation();
|
||||
const isControlledRef = useRef(false);
|
||||
useImperativeHandle(ref, () => {
|
||||
isControlledRef.current = true;
|
||||
return {
|
||||
startAnimation: () => controls.start("animate"),
|
||||
stopAnimation: () => controls.start("normal"),
|
||||
};
|
||||
});
|
||||
const handleMouseEnter = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (isControlledRef.current) {
|
||||
onMouseEnter?.(e);
|
||||
}
|
||||
else {
|
||||
controls.start("animate");
|
||||
}
|
||||
}, [controls, onMouseEnter]);
|
||||
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (isControlledRef.current) {
|
||||
onMouseLeave?.(e);
|
||||
}
|
||||
else {
|
||||
controls.start("normal");
|
||||
}
|
||||
}, [controls, onMouseLeave]);
|
||||
return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
|
||||
<svg fill="none" height={size} stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" viewBox="0 0 24 24" width={size} xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M2 10v3"/>
|
||||
<motion.path animate={controls} d="M6 6v11" variants={{
|
||||
normal: { d: "M6 6v11" },
|
||||
animate: {
|
||||
d: ["M6 6v11", "M6 10v3", "M6 6v11"],
|
||||
transition: {
|
||||
duration: 1.5,
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
},
|
||||
},
|
||||
}}/>
|
||||
<motion.path animate={controls} d="M10 3v18" variants={{
|
||||
normal: { d: "M10 3v18" },
|
||||
animate: {
|
||||
d: ["M10 3v18", "M10 9v5", "M10 3v18"],
|
||||
transition: {
|
||||
duration: 1,
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
},
|
||||
},
|
||||
}}/>
|
||||
<motion.path animate={controls} d="M14 8v7" variants={{
|
||||
normal: { d: "M14 8v7" },
|
||||
animate: {
|
||||
d: ["M14 8v7", "M14 6v11", "M14 8v7"],
|
||||
transition: {
|
||||
duration: 0.8,
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
},
|
||||
},
|
||||
}}/>
|
||||
<motion.path animate={controls} d="M18 5v13" variants={{
|
||||
normal: { d: "M18 5v13" },
|
||||
animate: {
|
||||
d: ["M18 5v13", "M18 7v9", "M18 5v13"],
|
||||
transition: {
|
||||
duration: 1.5,
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
},
|
||||
},
|
||||
}}/>
|
||||
<path d="M22 10v3"/>
|
||||
</svg>
|
||||
</div>);
|
||||
});
|
||||
AudioLinesIcon.displayName = "AudioLinesIcon";
|
||||
export { AudioLinesIcon };
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
import type { Variants } from "motion/react";
|
||||
import { motion, useAnimation } from "motion/react";
|
||||
import type { HTMLAttributes } from "react";
|
||||
import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
export interface BadgeAlertIconHandle {
|
||||
startAnimation: () => void;
|
||||
stopAnimation: () => void;
|
||||
}
|
||||
interface BadgeAlertIconProps extends HTMLAttributes<HTMLDivElement> {
|
||||
size?: number;
|
||||
}
|
||||
const ICON_VARIANTS: Variants = {
|
||||
normal: { scale: 1, rotate: 0 },
|
||||
animate: {
|
||||
scale: [1, 1.1, 1.1, 1.1, 1],
|
||||
rotate: [0, -3, 3, -2, 2, 0],
|
||||
transition: {
|
||||
duration: 0.5,
|
||||
times: [0, 0.2, 0.4, 0.6, 1],
|
||||
ease: "easeInOut",
|
||||
},
|
||||
},
|
||||
};
|
||||
const BadgeAlertIcon = forwardRef<BadgeAlertIconHandle, BadgeAlertIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
|
||||
const controls = useAnimation();
|
||||
const isControlledRef = useRef(false);
|
||||
useImperativeHandle(ref, () => {
|
||||
isControlledRef.current = true;
|
||||
return {
|
||||
startAnimation: () => controls.start("animate"),
|
||||
stopAnimation: () => controls.start("normal"),
|
||||
};
|
||||
});
|
||||
const handleMouseEnter = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (isControlledRef.current) {
|
||||
onMouseEnter?.(e);
|
||||
}
|
||||
else {
|
||||
controls.start("animate");
|
||||
}
|
||||
}, [controls, onMouseEnter]);
|
||||
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (isControlledRef.current) {
|
||||
onMouseLeave?.(e);
|
||||
}
|
||||
else {
|
||||
controls.start("normal");
|
||||
}
|
||||
}, [controls, onMouseLeave]);
|
||||
return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
|
||||
<motion.svg animate={controls} fill="none" height={size} stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" variants={ICON_VARIANTS} viewBox="0 0 24 24" width={size} xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z"/>
|
||||
<line x1="12" x2="12" y1="8" y2="12"/>
|
||||
<line x1="12" x2="12.01" y1="16" y2="16"/>
|
||||
</motion.svg>
|
||||
</div>);
|
||||
});
|
||||
BadgeAlertIcon.displayName = "BadgeAlertIcon";
|
||||
export { BadgeAlertIcon };
|
||||
@@ -1,52 +1,53 @@
|
||||
'use client';
|
||||
import type { Variants } from 'motion/react';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
|
||||
import { motion, useAnimation } from 'motion/react';
|
||||
import { cn } from '@/lib/utils';
|
||||
"use client";
|
||||
import type { Variants } from "motion/react";
|
||||
import { motion, useAnimation } from "motion/react";
|
||||
import type { HTMLAttributes } from "react";
|
||||
import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
export interface BlocksIconHandle {
|
||||
startAnimation: () => void;
|
||||
stopAnimation: () => void;
|
||||
}
|
||||
interface BlocksIconProps extends HTMLAttributes<HTMLDivElement> {
|
||||
size?: number;
|
||||
loop?: boolean;
|
||||
}
|
||||
const VARIANTS: Variants = {
|
||||
normal: { translateX: 0, translateY: 0 },
|
||||
animate: { translateX: -4, translateY: 4 },
|
||||
};
|
||||
const BlocksIcon = forwardRef<BlocksIconHandle, BlocksIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
|
||||
const BlocksIcon = forwardRef<BlocksIconHandle, BlocksIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, loop = false, ...props }, ref) => {
|
||||
const controls = useAnimation();
|
||||
const isControlledRef = useRef(false);
|
||||
useImperativeHandle(ref, () => {
|
||||
isControlledRef.current = true;
|
||||
return {
|
||||
startAnimation: () => controls.start('animate'),
|
||||
stopAnimation: () => controls.start('normal'),
|
||||
startAnimation: () => controls.start("animate"),
|
||||
stopAnimation: () => controls.start("normal"),
|
||||
};
|
||||
});
|
||||
const handleMouseEnter = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!isControlledRef.current) {
|
||||
controls.start('animate');
|
||||
if (isControlledRef.current) {
|
||||
onMouseEnter?.(e);
|
||||
}
|
||||
else {
|
||||
onMouseEnter?.(e);
|
||||
controls.start("animate");
|
||||
}
|
||||
}, [controls, onMouseEnter]);
|
||||
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!isControlledRef.current) {
|
||||
controls.start('normal');
|
||||
}
|
||||
else {
|
||||
if (isControlledRef.current) {
|
||||
onMouseLeave?.(e);
|
||||
}
|
||||
else {
|
||||
controls.start("normal");
|
||||
}
|
||||
}, [controls, onMouseLeave]);
|
||||
return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
return (<div className={cn("flex items-center justify-center", className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
|
||||
<svg fill="none" height={size} stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" viewBox="0 0 24 24" width={size} xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10 21V8a1 1 0 0 0-1-1H4a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-5a1 1 0 0 0-1-1H3"/>
|
||||
<motion.path d="M14 3h7v7h-7z" variants={VARIANTS} animate={controls}/>
|
||||
<motion.path animate={controls} d="M14 3h7v7h-7z" variants={VARIANTS}/>
|
||||
</svg>
|
||||
</div>);
|
||||
});
|
||||
BlocksIcon.displayName = 'BlocksIcon';
|
||||
BlocksIcon.displayName = "BlocksIcon";
|
||||
export { BlocksIcon };
|
||||
@@ -16,9 +16,9 @@ const buttonVariants = cva("inline-flex items-center justify-center gap-2 whites
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
icon: "h-9 w-9 p-0",
|
||||
"icon-sm": "h-8 w-8 p-0",
|
||||
"icon-lg": "h-10 w-10 p-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface CoffeeIconHandle {
|
||||
}
|
||||
interface CoffeeIconProps extends HTMLAttributes<HTMLDivElement> {
|
||||
size?: number;
|
||||
loop?: boolean;
|
||||
}
|
||||
const PATH_VARIANTS: Variants = {
|
||||
normal: {
|
||||
@@ -27,7 +28,7 @@ const PATH_VARIANTS: Variants = {
|
||||
},
|
||||
}),
|
||||
};
|
||||
const CoffeeIcon = forwardRef<CoffeeIconHandle, CoffeeIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
|
||||
const CoffeeIcon = forwardRef<CoffeeIconHandle, CoffeeIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, loop = false, ...props }, ref) => {
|
||||
const controls = useAnimation();
|
||||
const isControlledRef = useRef(false);
|
||||
useImperativeHandle(ref, () => {
|
||||
@@ -55,12 +56,12 @@ const CoffeeIcon = forwardRef<CoffeeIconHandle, CoffeeIconProps>(({ onMouseEnter
|
||||
}, [controls, onMouseLeave]);
|
||||
return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ overflow: 'visible' }}>
|
||||
<motion.path d="M10 2v2" animate={controls} variants={PATH_VARIANTS} custom={0.2}/>
|
||||
<motion.path d="M14 2v2" animate={controls} variants={PATH_VARIANTS} custom={0.4}/>
|
||||
<motion.path d="M6 2v2" animate={controls} variants={PATH_VARIANTS} custom={0}/>
|
||||
<path d="M16 8a1 1 0 0 1 1 1v8a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4V9a1 1 0 0 1 1-1h14a4 4 0 1 1 0 8h-1"/>
|
||||
<motion.path d="M10 2v2" animate={loop ? 'animate' : controls} variants={PATH_VARIANTS} custom={0.2}/>
|
||||
<motion.path d="M14 2v2" animate={loop ? 'animate' : controls} variants={PATH_VARIANTS} custom={0.4}/>
|
||||
<motion.path d="M6 2v2" animate={loop ? 'animate' : controls} variants={PATH_VARIANTS} custom={0}/>
|
||||
<path d="M16 8a1 1 0 0 1 1 1v8a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4V9a1 1 0 0 1 1-1h14a4 4 0 1 1 0 8h-1"/>
|
||||
</svg>
|
||||
</div>);
|
||||
</div>);
|
||||
});
|
||||
CoffeeIcon.displayName = 'CoffeeIcon';
|
||||
export { CoffeeIcon };
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import * as React from "react";
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
function DropdownMenu({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props}/>;
|
||||
}
|
||||
function DropdownMenuPortal({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props}/>);
|
||||
}
|
||||
function DropdownMenuTrigger({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return (<DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props}/>);
|
||||
}
|
||||
function DropdownMenuContent({ className, sideOffset = 4, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content data-slot="dropdown-menu-content" sideOffset={sideOffset} className={cn("z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95", className)} {...props}/>
|
||||
</DropdownMenuPrimitive.Portal>);
|
||||
}
|
||||
function DropdownMenuGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props}/>);
|
||||
}
|
||||
function DropdownMenuItem({ className, inset, variant = "default", ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
variant?: "default" | "destructive";
|
||||
}) {
|
||||
return (<DropdownMenuPrimitive.Item data-slot="dropdown-menu-item" data-inset={inset} data-variant={variant} className={cn("relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!", className)} {...props}/>);
|
||||
}
|
||||
function DropdownMenuCheckboxItem({ className, children, checked, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (<DropdownMenuPrimitive.CheckboxItem data-slot="dropdown-menu-checkbox-item" className={cn("relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className)} checked={checked} {...props}>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4"/>
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>);
|
||||
}
|
||||
function DropdownMenuRadioGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return (<DropdownMenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props}/>);
|
||||
}
|
||||
function DropdownMenuRadioItem({ className, children, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
return (<DropdownMenuPrimitive.RadioItem data-slot="dropdown-menu-radio-item" className={cn("relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className)} {...props}>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current"/>
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>);
|
||||
}
|
||||
function DropdownMenuLabel({ className, inset, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (<DropdownMenuPrimitive.Label data-slot="dropdown-menu-label" data-inset={inset} className={cn("px-2 py-1.5 text-sm font-medium data-[inset]:pl-8", className)} {...props}/>);
|
||||
}
|
||||
function DropdownMenuSeparator({ className, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (<DropdownMenuPrimitive.Separator data-slot="dropdown-menu-separator" className={cn("-mx-1 my-1 h-px bg-border", className)} {...props}/>);
|
||||
}
|
||||
function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (<span data-slot="dropdown-menu-shortcut" className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)} {...props}/>);
|
||||
}
|
||||
function DropdownMenuSub({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props}/>;
|
||||
}
|
||||
function DropdownMenuSubTrigger({ className, inset, children, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (<DropdownMenuPrimitive.SubTrigger data-slot="dropdown-menu-sub-trigger" data-inset={inset} className={cn("flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground", className)} {...props}>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4"/>
|
||||
</DropdownMenuPrimitive.SubTrigger>);
|
||||
}
|
||||
function DropdownMenuSubContent({ className, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (<DropdownMenuPrimitive.SubContent data-slot="dropdown-menu-sub-content" className={cn("z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95", className)} {...props}/>);
|
||||
}
|
||||
export { DropdownMenu, DropdownMenuPortal, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuGroup, DropdownMenuLabel, DropdownMenuItem, DropdownMenuCheckboxItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent, };
|
||||
@@ -1,9 +1,9 @@
|
||||
'use client';
|
||||
import type { Variants } from 'motion/react';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
|
||||
import { motion, useAnimation } from 'motion/react';
|
||||
import { cn } from '@/lib/utils';
|
||||
"use client";
|
||||
import type { Variants } from "motion/react";
|
||||
import { motion, useAnimation } from "motion/react";
|
||||
import type { HTMLAttributes } from "react";
|
||||
import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
export interface GithubIconHandle {
|
||||
startAnimation: () => void;
|
||||
stopAnimation: () => void;
|
||||
@@ -49,8 +49,8 @@ const TAIL_VARIANTS: Variants = {
|
||||
rotate: [0, -15, 15, -10, 10, -5, 5],
|
||||
transition: {
|
||||
duration: 2.5,
|
||||
ease: 'easeInOut',
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut",
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -62,41 +62,41 @@ const GithubIcon = forwardRef<GithubIconHandle, GithubIconProps>(({ onMouseEnter
|
||||
isControlledRef.current = true;
|
||||
return {
|
||||
startAnimation: async () => {
|
||||
bodyControls.start('animate');
|
||||
await tailControls.start('draw');
|
||||
tailControls.start('wag');
|
||||
bodyControls.start("animate");
|
||||
await tailControls.start("draw");
|
||||
tailControls.start("wag");
|
||||
},
|
||||
stopAnimation: () => {
|
||||
bodyControls.start('normal');
|
||||
tailControls.start('normal');
|
||||
bodyControls.start("normal");
|
||||
tailControls.start("normal");
|
||||
},
|
||||
};
|
||||
});
|
||||
const handleMouseEnter = useCallback(async (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!isControlledRef.current) {
|
||||
bodyControls.start('animate');
|
||||
await tailControls.start('draw');
|
||||
tailControls.start('wag');
|
||||
if (isControlledRef.current) {
|
||||
onMouseEnter?.(e);
|
||||
}
|
||||
else {
|
||||
onMouseEnter?.(e);
|
||||
bodyControls.start("animate");
|
||||
await tailControls.start("draw");
|
||||
tailControls.start("wag");
|
||||
}
|
||||
}, [bodyControls, onMouseEnter, tailControls]);
|
||||
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!isControlledRef.current) {
|
||||
bodyControls.start('normal');
|
||||
tailControls.start('normal');
|
||||
if (isControlledRef.current) {
|
||||
onMouseLeave?.(e);
|
||||
}
|
||||
else {
|
||||
onMouseLeave?.(e);
|
||||
bodyControls.start("normal");
|
||||
tailControls.start("normal");
|
||||
}
|
||||
}, [bodyControls, tailControls, onMouseLeave]);
|
||||
return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<motion.path variants={BODY_VARIANTS} initial="normal" animate={bodyControls} d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"/>
|
||||
<motion.path variants={TAIL_VARIANTS} initial="normal" animate={tailControls} d="M9 18c-4.51 2-5-2-7-2"/>
|
||||
<svg fill="none" height={size} stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" viewBox="0 0 24 24" width={size} xmlns="http://www.w3.org/2000/svg">
|
||||
<motion.path animate={bodyControls} d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4" initial="normal" variants={BODY_VARIANTS}/>
|
||||
<motion.path animate={tailControls} d="M9 18c-4.51 2-5-2-7-2" initial="normal" variants={TAIL_VARIANTS}/>
|
||||
</svg>
|
||||
</div>);
|
||||
});
|
||||
GithubIcon.displayName = 'GithubIcon';
|
||||
GithubIcon.displayName = "GithubIcon";
|
||||
export { GithubIcon };
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
import type { Transition, Variants } from "motion/react";
|
||||
import { motion, useAnimation } from "motion/react";
|
||||
import type { HTMLAttributes } from "react";
|
||||
import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
export interface HistoryIconHandle {
|
||||
startAnimation: () => void;
|
||||
stopAnimation: () => void;
|
||||
}
|
||||
interface HistoryIconProps extends HTMLAttributes<HTMLDivElement> {
|
||||
size?: number;
|
||||
}
|
||||
const ARROW_TRANSITION: Transition = {
|
||||
type: "spring",
|
||||
stiffness: 250,
|
||||
damping: 25,
|
||||
};
|
||||
const ARROW_VARIANTS: Variants = {
|
||||
normal: {
|
||||
rotate: "0deg",
|
||||
},
|
||||
animate: {
|
||||
rotate: "-50deg",
|
||||
},
|
||||
};
|
||||
const HAND_TRANSITION: Transition = {
|
||||
duration: 0.6,
|
||||
ease: [0.4, 0, 0.2, 1],
|
||||
};
|
||||
const HAND_VARIANTS: Variants = {
|
||||
normal: {
|
||||
rotate: 0,
|
||||
originX: "0%",
|
||||
originY: "100%",
|
||||
},
|
||||
animate: {
|
||||
rotate: -360,
|
||||
originX: "0%",
|
||||
originY: "100%",
|
||||
},
|
||||
};
|
||||
const MINUTE_HAND_TRANSITION: Transition = {
|
||||
duration: 0.5,
|
||||
ease: "easeInOut",
|
||||
};
|
||||
const MINUTE_HAND_VARIANTS: Variants = {
|
||||
normal: {
|
||||
rotate: 0,
|
||||
originX: "0%",
|
||||
originY: "0%",
|
||||
},
|
||||
animate: {
|
||||
rotate: -45,
|
||||
originX: "0%",
|
||||
originY: "0%",
|
||||
},
|
||||
};
|
||||
const HistoryIcon = forwardRef<HistoryIconHandle, HistoryIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
|
||||
const controls = useAnimation();
|
||||
const isControlledRef = useRef(false);
|
||||
useImperativeHandle(ref, () => {
|
||||
isControlledRef.current = true;
|
||||
return {
|
||||
startAnimation: () => controls.start("animate"),
|
||||
stopAnimation: () => controls.start("normal"),
|
||||
};
|
||||
});
|
||||
const handleMouseEnter = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (isControlledRef.current) {
|
||||
onMouseEnter?.(e);
|
||||
}
|
||||
else {
|
||||
controls.start("animate");
|
||||
}
|
||||
}, [controls, onMouseEnter]);
|
||||
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (isControlledRef.current) {
|
||||
onMouseLeave?.(e);
|
||||
}
|
||||
else {
|
||||
controls.start("normal");
|
||||
}
|
||||
}, [controls, onMouseLeave]);
|
||||
return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
|
||||
<svg fill="none" height={size} stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" viewBox="0 0 24 24" width={size} xmlns="http://www.w3.org/2000/svg">
|
||||
<motion.g animate={controls} transition={ARROW_TRANSITION} variants={ARROW_VARIANTS}>
|
||||
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/>
|
||||
<path d="M3 3v5h5"/>
|
||||
</motion.g>
|
||||
<motion.line animate={controls} initial="normal" transition={HAND_TRANSITION} variants={HAND_VARIANTS} x1="12" x2="12" y1="12" y2="7"/>
|
||||
<motion.line animate={controls} initial="normal" transition={MINUTE_HAND_TRANSITION} variants={MINUTE_HAND_VARIANTS} x1="12" x2="16" y1="12" y2="14"/>
|
||||
</svg>
|
||||
</div>);
|
||||
});
|
||||
HistoryIcon.displayName = "HistoryIcon";
|
||||
export { HistoryIcon };
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
import * as React from "react";
|
||||
import * as MenubarPrimitive from "@radix-ui/react-menubar";
|
||||
import { Check, ChevronRight, Circle } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
const Menubar = React.forwardRef<React.ElementRef<typeof MenubarPrimitive.Root>, React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Root>>(({ className, ...props }, ref) => (<MenubarPrimitive.Root ref={ref} className={cn("flex h-9 items-center space-x-1 rounded-md border bg-background p-1 shadow-sm", className)} {...props}/>));
|
||||
Menubar.displayName = MenubarPrimitive.Root.displayName;
|
||||
const MenubarMenu = MenubarPrimitive.Menu;
|
||||
const MenubarGroup = MenubarPrimitive.Group;
|
||||
const MenubarPortal = MenubarPrimitive.Portal;
|
||||
const MenubarSub = MenubarPrimitive.Sub;
|
||||
const MenubarRadioGroup = MenubarPrimitive.RadioGroup;
|
||||
const MenubarTrigger = React.forwardRef<React.ElementRef<typeof MenubarPrimitive.Trigger>, React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Trigger>>(({ className, ...props }, ref) => (<MenubarPrimitive.Trigger ref={ref} className={cn("flex cursor-default select-none items-center rounded-sm px-3 py-1 text-sm font-medium outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground", className)} {...props}/>));
|
||||
MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName;
|
||||
const MenubarSubTrigger = React.forwardRef<React.ElementRef<typeof MenubarPrimitive.SubTrigger>, React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}>(({ className, inset, children, ...props }, ref) => (<MenubarPrimitive.SubTrigger ref={ref} className={cn("flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground", inset && "pl-8", className)} {...props}>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4"/>
|
||||
</MenubarPrimitive.SubTrigger>));
|
||||
MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName;
|
||||
const MenubarSubContent = React.forwardRef<React.ElementRef<typeof MenubarPrimitive.SubContent>, React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubContent>>(({ className, ...props }, ref) => (<MenubarPrimitive.SubContent ref={ref} className={cn("z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", className)} {...props}/>));
|
||||
MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName;
|
||||
const MenubarContent = React.forwardRef<React.ElementRef<typeof MenubarPrimitive.Content>, React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Content>>(({ className, align = "start", alignOffset = -4, sideOffset = 8, ...props }, ref) => (<MenubarPrimitive.Portal>
|
||||
<MenubarPrimitive.Content ref={ref} align={align} alignOffset={alignOffset} sideOffset={sideOffset} className={cn("z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in slide-in-from-top-1", className)} {...props}/>
|
||||
</MenubarPrimitive.Portal>));
|
||||
MenubarContent.displayName = MenubarPrimitive.Content.displayName;
|
||||
const MenubarItem = React.forwardRef<React.ElementRef<typeof MenubarPrimitive.Item>, React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
}>(({ className, inset, ...props }, ref) => (<MenubarPrimitive.Item ref={ref} className={cn("relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", inset && "pl-8", className)} {...props}/>));
|
||||
MenubarItem.displayName = MenubarPrimitive.Item.displayName;
|
||||
const MenubarCheckboxItem = React.forwardRef<React.ElementRef<typeof MenubarPrimitive.CheckboxItem>, React.ComponentPropsWithoutRef<typeof MenubarPrimitive.CheckboxItem>>(({ className, children, checked, ...props }, ref) => (<MenubarPrimitive.CheckboxItem ref={ref} className={cn("relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", className)} checked={checked} {...props}>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<MenubarPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4"/>
|
||||
</MenubarPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenubarPrimitive.CheckboxItem>));
|
||||
MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName;
|
||||
const MenubarRadioItem = React.forwardRef<React.ElementRef<typeof MenubarPrimitive.RadioItem>, React.ComponentPropsWithoutRef<typeof MenubarPrimitive.RadioItem>>(({ className, children, ...props }, ref) => (<MenubarPrimitive.RadioItem ref={ref} className={cn("relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", className)} {...props}>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<MenubarPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current"/>
|
||||
</MenubarPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenubarPrimitive.RadioItem>));
|
||||
MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName;
|
||||
const MenubarLabel = React.forwardRef<React.ElementRef<typeof MenubarPrimitive.Label>, React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}>(({ className, inset, ...props }, ref) => (<MenubarPrimitive.Label ref={ref} className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)} {...props}/>));
|
||||
MenubarLabel.displayName = MenubarPrimitive.Label.displayName;
|
||||
const MenubarSeparator = React.forwardRef<React.ElementRef<typeof MenubarPrimitive.Separator>, React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Separator>>(({ className, ...props }, ref) => (<MenubarPrimitive.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-muted", className)} {...props}/>));
|
||||
MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName;
|
||||
const MenubarShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (<span className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)} {...props}/>);
|
||||
};
|
||||
MenubarShortcut.displayname = "MenubarShortcut";
|
||||
export { Menubar, MenubarMenu, MenubarTrigger, MenubarContent, MenubarItem, MenubarSeparator, MenubarLabel, MenubarCheckboxItem, MenubarRadioGroup, MenubarRadioItem, MenubarPortal, MenubarSubContent, MenubarSubTrigger, MenubarSub, MenubarGroup, MenubarShortcut, };
|
||||
@@ -0,0 +1,18 @@
|
||||
import * as React from "react";
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
|
||||
import { cn } from "@/lib/utils";
|
||||
const ScrollArea = React.forwardRef<React.ElementRef<typeof ScrollAreaPrimitive.Root>, React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>>(({ className, children, ...props }, ref) => (<ScrollAreaPrimitive.Root ref={ref} className={cn("relative overflow-hidden", className)} {...props}>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>));
|
||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
|
||||
const ScrollBar = React.forwardRef<React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>, React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>>(({ className, orientation = "vertical", ...props }, ref) => (<ScrollAreaPrimitive.ScrollAreaScrollbar ref={ref} orientation={orientation} className={cn("flex touch-none select-none transition-colors", orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent p-[1px]", orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent p-[1px]", className)} {...props}>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border"/>
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>));
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
|
||||
export { ScrollArea, ScrollBar };
|
||||
@@ -0,0 +1,16 @@
|
||||
import * as React from "react";
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||
import { cn } from "@/lib/utils";
|
||||
function Tabs({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (<TabsPrimitive.Root data-slot="tabs" className={cn("flex flex-col gap-2", className)} {...props}/>);
|
||||
}
|
||||
function TabsList({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||
return (<TabsPrimitive.List data-slot="tabs-list" className={cn("bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]", className)} {...props}/>);
|
||||
}
|
||||
function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (<TabsPrimitive.Trigger data-slot="tabs-trigger" className={cn("inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm flex-1", className)} {...props}/>);
|
||||
}
|
||||
function TabsContent({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (<TabsPrimitive.Content data-slot="tabs-content" className={cn("flex-1 outline-none", className)} {...props}/>);
|
||||
}
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
@@ -10,6 +10,7 @@ export interface TerminalIconHandle {
|
||||
}
|
||||
interface TerminalIconProps extends HTMLAttributes<HTMLDivElement> {
|
||||
size?: number;
|
||||
loop?: boolean;
|
||||
}
|
||||
const LINE_VARIANTS: Variants = {
|
||||
normal: { opacity: 1 },
|
||||
@@ -22,7 +23,7 @@ const LINE_VARIANTS: Variants = {
|
||||
},
|
||||
},
|
||||
};
|
||||
const TerminalIcon = forwardRef<TerminalIconHandle, TerminalIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
|
||||
const TerminalIcon = forwardRef<TerminalIconHandle, TerminalIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, loop = false, ...props }, ref) => {
|
||||
const controls = useAnimation();
|
||||
const isControlledRef = useRef(false);
|
||||
useImperativeHandle(ref, () => {
|
||||
@@ -50,10 +51,10 @@ const TerminalIcon = forwardRef<TerminalIconHandle, TerminalIconProps>(({ onMous
|
||||
}, [controls, onMouseLeave]);
|
||||
return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="4 17 10 11 4 5"/>
|
||||
<motion.line x1="12" x2="20" y1="19" y2="19" variants={LINE_VARIANTS} animate={controls} initial="normal"/>
|
||||
<polyline points="4 17 10 11 4 5"/>
|
||||
<motion.line x1="12" x2="20" y1="19" y2="19" variants={LINE_VARIANTS} animate={loop ? 'animate' : controls} initial="normal"/>
|
||||
</svg>
|
||||
</div>);
|
||||
</div>);
|
||||
});
|
||||
TerminalIcon.displayName = 'TerminalIcon';
|
||||
export { TerminalIcon };
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (<textarea data-slot="textarea" className={cn("border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", className)} {...props}/>);
|
||||
}
|
||||
export { Textarea };
|
||||
@@ -1,4 +1,3 @@
|
||||
"use client";
|
||||
import * as React from "react";
|
||||
import * as TogglePrimitive from "@radix-ui/react-toggle";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||