Compare commits
58 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 85b542983e | |||
| ecc6fd961a | |||
| 9260adc2d2 | |||
| cb6dfc1638 | |||
| 5dacd70287 | |||
| b163355c50 | |||
| 58495dd9fd | |||
| 1eb8a5ac0c | |||
| 452cd9e118 | |||
| 1345ac25f4 | |||
| ae8b610462 | |||
| 14297171be | |||
| 6f6c7563a0 | |||
| a52c2bb658 | |||
| 2ce400a5f7 | |||
| b8fd2d1762 | |||
| d2af0d11df | |||
| 57640d85d2 | |||
| d7b0ca8b3c | |||
| 8e6a1196b5 | |||
| c150124273 | |||
| cb2a41d068 | |||
| 820a4a30ab | |||
| c9e49b4b95 | |||
| 0ba9443ef4 | |||
| 7f8c968d6a | |||
| 4fee88329b | |||
| 66c30de2db | |||
| 436feb7f7c | |||
| 7d0fde3acc | |||
| 939883c9cd | |||
| 99f3d59ff1 | |||
| 965f044e0c | |||
| 6a3bd37eb6 | |||
| b85ed89af3 | |||
| 8e78a882a3 | |||
| 237ee777c3 | |||
| b44a9abdd6 | |||
| 6c3fb13b25 | |||
| 9398e496e8 | |||
| a8d6276d00 | |||
| 4d3aac4990 | |||
| 64d7f82e52 | |||
| 910420634c | |||
| 22742f1ddd | |||
| 5c1d6619b5 | |||
| 76669f551e | |||
| ffd4daf031 | |||
| 64b86b65a1 | |||
| 8f10094e40 | |||
| 2fb544d1f8 | |||
| d16eaa324a | |||
| cc3f7640c6 | |||
| 2653586eea | |||
| 0c92385c56 | |||
| 957fb83dbc | |||
| 90f1871488 | |||
| 2d0e5055f8 |
+29
-11
@@ -2,18 +2,13 @@ name: Build Multi-Platform
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- 'v*'
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
GO_VERSION: '1.25.4'
|
GO_VERSION: '1.25.5'
|
||||||
NODE_VERSION: '20'
|
NODE_VERSION: '24'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-windows:
|
build-windows:
|
||||||
@@ -72,9 +67,17 @@ jobs:
|
|||||||
pnpm install
|
pnpm install
|
||||||
pnpm run generate-icon
|
pnpm run generate-icon
|
||||||
|
|
||||||
|
- name: Install UPX
|
||||||
|
run: |
|
||||||
|
choco install upx -y
|
||||||
|
|
||||||
- name: Build application
|
- name: Build application
|
||||||
run: wails build -platform windows/amd64
|
run: wails build -platform windows/amd64
|
||||||
|
|
||||||
|
- name: Compress with UPX
|
||||||
|
run: |
|
||||||
|
upx --best --lzma "build\bin\SpotiFLAC.exe"
|
||||||
|
|
||||||
- name: Prepare artifacts
|
- name: Prepare artifacts
|
||||||
run: |
|
run: |
|
||||||
mkdir -p dist
|
mkdir -p dist
|
||||||
@@ -219,7 +222,7 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libfuse2 imagemagick
|
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libfuse2 imagemagick upx-ucl
|
||||||
|
|
||||||
# Create symlink for webkit2gtk-4.0 -> webkit2gtk-4.1 (Ubuntu 24.04 compatibility)
|
# Create symlink for webkit2gtk-4.0 -> webkit2gtk-4.1 (Ubuntu 24.04 compatibility)
|
||||||
sudo ln -sf /usr/lib/x86_64-linux-gnu/pkgconfig/webkit2gtk-4.1.pc /usr/lib/x86_64-linux-gnu/pkgconfig/webkit2gtk-4.0.pc
|
sudo ln -sf /usr/lib/x86_64-linux-gnu/pkgconfig/webkit2gtk-4.1.pc /usr/lib/x86_64-linux-gnu/pkgconfig/webkit2gtk-4.0.pc
|
||||||
@@ -236,10 +239,25 @@ jobs:
|
|||||||
- name: Build application
|
- name: Build application
|
||||||
run: wails build -platform linux/amd64
|
run: wails build -platform linux/amd64
|
||||||
|
|
||||||
- name: Download appimagetool
|
- name: Compress with UPX
|
||||||
run: |
|
run: |
|
||||||
wget -O appimagetool https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage
|
upx --best --lzma build/bin/SpotiFLAC
|
||||||
chmod +x appimagetool
|
|
||||||
|
- name: Cache appimagetool
|
||||||
|
id: cache-appimagetool
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: appimagetool
|
||||||
|
key: appimagetool-x86_64-v1
|
||||||
|
|
||||||
|
- name: Download appimagetool
|
||||||
|
if: steps.cache-appimagetool.outputs.cache-hit != 'true'
|
||||||
|
run: |
|
||||||
|
wget --timeout=30 --tries=5 --retry-connrefused --waitretry=5 -O appimagetool https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage || \
|
||||||
|
wget --timeout=30 --tries=5 --retry-connrefused --waitretry=5 -O appimagetool https://github.com/AppImage/AppImageKit/releases/download/13/appimagetool-x86_64.AppImage
|
||||||
|
|
||||||
|
- name: Make appimagetool executable
|
||||||
|
run: chmod +x appimagetool
|
||||||
|
|
||||||
- name: Create AppImage
|
- name: Create AppImage
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
+4
-1
@@ -56,6 +56,9 @@ temp/
|
|||||||
*.bak
|
*.bak
|
||||||
*.old
|
*.old
|
||||||
|
|
||||||
|
# Test files
|
||||||
|
test
|
||||||
|
|
||||||
# Build notes (optional - uncomment if you don't want to commit)
|
# Build notes (optional - uncomment if you don't want to commit)
|
||||||
# BUILD_NOTES.md
|
# BUILD_NOTES.md
|
||||||
build.txt
|
push.bat
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 afkarxyz
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -1,37 +1,55 @@
|
|||||||
[](https://github.com/afkarxyz/SpotiFLAC/releases)
|
[](https://github.com/afkarxyz/SpotiFLAC/releases)
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
Get Spotify tracks in true FLAC from Tidal, Deezer, Qobuz & Amazon Music — no account required.
|
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>
|
</div>
|
||||||
|
|
||||||
### [Download](https://github.com/afkarxyz/SpotiFLAC/releases)
|
### [Download](https://github.com/afkarxyz/SpotiFLAC/releases)
|
||||||
|
|
||||||
## Screenshot
|
## Screenshot
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Lossless Audio Checker
|
|
||||||
|
|
||||||
A simple utility for verifying the authenticity of FLAC files.
|
|
||||||
|
|
||||||
#### [Download](https://github.com/afkarxyz/SpotiFLAC/releases/download/v0/FLAC-Checker.zip) - Windows only
|
|
||||||
|
|
||||||
#
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
## Other projects
|
## Other projects
|
||||||
|
|
||||||
### [SpotiDownloader](https://github.com/afkarxyz/SpotiDownloader)
|
### [SpotiDownloader](https://github.com/afkarxyz/SpotiDownloader)
|
||||||
|
Get Spotify tracks in MP3 and FLAC via the spotidownloader.com API.
|
||||||
|
|
||||||
Get Spotify tracks in MP3 and FLAC via the spotidownloader.com API
|
### [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)
|
||||||
|
|
||||||
|
> Every coffee helps me keep going
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
>
|
||||||
|
> **Star Us**, You will receive all release notifications from GitHub without any delay ~
|
||||||
|
|||||||
+116
-45
@@ -10,6 +10,7 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -62,16 +63,14 @@ func (a *AmazonDownloader) getRandomUserAgent() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (string, error) {
|
func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (string, error) {
|
||||||
// Rate limiting: max 10 requests per minute (song.link API limit)
|
|
||||||
// Reset counter every minute
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
if now.Sub(a.apiCallResetTime) >= time.Minute {
|
if now.Sub(a.apiCallResetTime) >= time.Minute {
|
||||||
a.apiCallCount = 0
|
a.apiCallCount = 0
|
||||||
a.apiCallResetTime = now
|
a.apiCallResetTime = now
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we've hit the limit, wait until the next minute
|
if a.apiCallCount >= 9 {
|
||||||
if a.apiCallCount >= 9 { // Use 9 to be safe (limit is 10)
|
|
||||||
waitTime := time.Minute - now.Sub(a.apiCallResetTime)
|
waitTime := time.Minute - now.Sub(a.apiCallResetTime)
|
||||||
if waitTime > 0 {
|
if waitTime > 0 {
|
||||||
fmt.Printf("Rate limit reached, waiting %v...\n", waitTime.Round(time.Second))
|
fmt.Printf("Rate limit reached, waiting %v...\n", waitTime.Round(time.Second))
|
||||||
@@ -81,10 +80,9 @@ func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (strin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add delay between requests (6 seconds = 10 requests per minute)
|
|
||||||
if !a.lastAPICallTime.IsZero() {
|
if !a.lastAPICallTime.IsZero() {
|
||||||
timeSinceLastCall := now.Sub(a.lastAPICallTime)
|
timeSinceLastCall := now.Sub(a.lastAPICallTime)
|
||||||
minDelay := 7 * time.Second // 7 seconds to be safe
|
minDelay := 7 * time.Second
|
||||||
if timeSinceLastCall < minDelay {
|
if timeSinceLastCall < minDelay {
|
||||||
waitTime := minDelay - timeSinceLastCall
|
waitTime := minDelay - timeSinceLastCall
|
||||||
fmt.Printf("Rate limiting: waiting %v...\n", waitTime.Round(time.Second))
|
fmt.Printf("Rate limiting: waiting %v...\n", waitTime.Round(time.Second))
|
||||||
@@ -92,7 +90,6 @@ func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (strin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decode base64 API URL
|
|
||||||
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
|
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
|
||||||
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID)
|
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID)
|
||||||
|
|
||||||
@@ -108,7 +105,6 @@ func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (strin
|
|||||||
|
|
||||||
fmt.Println("Getting Amazon URL...")
|
fmt.Println("Getting Amazon URL...")
|
||||||
|
|
||||||
// Retry logic for rate limit errors
|
|
||||||
maxRetries := 3
|
maxRetries := 3
|
||||||
var resp *http.Response
|
var resp *http.Response
|
||||||
for i := 0; i < maxRetries; i++ {
|
for i := 0; i < maxRetries; i++ {
|
||||||
@@ -117,11 +113,10 @@ func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (strin
|
|||||||
return "", fmt.Errorf("failed to get Amazon URL: %w", err)
|
return "", fmt.Errorf("failed to get Amazon URL: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update rate limit tracking
|
|
||||||
a.lastAPICallTime = time.Now()
|
a.lastAPICallTime = time.Now()
|
||||||
a.apiCallCount++
|
a.apiCallCount++
|
||||||
|
|
||||||
if resp.StatusCode == 429 { // Too Many Requests
|
if resp.StatusCode == 429 {
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
if i < maxRetries-1 {
|
if i < maxRetries-1 {
|
||||||
waitTime := 15 * time.Second
|
waitTime := 15 * time.Second
|
||||||
@@ -141,9 +136,23 @@ func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (strin
|
|||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to read response body: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(body) == 0 {
|
||||||
|
return "", fmt.Errorf("API returned empty response")
|
||||||
|
}
|
||||||
|
|
||||||
var songLinkResp SongLinkResponse
|
var songLinkResp SongLinkResponse
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&songLinkResp); err != nil {
|
if err := json.Unmarshal(body, &songLinkResp); err != nil {
|
||||||
return "", fmt.Errorf("failed to decode response: %w", err)
|
|
||||||
|
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"]
|
amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]
|
||||||
@@ -153,7 +162,6 @@ func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (strin
|
|||||||
|
|
||||||
amazonURL := amazonLink.URL
|
amazonURL := amazonLink.URL
|
||||||
|
|
||||||
// Convert album URL to track URL if needed
|
|
||||||
if strings.Contains(amazonURL, "trackAsin=") {
|
if strings.Contains(amazonURL, "trackAsin=") {
|
||||||
parts := strings.Split(amazonURL, "trackAsin=")
|
parts := strings.Split(amazonURL, "trackAsin=")
|
||||||
if len(parts) > 1 {
|
if len(parts) > 1 {
|
||||||
@@ -172,12 +180,11 @@ func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir string) (str
|
|||||||
|
|
||||||
for _, region := range a.regions {
|
for _, region := range a.regions {
|
||||||
fmt.Printf("\nTrying region: %s...\n", region)
|
fmt.Printf("\nTrying region: %s...\n", region)
|
||||||
// Decode base64 service URL
|
|
||||||
serviceBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly8=")
|
serviceBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly8=")
|
||||||
serviceDomain, _ := base64.StdEncoding.DecodeString("LmRvdWJsZWRvdWJsZS50b3A=")
|
serviceDomain, _ := base64.StdEncoding.DecodeString("LmRvdWJsZWRvdWJsZS50b3A=")
|
||||||
baseURL := fmt.Sprintf("%s%s%s", string(serviceBase), region, string(serviceDomain))
|
baseURL := fmt.Sprintf("%s%s%s", string(serviceBase), region, string(serviceDomain))
|
||||||
|
|
||||||
// Step 1: Submit download request
|
|
||||||
encodedURL := url.QueryEscape(amazonURL)
|
encodedURL := url.QueryEscape(amazonURL)
|
||||||
submitURL := fmt.Sprintf("%s/dl?url=%s", baseURL, encodedURL)
|
submitURL := fmt.Sprintf("%s/dl?url=%s", baseURL, encodedURL)
|
||||||
|
|
||||||
@@ -218,7 +225,6 @@ func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir string) (str
|
|||||||
downloadID := submitResp.ID
|
downloadID := submitResp.ID
|
||||||
fmt.Printf("Download ID: %s\n", downloadID)
|
fmt.Printf("Download ID: %s\n", downloadID)
|
||||||
|
|
||||||
// Step 2: Poll for completion
|
|
||||||
statusURL := fmt.Sprintf("%s/dl/%s", baseURL, downloadID)
|
statusURL := fmt.Sprintf("%s/dl/%s", baseURL, downloadID)
|
||||||
fmt.Println("Waiting for download to complete...")
|
fmt.Println("Waiting for download to complete...")
|
||||||
|
|
||||||
@@ -260,7 +266,6 @@ func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir string) (str
|
|||||||
if status.Status == "done" {
|
if status.Status == "done" {
|
||||||
fmt.Println("\nDownload ready!")
|
fmt.Println("\nDownload ready!")
|
||||||
|
|
||||||
// Build download URL
|
|
||||||
fileURL := status.URL
|
fileURL := status.URL
|
||||||
if strings.HasPrefix(fileURL, "./") {
|
if strings.HasPrefix(fileURL, "./") {
|
||||||
fileURL = fmt.Sprintf("%s/%s", baseURL, fileURL[2:])
|
fileURL = fmt.Sprintf("%s/%s", baseURL, fileURL[2:])
|
||||||
@@ -273,7 +278,6 @@ func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir string) (str
|
|||||||
|
|
||||||
fmt.Printf("Downloading: %s - %s\n", artist, trackName)
|
fmt.Printf("Downloading: %s - %s\n", artist, trackName)
|
||||||
|
|
||||||
// Download file
|
|
||||||
downloadReq, err := http.NewRequest("GET", fileURL, nil)
|
downloadReq, err := http.NewRequest("GET", fileURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
lastError = fmt.Errorf("failed to create download request: %w", err)
|
lastError = fmt.Errorf("failed to create download request: %w", err)
|
||||||
@@ -294,7 +298,6 @@ func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir string) (str
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate filename
|
|
||||||
fileName := fmt.Sprintf("%s - %s.flac", artist, trackName)
|
fileName := fmt.Sprintf("%s - %s.flac", artist, trackName)
|
||||||
for _, char := range `<>:"/\|?*` {
|
for _, char := range `<>:"/\|?*` {
|
||||||
fileName = strings.ReplaceAll(fileName, string(char), "")
|
fileName = strings.ReplaceAll(fileName, string(char), "")
|
||||||
@@ -303,7 +306,6 @@ func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir string) (str
|
|||||||
|
|
||||||
filePath := filepath.Join(outputDir, fileName)
|
filePath := filepath.Join(outputDir, fileName)
|
||||||
|
|
||||||
// Save file
|
|
||||||
out, err := os.Create(filePath)
|
out, err := os.Create(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
lastError = fmt.Errorf("failed to create file: %w", err)
|
lastError = fmt.Errorf("failed to create file: %w", err)
|
||||||
@@ -312,7 +314,7 @@ func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir string) (str
|
|||||||
defer out.Close()
|
defer out.Close()
|
||||||
|
|
||||||
fmt.Println("Downloading...")
|
fmt.Println("Downloading...")
|
||||||
// Use progress writer to track download
|
|
||||||
pw := NewProgressWriter(out)
|
pw := NewProgressWriter(out)
|
||||||
_, err = io.Copy(pw, fileResp.Body)
|
_, err = io.Copy(pw, fileResp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -320,7 +322,6 @@ func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir string) (str
|
|||||||
return "", fmt.Errorf("failed to write file: %w", err)
|
return "", fmt.Errorf("failed to write file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Print final size
|
|
||||||
fmt.Printf("\rDownloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024))
|
fmt.Printf("\rDownloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024))
|
||||||
fmt.Println("Download complete!")
|
fmt.Println("Download complete!")
|
||||||
return filePath, nil
|
return filePath, nil
|
||||||
@@ -333,7 +334,7 @@ func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir string) (str
|
|||||||
lastError = fmt.Errorf("processing failed: %s", errorMsg)
|
lastError = fmt.Errorf("processing failed: %s", errorMsg)
|
||||||
break
|
break
|
||||||
} else {
|
} else {
|
||||||
// Still processing
|
|
||||||
friendlyStatus := status.FriendlyStatus
|
friendlyStatus := status.FriendlyStatus
|
||||||
if friendlyStatus == "" {
|
if friendlyStatus == "" {
|
||||||
friendlyStatus = status.Status
|
friendlyStatus = status.Status
|
||||||
@@ -356,17 +357,16 @@ func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir string) (str
|
|||||||
return "", fmt.Errorf("all regions failed. Last error: %v", lastError)
|
return "", fmt.Errorf("all regions failed. Last error: %v", lastError)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName string, useAlbumTrackNumber bool) (string, error) {
|
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) {
|
||||||
// Create output directory if needed
|
|
||||||
if outputDir != "." {
|
if outputDir != "." {
|
||||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||||
return "", fmt.Errorf("failed to create output directory: %w", err)
|
return "", fmt.Errorf("failed to create output directory: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if file with expected name already exists (Amazon doesn't provide ISRC before download)
|
|
||||||
if spotifyTrackName != "" && spotifyArtistName != "" {
|
if spotifyTrackName != "" && spotifyArtistName != "" {
|
||||||
expectedFilename := BuildExpectedFilename(spotifyTrackName, spotifyArtistName, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
|
expectedFilename := BuildExpectedFilename(spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, filenameFormat, includeTrackNumber, position, spotifyDiscNumber, false)
|
||||||
expectedPath := filepath.Join(outputDir, expectedFilename)
|
expectedPath := filepath.Join(outputDir, expectedFilename)
|
||||||
|
|
||||||
if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 0 {
|
if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 0 {
|
||||||
@@ -377,37 +377,65 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, filenameFormat st
|
|||||||
|
|
||||||
fmt.Printf("Using Amazon URL: %s\n", amazonURL)
|
fmt.Printf("Using Amazon URL: %s\n", amazonURL)
|
||||||
|
|
||||||
// Download from service
|
|
||||||
filePath, err := a.DownloadFromService(amazonURL, outputDir)
|
filePath, err := a.DownloadFromService(amazonURL, outputDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
// File already has embedded metadata, just rename if needed
|
|
||||||
if spotifyTrackName != "" && spotifyArtistName != "" {
|
if spotifyTrackName != "" && spotifyArtistName != "" {
|
||||||
safeArtist := sanitizeFilename(spotifyArtistName)
|
safeArtist := sanitizeFilename(spotifyArtistName)
|
||||||
safeTitle := sanitizeFilename(spotifyTrackName)
|
safeTitle := sanitizeFilename(spotifyTrackName)
|
||||||
|
safeAlbum := sanitizeFilename(spotifyAlbumName)
|
||||||
|
safeAlbumArtist := sanitizeFilename(spotifyAlbumArtist)
|
||||||
|
|
||||||
// Build filename based on format settings
|
year := ""
|
||||||
var newFilename string
|
if len(spotifyReleaseDate) >= 4 {
|
||||||
switch filenameFormat {
|
year = spotifyReleaseDate[:4]
|
||||||
case "artist-title":
|
|
||||||
newFilename = fmt.Sprintf("%s - %s", safeArtist, safeTitle)
|
|
||||||
case "title":
|
|
||||||
newFilename = safeTitle
|
|
||||||
default: // "title-artist"
|
|
||||||
newFilename = fmt.Sprintf("%s - %s", safeTitle, safeArtist)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add track number prefix if enabled
|
var newFilename string
|
||||||
if includeTrackNumber && position > 0 {
|
|
||||||
newFilename = fmt.Sprintf("%02d. %s", position, newFilename)
|
if strings.Contains(filenameFormat, "{") {
|
||||||
|
newFilename = filenameFormat
|
||||||
|
newFilename = strings.ReplaceAll(newFilename, "{title}", safeTitle)
|
||||||
|
newFilename = strings.ReplaceAll(newFilename, "{artist}", safeArtist)
|
||||||
|
newFilename = strings.ReplaceAll(newFilename, "{album}", safeAlbum)
|
||||||
|
newFilename = strings.ReplaceAll(newFilename, "{album_artist}", safeAlbumArtist)
|
||||||
|
newFilename = strings.ReplaceAll(newFilename, "{year}", year)
|
||||||
|
|
||||||
|
if spotifyDiscNumber > 0 {
|
||||||
|
newFilename = strings.ReplaceAll(newFilename, "{disc}", fmt.Sprintf("%d", spotifyDiscNumber))
|
||||||
|
} else {
|
||||||
|
newFilename = strings.ReplaceAll(newFilename, "{disc}", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
if position > 0 {
|
||||||
|
newFilename = strings.ReplaceAll(newFilename, "{track}", fmt.Sprintf("%02d", position))
|
||||||
|
} else {
|
||||||
|
|
||||||
|
newFilename = regexp.MustCompile(`\{track\}\.\s*`).ReplaceAllString(newFilename, "")
|
||||||
|
newFilename = regexp.MustCompile(`\{track\}\s*-\s*`).ReplaceAllString(newFilename, "")
|
||||||
|
newFilename = regexp.MustCompile(`\{track\}\s*`).ReplaceAllString(newFilename, "")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
|
||||||
|
switch filenameFormat {
|
||||||
|
case "artist-title":
|
||||||
|
newFilename = fmt.Sprintf("%s - %s", safeArtist, safeTitle)
|
||||||
|
case "title":
|
||||||
|
newFilename = safeTitle
|
||||||
|
default:
|
||||||
|
newFilename = fmt.Sprintf("%s - %s", safeTitle, safeArtist)
|
||||||
|
}
|
||||||
|
|
||||||
|
if includeTrackNumber && position > 0 {
|
||||||
|
newFilename = fmt.Sprintf("%02d. %s", position, newFilename)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
newFilename = newFilename + ".flac"
|
newFilename = newFilename + ".flac"
|
||||||
newFilePath := filepath.Join(outputDir, newFilename)
|
newFilePath := filepath.Join(outputDir, newFilename)
|
||||||
|
|
||||||
// Rename file
|
|
||||||
if err := os.Rename(filePath, newFilePath); err != nil {
|
if err := os.Rename(filePath, newFilePath); err != nil {
|
||||||
fmt.Printf("Warning: Failed to rename file: %v\n", err)
|
fmt.Printf("Warning: Failed to rename file: %v\n", err)
|
||||||
} else {
|
} else {
|
||||||
@@ -416,17 +444,60 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, filenameFormat st
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fmt.Println("Embedding Spotify metadata...")
|
||||||
|
|
||||||
|
coverPath := ""
|
||||||
|
|
||||||
|
if spotifyCoverURL != "" {
|
||||||
|
coverPath = filePath + ".cover.jpg"
|
||||||
|
coverClient := NewCoverClient()
|
||||||
|
if err := coverClient.DownloadCoverToPath(spotifyCoverURL, coverPath, embedMaxQualityCover); err != nil {
|
||||||
|
fmt.Printf("Warning: Failed to download Spotify cover: %v\n", err)
|
||||||
|
coverPath = ""
|
||||||
|
} else {
|
||||||
|
defer os.Remove(coverPath)
|
||||||
|
fmt.Println("Spotify cover downloaded")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
trackNumberToEmbed := spotifyTrackNumber
|
||||||
|
if trackNumberToEmbed == 0 {
|
||||||
|
trackNumberToEmbed = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata := Metadata{
|
||||||
|
Title: spotifyTrackName,
|
||||||
|
Artist: spotifyArtistName,
|
||||||
|
Album: spotifyAlbumName,
|
||||||
|
AlbumArtist: spotifyAlbumArtist,
|
||||||
|
Date: spotifyReleaseDate,
|
||||||
|
TrackNumber: trackNumberToEmbed,
|
||||||
|
TotalTracks: spotifyTotalTracks,
|
||||||
|
DiscNumber: spotifyDiscNumber,
|
||||||
|
TotalDiscs: spotifyTotalDiscs,
|
||||||
|
URL: spotifyURL,
|
||||||
|
Copyright: spotifyCopyright,
|
||||||
|
Publisher: spotifyPublisher,
|
||||||
|
Description: "https://github.com/afkarxyz/SpotiFLAC",
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := EmbedMetadata(filePath, metadata, coverPath); err != nil {
|
||||||
|
fmt.Printf("Warning: Failed to embed metadata: %v\n", err)
|
||||||
|
} else {
|
||||||
|
fmt.Println("Metadata embedded successfully")
|
||||||
|
}
|
||||||
|
|
||||||
fmt.Println("Done")
|
fmt.Println("Done")
|
||||||
fmt.Println("✓ Downloaded successfully from Amazon Music")
|
fmt.Println("✓ Downloaded successfully from Amazon Music")
|
||||||
return filePath, nil
|
return filePath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *AmazonDownloader) DownloadBySpotifyID(spotifyTrackID, outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName string, useAlbumTrackNumber bool) (string, error) {
|
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) {
|
||||||
// Get Amazon URL from Spotify track ID
|
|
||||||
amazonURL, err := a.GetAmazonURLFromSpotify(spotifyTrackID)
|
amazonURL, err := a.GetAmazonURLFromSpotify(spotifyTrackID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return a.DownloadByURL(amazonURL, outputDir, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useAlbumTrackNumber)
|
return a.DownloadByURL(amazonURL, outputDir, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, embedMaxQualityCover, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyURL)
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-29
@@ -9,9 +9,9 @@ import (
|
|||||||
mewflac "github.com/mewkiz/flac"
|
mewflac "github.com/mewkiz/flac"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AnalysisResult contains the audio analysis data
|
|
||||||
type AnalysisResult struct {
|
type AnalysisResult struct {
|
||||||
FilePath string `json:"file_path"`
|
FilePath string `json:"file_path"`
|
||||||
|
FileSize int64 `json:"file_size"`
|
||||||
SampleRate uint32 `json:"sample_rate"`
|
SampleRate uint32 `json:"sample_rate"`
|
||||||
Channels uint8 `json:"channels"`
|
Channels uint8 `json:"channels"`
|
||||||
BitsPerSample uint8 `json:"bits_per_sample"`
|
BitsPerSample uint8 `json:"bits_per_sample"`
|
||||||
@@ -24,13 +24,16 @@ type AnalysisResult struct {
|
|||||||
Spectrum *SpectrumData `json:"spectrum,omitempty"`
|
Spectrum *SpectrumData `json:"spectrum,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AnalyzeTrack performs audio analysis on a FLAC file
|
|
||||||
func AnalyzeTrack(filepath string) (*AnalysisResult, error) {
|
func AnalyzeTrack(filepath string) (*AnalysisResult, error) {
|
||||||
if !fileExists(filepath) {
|
if !fileExists(filepath) {
|
||||||
return nil, fmt.Errorf("file does not exist: %s", filepath)
|
return nil, fmt.Errorf("file does not exist: %s", filepath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse FLAC file
|
fileInfo, err := os.Stat(filepath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get file info: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
f, err := flac.ParseFile(filepath)
|
f, err := flac.ParseFile(filepath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to parse FLAC file: %w", err)
|
return nil, fmt.Errorf("failed to parse FLAC file: %w", err)
|
||||||
@@ -38,70 +41,58 @@ func AnalyzeTrack(filepath string) (*AnalysisResult, error) {
|
|||||||
|
|
||||||
result := &AnalysisResult{
|
result := &AnalysisResult{
|
||||||
FilePath: filepath,
|
FilePath: filepath,
|
||||||
|
FileSize: fileInfo.Size(),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract basic audio properties from STREAMINFO block
|
|
||||||
if len(f.Meta) > 0 {
|
if len(f.Meta) > 0 {
|
||||||
streamInfo := f.Meta[0]
|
streamInfo := f.Meta[0]
|
||||||
if streamInfo.Type == flac.StreamInfo {
|
if streamInfo.Type == flac.StreamInfo {
|
||||||
// Read STREAMINFO data
|
|
||||||
data := streamInfo.Data
|
data := streamInfo.Data
|
||||||
if len(data) >= 18 {
|
if len(data) >= 18 {
|
||||||
// Sample rate (bits 10-29 of bytes 10-13)
|
|
||||||
result.SampleRate = uint32(data[10])<<12 | uint32(data[11])<<4 | uint32(data[12])>>4
|
result.SampleRate = uint32(data[10])<<12 | uint32(data[11])<<4 | uint32(data[12])>>4
|
||||||
|
|
||||||
// Channels (bits 30-32 of byte 12)
|
|
||||||
result.Channels = ((data[12] >> 1) & 0x07) + 1
|
result.Channels = ((data[12] >> 1) & 0x07) + 1
|
||||||
|
|
||||||
// Bits per sample (bits 33-37 of bytes 12-13)
|
|
||||||
result.BitsPerSample = ((data[12]&0x01)<<4 | data[13]>>4) + 1
|
result.BitsPerSample = ((data[12]&0x01)<<4 | data[13]>>4) + 1
|
||||||
|
|
||||||
// Total samples (bits 38-73 of bytes 13-17)
|
|
||||||
result.TotalSamples = uint64(data[13]&0x0F)<<32 |
|
result.TotalSamples = uint64(data[13]&0x0F)<<32 |
|
||||||
uint64(data[14])<<24 |
|
uint64(data[14])<<24 |
|
||||||
uint64(data[15])<<16 |
|
uint64(data[15])<<16 |
|
||||||
uint64(data[16])<<8 |
|
uint64(data[16])<<8 |
|
||||||
uint64(data[17])
|
uint64(data[17])
|
||||||
|
|
||||||
// Calculate duration
|
|
||||||
if result.SampleRate > 0 {
|
if result.SampleRate > 0 {
|
||||||
result.Duration = float64(result.TotalSamples) / float64(result.SampleRate)
|
result.Duration = float64(result.TotalSamples) / float64(result.SampleRate)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read min/max frame size and block size for additional analysis
|
|
||||||
// Min block size (bytes 0-1)
|
|
||||||
// Max block size (bytes 2-3)
|
|
||||||
// These can give us hints about encoding quality
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Analyze spectrum and calculate real audio metrics
|
|
||||||
spectrum, err := AnalyzeSpectrum(filepath)
|
spectrum, err := AnalyzeSpectrum(filepath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Log error but continue
|
|
||||||
fmt.Printf("Warning: failed to analyze spectrum: %v\n", err)
|
fmt.Printf("Warning: failed to analyze spectrum: %v\n", err)
|
||||||
} else {
|
} else {
|
||||||
result.Spectrum = spectrum
|
result.Spectrum = spectrum
|
||||||
// Calculate dynamic range, peak, and RMS from decoded samples
|
|
||||||
calculateRealAudioMetrics(result, filepath)
|
calculateRealAudioMetrics(result, filepath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set bit depth
|
|
||||||
result.BitDepth = fmt.Sprintf("%d-bit", result.BitsPerSample)
|
result.BitDepth = fmt.Sprintf("%d-bit", result.BitsPerSample)
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// calculateRealAudioMetrics calculates actual dynamic range, peak, and RMS from decoded audio
|
|
||||||
func calculateRealAudioMetrics(result *AnalysisResult, filepath string) {
|
func calculateRealAudioMetrics(result *AnalysisResult, filepath string) {
|
||||||
// Decode FLAC to get actual samples
|
|
||||||
samples, err := decodeFLACForMetrics(filepath)
|
samples, err := decodeFLACForMetrics(filepath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate peak amplitude
|
|
||||||
var peak float64
|
var peak float64
|
||||||
var sumSquares float64
|
var sumSquares float64
|
||||||
|
|
||||||
@@ -116,20 +107,16 @@ func calculateRealAudioMetrics(result *AnalysisResult, filepath string) {
|
|||||||
sumSquares += sample * sample
|
sumSquares += sample * sample
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert peak to dB (reference: 1.0 = 0 dBFS)
|
|
||||||
peakDB := 20.0 * math.Log10(peak)
|
peakDB := 20.0 * math.Log10(peak)
|
||||||
result.PeakAmplitude = peakDB
|
result.PeakAmplitude = peakDB
|
||||||
|
|
||||||
// Calculate RMS (Root Mean Square)
|
|
||||||
rms := math.Sqrt(sumSquares / float64(len(samples)))
|
rms := math.Sqrt(sumSquares / float64(len(samples)))
|
||||||
rmsDB := 20.0 * math.Log10(rms)
|
rmsDB := 20.0 * math.Log10(rms)
|
||||||
result.RMSLevel = rmsDB
|
result.RMSLevel = rmsDB
|
||||||
|
|
||||||
// Dynamic range is the difference between peak and RMS
|
|
||||||
result.DynamicRange = peakDB - rmsDB
|
result.DynamicRange = peakDB - rmsDB
|
||||||
}
|
}
|
||||||
|
|
||||||
// decodeFLACForMetrics decodes FLAC file and returns normalized samples for metric calculation
|
|
||||||
func decodeFLACForMetrics(filepath string) ([]float64, error) {
|
func decodeFLACForMetrics(filepath string) ([]float64, error) {
|
||||||
stream, err := mewflac.ParseFile(filepath)
|
stream, err := mewflac.ParseFile(filepath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -137,24 +124,20 @@ func decodeFLACForMetrics(filepath string) ([]float64, error) {
|
|||||||
}
|
}
|
||||||
defer stream.Close()
|
defer stream.Close()
|
||||||
|
|
||||||
// Limit samples to prevent memory issues (10 million samples = ~3.8 minutes at 44.1kHz)
|
|
||||||
maxSamples := 10000000
|
maxSamples := 10000000
|
||||||
samples := make([]float64, 0, maxSamples)
|
samples := make([]float64, 0, maxSamples)
|
||||||
|
|
||||||
// Read all audio frames
|
|
||||||
for {
|
for {
|
||||||
frame, err := stream.ParseNext()
|
frame, err := stream.ParseNext()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get samples from first channel (mono or left channel)
|
|
||||||
var channelSamples []int32
|
var channelSamples []int32
|
||||||
if len(frame.Subframes) > 0 {
|
if len(frame.Subframes) > 0 {
|
||||||
channelSamples = frame.Subframes[0].Samples
|
channelSamples = frame.Subframes[0].Samples
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normalize samples to -1.0 to 1.0 range
|
|
||||||
maxVal := float64(int64(1) << (stream.Info.BitsPerSample - 1))
|
maxVal := float64(int64(1) << (stream.Info.BitsPerSample - 1))
|
||||||
for _, sample := range channelSamples {
|
for _, sample := range channelSamples {
|
||||||
if len(samples) >= maxSamples {
|
if len(samples) >= maxSamples {
|
||||||
|
|||||||
+2
-3
@@ -6,13 +6,12 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func GetDefaultMusicPath() string {
|
func GetDefaultMusicPath() string {
|
||||||
// Get user's home directory
|
|
||||||
homeDir, err := os.UserHomeDir()
|
homeDir, err := os.UserHomeDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Fallback to Public Music if can't get home dir
|
|
||||||
return "C:\\Users\\Public\\Music"
|
return "C:\\Users\\Public\\Music"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return path to user's Music folder
|
|
||||||
return filepath.Join(homeDir, "Music")
|
return filepath.Join(homeDir, "Music")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,515 @@
|
|||||||
|
package backend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
spotifySize640 = "ab67616d0000b273"
|
||||||
|
spotifySizeMax = "ab67616d000082c1"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CoverDownloadRequest struct {
|
||||||
|
CoverURL string `json:"cover_url"`
|
||||||
|
TrackName string `json:"track_name"`
|
||||||
|
ArtistName string `json:"artist_name"`
|
||||||
|
AlbumName string `json:"album_name"`
|
||||||
|
AlbumArtist string `json:"album_artist"`
|
||||||
|
ReleaseDate string `json:"release_date"`
|
||||||
|
OutputDir string `json:"output_dir"`
|
||||||
|
FilenameFormat string `json:"filename_format"`
|
||||||
|
TrackNumber bool `json:"track_number"`
|
||||||
|
Position int `json:"position"`
|
||||||
|
DiscNumber int `json:"disc_number"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CoverDownloadResponse struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
File string `json:"file,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
AlreadyExists bool `json:"already_exists,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type HeaderDownloadRequest struct {
|
||||||
|
HeaderURL string `json:"header_url"`
|
||||||
|
ArtistName string `json:"artist_name"`
|
||||||
|
OutputDir string `json:"output_dir"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type HeaderDownloadResponse struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
File string `json:"file,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
AlreadyExists bool `json:"already_exists,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CoverClient struct {
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCoverClient() *CoverClient {
|
||||||
|
return &CoverClient{
|
||||||
|
httpClient: &http.Client{Timeout: 30 * time.Second},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildCoverFilename(trackName, artistName, albumName, albumArtist, releaseDate, filenameFormat string, includeTrackNumber bool, position, discNumber int) string {
|
||||||
|
safeTitle := sanitizeFilename(trackName)
|
||||||
|
safeArtist := sanitizeFilename(artistName)
|
||||||
|
safeAlbum := sanitizeFilename(albumName)
|
||||||
|
safeAlbumArtist := sanitizeFilename(albumArtist)
|
||||||
|
|
||||||
|
year := ""
|
||||||
|
if len(releaseDate) >= 4 {
|
||||||
|
year = releaseDate[:4]
|
||||||
|
}
|
||||||
|
|
||||||
|
var filename string
|
||||||
|
|
||||||
|
if strings.Contains(filenameFormat, "{") {
|
||||||
|
filename = filenameFormat
|
||||||
|
filename = strings.ReplaceAll(filename, "{title}", safeTitle)
|
||||||
|
filename = strings.ReplaceAll(filename, "{artist}", safeArtist)
|
||||||
|
filename = strings.ReplaceAll(filename, "{album}", safeAlbum)
|
||||||
|
filename = strings.ReplaceAll(filename, "{album_artist}", safeAlbumArtist)
|
||||||
|
filename = strings.ReplaceAll(filename, "{year}", year)
|
||||||
|
|
||||||
|
if discNumber > 0 {
|
||||||
|
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
|
||||||
|
} else {
|
||||||
|
filename = strings.ReplaceAll(filename, "{disc}", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
if position > 0 {
|
||||||
|
filename = strings.ReplaceAll(filename, "{track}", fmt.Sprintf("%02d", position))
|
||||||
|
} else {
|
||||||
|
|
||||||
|
filename = regexp.MustCompile(`\{track\}\.\s*`).ReplaceAllString(filename, "")
|
||||||
|
filename = regexp.MustCompile(`\{track\}\s*-\s*`).ReplaceAllString(filename, "")
|
||||||
|
filename = regexp.MustCompile(`\{track\}\s*`).ReplaceAllString(filename, "")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
|
||||||
|
switch filenameFormat {
|
||||||
|
case "artist-title":
|
||||||
|
filename = fmt.Sprintf("%s - %s", safeArtist, safeTitle)
|
||||||
|
case "title-artist":
|
||||||
|
filename = fmt.Sprintf("%s - %s", safeTitle, safeArtist)
|
||||||
|
case "title":
|
||||||
|
filename = safeTitle
|
||||||
|
default:
|
||||||
|
filename = fmt.Sprintf("%s - %s", safeTitle, safeArtist)
|
||||||
|
}
|
||||||
|
|
||||||
|
if includeTrackNumber && position > 0 {
|
||||||
|
filename = fmt.Sprintf("%02d - %s", position, filename)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return filename + ".cover.jpg"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CoverClient) getMaxResolutionURL(imageURL string) string {
|
||||||
|
if strings.Contains(imageURL, spotifySize640) {
|
||||||
|
return strings.Replace(imageURL, spotifySize640, spotifySizeMax, 1)
|
||||||
|
}
|
||||||
|
return imageURL
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CoverClient) DownloadCoverToPath(coverURL, outputPath string, embedMaxQualityCover bool) error {
|
||||||
|
if coverURL == "" {
|
||||||
|
return fmt.Errorf("cover URL is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadURL := coverURL
|
||||||
|
if embedMaxQualityCover {
|
||||||
|
downloadURL = c.getMaxResolutionURL(coverURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Get(downloadURL)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to download cover: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("failed to download cover: HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := os.Create(outputPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create file: %v", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
_, err = io.Copy(file, resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to write cover file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CoverClient) DownloadCover(req CoverDownloadRequest) (*CoverDownloadResponse, error) {
|
||||||
|
if req.CoverURL == "" {
|
||||||
|
return &CoverDownloadResponse{
|
||||||
|
Success: false,
|
||||||
|
Error: "Cover URL is required",
|
||||||
|
}, fmt.Errorf("cover URL is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
outputDir := req.OutputDir
|
||||||
|
if outputDir == "" {
|
||||||
|
outputDir = GetDefaultMusicPath()
|
||||||
|
} else {
|
||||||
|
outputDir = NormalizePath(outputDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||||
|
return &CoverDownloadResponse{
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("failed to create output directory: %v", err),
|
||||||
|
}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
filenameFormat := req.FilenameFormat
|
||||||
|
if filenameFormat == "" {
|
||||||
|
filenameFormat = "title-artist"
|
||||||
|
}
|
||||||
|
filename := buildCoverFilename(req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, filenameFormat, req.TrackNumber, req.Position, req.DiscNumber)
|
||||||
|
filePath := filepath.Join(outputDir, filename)
|
||||||
|
|
||||||
|
if fileInfo, err := os.Stat(filePath); err == nil && fileInfo.Size() > 0 {
|
||||||
|
return &CoverDownloadResponse{
|
||||||
|
Success: true,
|
||||||
|
Message: "Cover file already exists",
|
||||||
|
File: filePath,
|
||||||
|
AlreadyExists: true,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadURL := c.getMaxResolutionURL(req.CoverURL)
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Get(downloadURL)
|
||||||
|
if err != nil {
|
||||||
|
return &CoverDownloadResponse{
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("failed to download cover: %v", err),
|
||||||
|
}, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return &CoverDownloadResponse{
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("failed to download cover: HTTP %d", resp.StatusCode),
|
||||||
|
}, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := os.Create(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return &CoverDownloadResponse{
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("failed to create file: %v", err),
|
||||||
|
}, err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
_, err = io.Copy(file, resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return &CoverDownloadResponse{
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("failed to write cover file: %v", err),
|
||||||
|
}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &CoverDownloadResponse{
|
||||||
|
Success: true,
|
||||||
|
Message: "Cover downloaded successfully",
|
||||||
|
File: filePath,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CoverClient) DownloadHeader(req HeaderDownloadRequest) (*HeaderDownloadResponse, error) {
|
||||||
|
if req.HeaderURL == "" {
|
||||||
|
return &HeaderDownloadResponse{
|
||||||
|
Success: false,
|
||||||
|
Error: "Header URL is required",
|
||||||
|
}, fmt.Errorf("header URL is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.ArtistName == "" {
|
||||||
|
return &HeaderDownloadResponse{
|
||||||
|
Success: false,
|
||||||
|
Error: "Artist name is required",
|
||||||
|
}, fmt.Errorf("artist name is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
outputDir := req.OutputDir
|
||||||
|
if outputDir == "" {
|
||||||
|
outputDir = GetDefaultMusicPath()
|
||||||
|
} else {
|
||||||
|
outputDir = NormalizePath(outputDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
artistFolder := filepath.Join(outputDir, sanitizeFilename(req.ArtistName))
|
||||||
|
if err := os.MkdirAll(artistFolder, 0755); err != nil {
|
||||||
|
return &HeaderDownloadResponse{
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("failed to create artist folder: %v", err),
|
||||||
|
}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
filename := sanitizeFilename(req.ArtistName) + "_Header.jpg"
|
||||||
|
filePath := filepath.Join(artistFolder, filename)
|
||||||
|
|
||||||
|
if fileInfo, err := os.Stat(filePath); err == nil && fileInfo.Size() > 0 {
|
||||||
|
return &HeaderDownloadResponse{
|
||||||
|
Success: true,
|
||||||
|
Message: "Header file already exists",
|
||||||
|
File: filePath,
|
||||||
|
AlreadyExists: true,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Get(req.HeaderURL)
|
||||||
|
if err != nil {
|
||||||
|
return &HeaderDownloadResponse{
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("failed to download header: %v", err),
|
||||||
|
}, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return &HeaderDownloadResponse{
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("failed to download header: HTTP %d", resp.StatusCode),
|
||||||
|
}, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := os.Create(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return &HeaderDownloadResponse{
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("failed to create file: %v", err),
|
||||||
|
}, err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
_, err = io.Copy(file, resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return &HeaderDownloadResponse{
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("failed to write header file: %v", err),
|
||||||
|
}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &HeaderDownloadResponse{
|
||||||
|
Success: true,
|
||||||
|
Message: "Header downloaded successfully",
|
||||||
|
File: filePath,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type GalleryImageDownloadRequest struct {
|
||||||
|
ImageURL string `json:"image_url"`
|
||||||
|
ArtistName string `json:"artist_name"`
|
||||||
|
ImageIndex int `json:"image_index"`
|
||||||
|
OutputDir string `json:"output_dir"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GalleryImageDownloadResponse struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
File string `json:"file,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
AlreadyExists bool `json:"already_exists,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CoverClient) DownloadGalleryImage(req GalleryImageDownloadRequest) (*GalleryImageDownloadResponse, error) {
|
||||||
|
if req.ImageURL == "" {
|
||||||
|
return &GalleryImageDownloadResponse{
|
||||||
|
Success: false,
|
||||||
|
Error: "Image URL is required",
|
||||||
|
}, fmt.Errorf("image URL is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.ArtistName == "" {
|
||||||
|
return &GalleryImageDownloadResponse{
|
||||||
|
Success: false,
|
||||||
|
Error: "Artist name is required",
|
||||||
|
}, fmt.Errorf("artist name is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
outputDir := req.OutputDir
|
||||||
|
if outputDir == "" {
|
||||||
|
outputDir = GetDefaultMusicPath()
|
||||||
|
} else {
|
||||||
|
outputDir = NormalizePath(outputDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
artistFolder := filepath.Join(outputDir, sanitizeFilename(req.ArtistName))
|
||||||
|
if err := os.MkdirAll(artistFolder, 0755); err != nil {
|
||||||
|
return &GalleryImageDownloadResponse{
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("failed to create artist folder: %v", err),
|
||||||
|
}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
filename := sanitizeFilename(req.ArtistName) + fmt.Sprintf("_Gallery_%d.jpg", req.ImageIndex+1)
|
||||||
|
filePath := filepath.Join(artistFolder, filename)
|
||||||
|
|
||||||
|
if fileInfo, err := os.Stat(filePath); err == nil && fileInfo.Size() > 0 {
|
||||||
|
return &GalleryImageDownloadResponse{
|
||||||
|
Success: true,
|
||||||
|
Message: "Gallery image file already exists",
|
||||||
|
File: filePath,
|
||||||
|
AlreadyExists: true,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Get(req.ImageURL)
|
||||||
|
if err != nil {
|
||||||
|
return &GalleryImageDownloadResponse{
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("failed to download gallery image: %v", err),
|
||||||
|
}, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return &GalleryImageDownloadResponse{
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("failed to download gallery image: HTTP %d", resp.StatusCode),
|
||||||
|
}, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := os.Create(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return &GalleryImageDownloadResponse{
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("failed to create file: %v", err),
|
||||||
|
}, err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
_, err = io.Copy(file, resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return &GalleryImageDownloadResponse{
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("failed to write gallery image file: %v", err),
|
||||||
|
}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &GalleryImageDownloadResponse{
|
||||||
|
Success: true,
|
||||||
|
Message: "Gallery image downloaded successfully",
|
||||||
|
File: filePath,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type AvatarDownloadRequest struct {
|
||||||
|
AvatarURL string `json:"avatar_url"`
|
||||||
|
ArtistName string `json:"artist_name"`
|
||||||
|
OutputDir string `json:"output_dir"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AvatarDownloadResponse struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
File string `json:"file,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
AlreadyExists bool `json:"already_exists,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CoverClient) DownloadAvatar(req AvatarDownloadRequest) (*AvatarDownloadResponse, error) {
|
||||||
|
if req.AvatarURL == "" {
|
||||||
|
return &AvatarDownloadResponse{
|
||||||
|
Success: false,
|
||||||
|
Error: "Avatar URL is required",
|
||||||
|
}, fmt.Errorf("avatar URL is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.ArtistName == "" {
|
||||||
|
return &AvatarDownloadResponse{
|
||||||
|
Success: false,
|
||||||
|
Error: "Artist name is required",
|
||||||
|
}, fmt.Errorf("artist name is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
outputDir := req.OutputDir
|
||||||
|
if outputDir == "" {
|
||||||
|
outputDir = GetDefaultMusicPath()
|
||||||
|
} else {
|
||||||
|
outputDir = NormalizePath(outputDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
artistFolder := filepath.Join(outputDir, sanitizeFilename(req.ArtistName))
|
||||||
|
if err := os.MkdirAll(artistFolder, 0755); err != nil {
|
||||||
|
return &AvatarDownloadResponse{
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("failed to create artist folder: %v", err),
|
||||||
|
}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
filename := sanitizeFilename(req.ArtistName) + "_Avatar.jpg"
|
||||||
|
filePath := filepath.Join(artistFolder, filename)
|
||||||
|
|
||||||
|
if fileInfo, err := os.Stat(filePath); err == nil && fileInfo.Size() > 0 {
|
||||||
|
return &AvatarDownloadResponse{
|
||||||
|
Success: true,
|
||||||
|
Message: "Avatar file already exists",
|
||||||
|
File: filePath,
|
||||||
|
AlreadyExists: true,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Get(req.AvatarURL)
|
||||||
|
if err != nil {
|
||||||
|
return &AvatarDownloadResponse{
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("failed to download avatar: %v", err),
|
||||||
|
}, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return &AvatarDownloadResponse{
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("failed to download avatar: HTTP %d", resp.StatusCode),
|
||||||
|
}, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := os.Create(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return &AvatarDownloadResponse{
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("failed to create file: %v", err),
|
||||||
|
}, err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
_, err = io.Copy(file, resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return &AvatarDownloadResponse{
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("failed to write avatar file: %v", err),
|
||||||
|
}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &AvatarDownloadResponse{
|
||||||
|
Success: true,
|
||||||
|
Message: "Avatar downloaded successfully",
|
||||||
|
File: filePath,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -1,380 +0,0 @@
|
|||||||
package backend
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type DeezerDownloader struct {
|
|
||||||
client *http.Client
|
|
||||||
}
|
|
||||||
|
|
||||||
type DeezerTrack struct {
|
|
||||||
ID int64 `json:"id"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
TitleShort string `json:"title_short"`
|
|
||||||
Duration int `json:"duration"`
|
|
||||||
TrackPos int `json:"track_position"`
|
|
||||||
DiskNumber int `json:"disk_number"`
|
|
||||||
ISRC string `json:"isrc"`
|
|
||||||
ReleaseDate string `json:"release_date"`
|
|
||||||
Artist struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
ID int64 `json:"id"`
|
|
||||||
} `json:"artist"`
|
|
||||||
Album struct {
|
|
||||||
Title string `json:"title"`
|
|
||||||
ID int64 `json:"id"`
|
|
||||||
CoverXL string `json:"cover_xl"`
|
|
||||||
CoverBig string `json:"cover_big"`
|
|
||||||
} `json:"album"`
|
|
||||||
Contributors []struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Role string `json:"role"`
|
|
||||||
} `json:"contributors"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type DeezMateResponse struct {
|
|
||||||
Success bool `json:"success"`
|
|
||||||
Links struct {
|
|
||||||
FLAC string `json:"flac"`
|
|
||||||
} `json:"links"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewDeezerDownloader() *DeezerDownloader {
|
|
||||||
return &DeezerDownloader{
|
|
||||||
client: &http.Client{
|
|
||||||
Timeout: 60 * time.Second,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *DeezerDownloader) GetDeezerURLFromSpotify(spotifyTrackID string) (string, error) {
|
|
||||||
// Decode base64 API URL
|
|
||||||
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), spotifyURL)
|
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", apiURL, nil)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to create request: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println("Getting Deezer URL...")
|
|
||||||
|
|
||||||
resp, err := d.client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to get Deezer URL: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
return "", fmt.Errorf("API returned status %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *DeezerDownloader) GetTrackIDFromURL(deezerURL string) (int64, error) {
|
|
||||||
// Extract track ID from Deezer URL
|
|
||||||
// Format: https://www.deezer.com/track/3412534581
|
|
||||||
parts := strings.Split(deezerURL, "/track/")
|
|
||||||
if len(parts) < 2 {
|
|
||||||
return 0, fmt.Errorf("invalid Deezer URL format")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the track ID part and remove any query parameters
|
|
||||||
trackIDStr := strings.Split(parts[1], "?")[0]
|
|
||||||
trackIDStr = strings.TrimSpace(trackIDStr)
|
|
||||||
|
|
||||||
var trackID int64
|
|
||||||
_, err := fmt.Sscanf(trackIDStr, "%d", &trackID)
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("failed to parse track ID: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return trackID, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *DeezerDownloader) GetTrackByID(trackID int64) (*DeezerTrack, error) {
|
|
||||||
// Decode base64 API URL
|
|
||||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuZGVlemVyLmNvbS8yLjAvdHJhY2sv")
|
|
||||||
url := fmt.Sprintf("%s%d", string(apiBase), trackID)
|
|
||||||
|
|
||||||
resp, err := d.client.Get(url)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to fetch track: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
var track DeezerTrack
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&track); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if track.ID == 0 {
|
|
||||||
return nil, fmt.Errorf("track not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
return &track, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *DeezerDownloader) GetDownloadURL(trackID int64) (string, error) {
|
|
||||||
// Decode base64 API URL
|
|
||||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuZGVlem1hdGUuY29tL2RsLw==")
|
|
||||||
url := fmt.Sprintf("%s%d", string(apiBase), trackID)
|
|
||||||
|
|
||||||
resp, err := d.client.Get(url)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to get download URL: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
var apiResp DeezMateResponse
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
|
|
||||||
return "", fmt.Errorf("failed to decode API response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !apiResp.Success || apiResp.Links.FLAC == "" {
|
|
||||||
return "", fmt.Errorf("no FLAC download link available")
|
|
||||||
}
|
|
||||||
|
|
||||||
return apiResp.Links.FLAC, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *DeezerDownloader) DownloadFile(url, filepath string) error {
|
|
||||||
resp, err := d.client.Get(url)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to download file: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
return fmt.Errorf("download failed with status %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
out, err := os.Create(filepath)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create file: %w", err)
|
|
||||||
}
|
|
||||||
defer out.Close()
|
|
||||||
|
|
||||||
fmt.Println("Downloading...")
|
|
||||||
// Use progress writer to track download
|
|
||||||
pw := NewProgressWriter(out)
|
|
||||||
_, err = io.Copy(pw, resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to write file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Print final size
|
|
||||||
fmt.Printf("\rDownloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *DeezerDownloader) DownloadCoverArt(coverURL, filepath string) error {
|
|
||||||
if coverURL == "" {
|
|
||||||
return fmt.Errorf("no cover URL provided")
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := d.client.Get(coverURL)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to download cover: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
return fmt.Errorf("cover download failed with status %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
out, err := os.Create(filepath)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create cover file: %w", err)
|
|
||||||
}
|
|
||||||
defer out.Close()
|
|
||||||
|
|
||||||
_, err = io.Copy(out, resp.Body)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildFilename(title, artist string, trackNumber int, format string, includeTrackNumber bool, position int, useAlbumTrackNumber bool) string {
|
|
||||||
var filename string
|
|
||||||
|
|
||||||
// Build base filename based on format
|
|
||||||
switch format {
|
|
||||||
case "artist-title":
|
|
||||||
filename = fmt.Sprintf("%s - %s", artist, title)
|
|
||||||
case "title":
|
|
||||||
filename = title
|
|
||||||
default: // "title-artist"
|
|
||||||
filename = fmt.Sprintf("%s - %s", title, artist)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add track number prefix if enabled
|
|
||||||
if includeTrackNumber && position > 0 {
|
|
||||||
// Use album track number if in album folder structure, otherwise use playlist position
|
|
||||||
numberToUse := position
|
|
||||||
if useAlbumTrackNumber && trackNumber > 0 {
|
|
||||||
numberToUse = trackNumber
|
|
||||||
}
|
|
||||||
filename = fmt.Sprintf("%02d. %s", numberToUse, filename)
|
|
||||||
}
|
|
||||||
|
|
||||||
return filename + ".flac"
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *DeezerDownloader) DownloadByURL(deezerURL, outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName string, useAlbumTrackNumber bool) (string, error) {
|
|
||||||
fmt.Printf("Using Deezer URL: %s\n", deezerURL)
|
|
||||||
|
|
||||||
// Extract track ID from URL
|
|
||||||
trackID, err := d.GetTrackIDFromURL(deezerURL)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get track info by ID
|
|
||||||
track, err := d.GetTrackByID(trackID)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use Spotify metadata if provided, otherwise fallback to Deezer metadata
|
|
||||||
artists := spotifyArtistName
|
|
||||||
trackTitle := spotifyTrackName
|
|
||||||
albumTitle := spotifyAlbumName
|
|
||||||
|
|
||||||
if artists == "" {
|
|
||||||
artists = track.Artist.Name
|
|
||||||
if len(track.Contributors) > 0 {
|
|
||||||
var mainArtists []string
|
|
||||||
for _, contrib := range track.Contributors {
|
|
||||||
if contrib.Role == "Main" {
|
|
||||||
mainArtists = append(mainArtists, contrib.Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(mainArtists) > 0 {
|
|
||||||
artists = strings.Join(mainArtists, ", ")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if trackTitle == "" {
|
|
||||||
trackTitle = track.Title
|
|
||||||
}
|
|
||||||
|
|
||||||
if albumTitle == "" {
|
|
||||||
albumTitle = track.Album.Title
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("Found track: %s - %s\n", artists, trackTitle)
|
|
||||||
fmt.Printf("Album: %s\n", albumTitle)
|
|
||||||
|
|
||||||
downloadURL, err := d.GetDownloadURL(track.ID)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
safeArtist := sanitizeFilename(artists)
|
|
||||||
safeTitle := sanitizeFilename(trackTitle)
|
|
||||||
|
|
||||||
// Check if file with same ISRC already exists
|
|
||||||
if existingFile, exists := CheckISRCExists(outputDir, track.ISRC); exists {
|
|
||||||
fmt.Printf("File with ISRC %s already exists: %s\n", track.ISRC, existingFile)
|
|
||||||
return "EXISTS:" + existingFile, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build filename based on format settings
|
|
||||||
filename := buildFilename(safeTitle, safeArtist, track.TrackPos, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
|
|
||||||
filepath := filepath.Join(outputDir, filename)
|
|
||||||
|
|
||||||
if fileInfo, err := os.Stat(filepath); err == nil && fileInfo.Size() > 0 {
|
|
||||||
fmt.Printf("File already exists: %s (%.2f MB)\n", filepath, float64(fileInfo.Size())/(1024*1024))
|
|
||||||
return "EXISTS:" + filepath, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println("Downloading FLAC file...")
|
|
||||||
if err := d.DownloadFile(downloadURL, filepath); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("Downloaded: %s\n", filepath)
|
|
||||||
|
|
||||||
coverPath := ""
|
|
||||||
if track.Album.CoverXL != "" {
|
|
||||||
coverPath = filepath + ".cover.jpg"
|
|
||||||
fmt.Println("Downloading cover art...")
|
|
||||||
if err := d.DownloadCoverArt(track.Album.CoverXL, coverPath); err != nil {
|
|
||||||
fmt.Printf("Warning: Failed to download cover art: %v\n", err)
|
|
||||||
} else {
|
|
||||||
defer os.Remove(coverPath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println("Embedding metadata and cover art...")
|
|
||||||
// Use album track number if in album folder structure, otherwise use playlist position
|
|
||||||
trackNumberToEmbed := 0
|
|
||||||
if position > 0 {
|
|
||||||
if useAlbumTrackNumber && track.TrackPos > 0 {
|
|
||||||
trackNumberToEmbed = track.TrackPos
|
|
||||||
} else {
|
|
||||||
trackNumberToEmbed = position
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
metadata := Metadata{
|
|
||||||
Title: trackTitle,
|
|
||||||
Artist: artists,
|
|
||||||
Album: albumTitle,
|
|
||||||
Date: track.ReleaseDate,
|
|
||||||
TrackNumber: trackNumberToEmbed,
|
|
||||||
DiscNumber: track.DiskNumber,
|
|
||||||
ISRC: track.ISRC,
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := EmbedMetadata(filepath, metadata, coverPath); err != nil {
|
|
||||||
return "", fmt.Errorf("failed to embed metadata: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println("Metadata embedded successfully!")
|
|
||||||
fmt.Println("✓ Downloaded successfully from Deezer")
|
|
||||||
return filepath, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *DeezerDownloader) DownloadBySpotifyID(spotifyTrackID, outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName string, useAlbumTrackNumber bool) (string, error) {
|
|
||||||
// Get Deezer URL from Spotify track ID
|
|
||||||
deezerURL, err := d.GetDeezerURLFromSpotify(spotifyTrackID)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
return d.DownloadByURL(deezerURL, outputDir, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useAlbumTrackNumber)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,680 @@
|
|||||||
|
package backend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"archive/zip"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ulikunitz/xz"
|
||||||
|
)
|
||||||
|
|
||||||
|
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 == "" {
|
||||||
|
return fmt.Errorf("empty path")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !filepath.IsAbs(cleanedPath) {
|
||||||
|
return fmt.Errorf("path must be absolute: %s", path)
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := os.Stat(cleanedPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to stat file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.IsDir() {
|
||||||
|
return fmt.Errorf("path is a directory: %s", path)
|
||||||
|
}
|
||||||
|
|
||||||
|
if runtime.GOOS != "windows" {
|
||||||
|
if info.Mode()&0111 == 0 {
|
||||||
|
return fmt.Errorf("file is not executable: %s", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
base := filepath.Base(cleanedPath)
|
||||||
|
validNames := map[string]bool{
|
||||||
|
"ffmpeg": true,
|
||||||
|
"ffmpeg.exe": true,
|
||||||
|
"ffprobe": true,
|
||||||
|
"ffprobe.exe": true,
|
||||||
|
}
|
||||||
|
if !validNames[base] {
|
||||||
|
return fmt.Errorf("invalid executable name: %s", base)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
ffmpegWindowsURL = "aHR0cHM6Ly9naXRodWIuY29tL0J0Yk4vRkZtcGVnLUJ1aWxkcy9yZWxlYXNlcy9kb3dubG9hZC9sYXRlc3QvZmZtcGVnLW1hc3Rlci1sYXRlc3Qtd2luNjQtZ3BsLnppcA=="
|
||||||
|
ffmpegLinuxURL = "aHR0cHM6Ly9naXRodWIuY29tL0J0Yk4vRkZtcGVnLUJ1aWxkcy9yZWxlYXNlcy9kb3dubG9hZC9sYXRlc3QvZmZtcGVnLW1hc3Rlci1sYXRlc3QtbGludXg2NC1ncGwudGFyLnh6"
|
||||||
|
ffmpegMacOSURL = "aHR0cHM6Ly9ldmVybWVldC5jeC9mZm1wZWcvZ2V0cmVsZWFzZS96aXA="
|
||||||
|
ffprobeMacOSURL = "aHR0cHM6Ly9ldmVybWVldC5jeC9mZm1wZWcvZ2V0cmVsZWFzZS9mZnByb2JlL3ppcA=="
|
||||||
|
)
|
||||||
|
|
||||||
|
func GetFFmpegDir() (string, error) {
|
||||||
|
homeDir, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to get home directory: %w", err)
|
||||||
|
}
|
||||||
|
return filepath.Join(homeDir, ".spotiflac"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetFFmpegPath() (string, error) {
|
||||||
|
ffmpegDir, err := GetFFmpegDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
ffmpegName := "ffmpeg"
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
ffmpegName = "ffmpeg.exe"
|
||||||
|
}
|
||||||
|
|
||||||
|
return filepath.Join(ffmpegDir, ffmpegName), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetFFprobePath() (string, error) {
|
||||||
|
ffmpegDir, err := GetFFmpegDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
ffprobeName := "ffprobe"
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
ffprobeName = "ffprobe.exe"
|
||||||
|
}
|
||||||
|
|
||||||
|
ffprobePath := filepath.Join(ffmpegDir, ffprobeName)
|
||||||
|
if _, err := os.Stat(ffprobePath); err == nil {
|
||||||
|
return ffprobePath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("ffprobe not found in app directory")
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsFFprobeInstalled() (bool, error) {
|
||||||
|
ffprobePath, err := GetFFprobePath()
|
||||||
|
if err != nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ValidateExecutable(ffprobePath); err != nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command(ffprobePath, "-version")
|
||||||
|
setHideWindow(cmd)
|
||||||
|
err = cmd.Run()
|
||||||
|
return err == nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsFFmpegInstalled() (bool, error) {
|
||||||
|
ffmpegPath, err := GetFFmpegPath()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ValidateExecutable(ffmpegPath); err != nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command(ffmpegPath, "-version")
|
||||||
|
|
||||||
|
setHideWindow(cmd)
|
||||||
|
err = cmd.Run()
|
||||||
|
return err == nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func DownloadFFmpeg(progressCallback func(int)) error {
|
||||||
|
|
||||||
|
SetDownloadProgress(0)
|
||||||
|
SetDownloadSpeed(0)
|
||||||
|
SetDownloading(true)
|
||||||
|
defer SetDownloading(false)
|
||||||
|
|
||||||
|
ffmpegDir, err := GetFFmpegDir()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(ffmpegDir, 0755); err != nil {
|
||||||
|
return fmt.Errorf("failed to create ffmpeg directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if runtime.GOOS == "darwin" {
|
||||||
|
ffmpegInstalled, _ := IsFFmpegInstalled()
|
||||||
|
ffprobeInstalled, _ := IsFFprobeInstalled()
|
||||||
|
|
||||||
|
if !ffmpegInstalled && !ffprobeInstalled {
|
||||||
|
|
||||||
|
ffmpegURL, _ := decodeBase64(ffmpegMacOSURL)
|
||||||
|
fmt.Printf("[FFmpeg] Downloading ffmpeg from: %s\n", ffmpegURL)
|
||||||
|
if err := downloadAndExtract(ffmpegURL, 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)
|
||||||
|
}
|
||||||
|
} 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 {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var encodedURL string
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "windows":
|
||||||
|
encodedURL = ffmpegWindowsURL
|
||||||
|
case "linux":
|
||||||
|
encodedURL = 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
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func downloadAndExtract(url, destDir string, progressCallback func(int), progressStart, progressEnd int) error {
|
||||||
|
|
||||||
|
tmpFile, err := os.CreateTemp("", "ffmpeg-*")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create temp file: %w", err)
|
||||||
|
}
|
||||||
|
defer os.Remove(tmpFile.Name())
|
||||||
|
defer tmpFile.Close()
|
||||||
|
|
||||||
|
resp, err := http.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to download: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("failed to download: HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
totalSize := resp.ContentLength
|
||||||
|
var downloaded int64
|
||||||
|
lastTime := time.Now()
|
||||||
|
var lastBytes int64
|
||||||
|
|
||||||
|
if totalSize > 0 {
|
||||||
|
totalSizeMB := float64(totalSize) / (1024 * 1024)
|
||||||
|
fmt.Printf("[FFmpeg] Total size: %.2f MB\n", totalSizeMB)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("[FFmpeg] Downloading... (size unknown)\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
buf := make([]byte, 32*1024)
|
||||||
|
for {
|
||||||
|
n, err := resp.Body.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
_, writeErr := tmpFile.Write(buf[:n])
|
||||||
|
if writeErr != nil {
|
||||||
|
return fmt.Errorf("failed to write to temp file: %w", writeErr)
|
||||||
|
}
|
||||||
|
downloaded += int64(n)
|
||||||
|
|
||||||
|
mbDownloaded := float64(downloaded) / (1024 * 1024)
|
||||||
|
now := time.Now()
|
||||||
|
timeDiff := now.Sub(lastTime).Seconds()
|
||||||
|
var speedMBps float64
|
||||||
|
|
||||||
|
if timeDiff > 0.1 {
|
||||||
|
bytesDiff := float64(downloaded - lastBytes)
|
||||||
|
speedMBps = (bytesDiff / (1024 * 1024)) / timeDiff
|
||||||
|
lastTime = now
|
||||||
|
lastBytes = downloaded
|
||||||
|
}
|
||||||
|
|
||||||
|
SetDownloadProgress(mbDownloaded)
|
||||||
|
if speedMBps > 0 {
|
||||||
|
SetDownloadSpeed(speedMBps)
|
||||||
|
}
|
||||||
|
|
||||||
|
if totalSize > 0 && progressCallback != nil {
|
||||||
|
rawProgress := float64(downloaded) / float64(totalSize)
|
||||||
|
scaledProgress := progressStart + int(rawProgress*float64(progressEnd-progressStart))
|
||||||
|
progressCallback(scaledProgress)
|
||||||
|
}
|
||||||
|
|
||||||
|
if totalSize > 0 {
|
||||||
|
percent := float64(downloaded) * 100 / float64(totalSize)
|
||||||
|
if speedMBps > 0 {
|
||||||
|
fmt.Printf("\r[FFmpeg] Downloading: %.2f MB / %.2f MB (%.1f%%) - %.2f MB/s",
|
||||||
|
mbDownloaded, float64(totalSize)/(1024*1024), percent, speedMBps)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("\r[FFmpeg] Downloading: %.2f MB / %.2f MB (%.1f%%)",
|
||||||
|
mbDownloaded, float64(totalSize)/(1024*1024), percent)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if speedMBps > 0 {
|
||||||
|
fmt.Printf("\r[FFmpeg] Downloading: %.2f MB - %.2f MB/s", mbDownloaded, speedMBps)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("\r[FFmpeg] Downloading: %.2f MB", mbDownloaded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read response: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpFile.Close()
|
||||||
|
|
||||||
|
if totalSize > 0 {
|
||||||
|
fmt.Printf("\r[FFmpeg] Download complete: %.2f MB / %.2f MB (100%%) \n",
|
||||||
|
float64(downloaded)/(1024*1024), float64(totalSize)/(1024*1024))
|
||||||
|
} else {
|
||||||
|
fmt.Printf("\r[FFmpeg] Download complete: %.2f MB \n", float64(downloaded)/(1024*1024))
|
||||||
|
}
|
||||||
|
fmt.Printf("[FFmpeg] Extracting...\n")
|
||||||
|
|
||||||
|
if strings.HasSuffix(url, ".tar.xz") || runtime.GOOS == "linux" {
|
||||||
|
return extractTarXz(tmpFile.Name(), destDir)
|
||||||
|
}
|
||||||
|
return extractZip(tmpFile.Name(), destDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractZip(zipPath, destDir string) error {
|
||||||
|
r, err := zip.OpenReader(zipPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to open zip: %w", err)
|
||||||
|
}
|
||||||
|
defer r.Close()
|
||||||
|
|
||||||
|
ffmpegName := "ffmpeg"
|
||||||
|
ffprobeName := "ffprobe"
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
ffmpegName = "ffmpeg.exe"
|
||||||
|
ffprobeName = "ffprobe.exe"
|
||||||
|
}
|
||||||
|
|
||||||
|
foundFFmpeg := false
|
||||||
|
foundFFprobe := false
|
||||||
|
|
||||||
|
for _, f := range r.File {
|
||||||
|
baseName := filepath.Base(f.Name)
|
||||||
|
if f.FileInfo().IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var destPath string
|
||||||
|
if baseName == ffmpegName {
|
||||||
|
destPath = filepath.Join(destDir, ffmpegName)
|
||||||
|
foundFFmpeg = true
|
||||||
|
} else if baseName == ffprobeName {
|
||||||
|
destPath = filepath.Join(destDir, ffprobeName)
|
||||||
|
foundFFprobe = true
|
||||||
|
} else {
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("[FFmpeg] Found: %s\n", f.Name)
|
||||||
|
|
||||||
|
rc, err := f.Open()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to open file in zip: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
outFile, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0755)
|
||||||
|
if err != nil {
|
||||||
|
rc.Close()
|
||||||
|
return fmt.Errorf("failed to create output file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = io.Copy(outFile, rc)
|
||||||
|
rc.Close()
|
||||||
|
outFile.Close()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to extract file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("[FFmpeg] Extracted to: %s\n", destPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !foundFFmpeg && !foundFFprobe {
|
||||||
|
return fmt.Errorf("neither ffmpeg nor ffprobe found in archive")
|
||||||
|
}
|
||||||
|
|
||||||
|
if foundFFmpeg {
|
||||||
|
fmt.Printf("[FFmpeg] ffmpeg extracted successfully\n")
|
||||||
|
}
|
||||||
|
if foundFFprobe {
|
||||||
|
fmt.Printf("[FFmpeg] ffprobe extracted successfully\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractTarXz(tarXzPath, destDir string) error {
|
||||||
|
file, err := os.Open(tarXzPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to open tar.xz: %w", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
xzReader, err := xz.NewReader(file)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create xz reader: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tarReader := tar.NewReader(xzReader)
|
||||||
|
|
||||||
|
ffmpegName := "ffmpeg"
|
||||||
|
ffprobeName := "ffprobe"
|
||||||
|
foundFFmpeg := false
|
||||||
|
foundFFprobe := false
|
||||||
|
|
||||||
|
for {
|
||||||
|
header, err := tarReader.Next()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read tar: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if header.Typeflag != tar.TypeReg {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
baseName := filepath.Base(header.Name)
|
||||||
|
var destPath string
|
||||||
|
|
||||||
|
if baseName == ffmpegName {
|
||||||
|
destPath = filepath.Join(destDir, ffmpegName)
|
||||||
|
foundFFmpeg = true
|
||||||
|
} else if baseName == ffprobeName {
|
||||||
|
destPath = filepath.Join(destDir, ffprobeName)
|
||||||
|
foundFFprobe = true
|
||||||
|
} else {
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("[FFmpeg] Found: %s\n", header.Name)
|
||||||
|
|
||||||
|
outFile, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0755)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create output file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = io.Copy(outFile, tarReader)
|
||||||
|
outFile.Close()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to extract file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("[FFmpeg] Extracted to: %s\n", destPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !foundFFmpeg && !foundFFprobe {
|
||||||
|
return fmt.Errorf("neither ffmpeg nor ffprobe found in archive")
|
||||||
|
}
|
||||||
|
|
||||||
|
if foundFFmpeg {
|
||||||
|
fmt.Printf("[FFmpeg] ffmpeg extracted successfully\n")
|
||||||
|
}
|
||||||
|
if foundFFprobe {
|
||||||
|
fmt.Printf("[FFmpeg] ffprobe extracted successfully\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConvertAudioRequest struct {
|
||||||
|
InputFiles []string `json:"input_files"`
|
||||||
|
OutputFormat string `json:"output_format"`
|
||||||
|
Bitrate string `json:"bitrate"`
|
||||||
|
Codec string `json:"codec"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConvertAudioResult struct {
|
||||||
|
InputFile string `json:"input_file"`
|
||||||
|
OutputFile string `json:"output_file"`
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, 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")
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([]ConvertAudioResult, len(req.InputFiles))
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
var mu sync.Mutex
|
||||||
|
|
||||||
|
for i, inputFile := range req.InputFiles {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(idx int, inputFile string) {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
|
result := ConvertAudioResult{
|
||||||
|
InputFile: inputFile,
|
||||||
|
}
|
||||||
|
|
||||||
|
inputExt := strings.ToLower(filepath.Ext(inputFile))
|
||||||
|
baseName := strings.TrimSuffix(filepath.Base(inputFile), inputExt)
|
||||||
|
inputDir := filepath.Dir(inputFile)
|
||||||
|
|
||||||
|
outputFormatUpper := strings.ToUpper(req.OutputFormat)
|
||||||
|
outputDir := filepath.Join(inputDir, outputFormatUpper)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
outputExt := "." + strings.ToLower(req.OutputFormat)
|
||||||
|
outputFile := filepath.Join(outputDir, baseName+outputExt)
|
||||||
|
|
||||||
|
if inputExt == outputExt {
|
||||||
|
result.Error = "Input and output formats are the same"
|
||||||
|
result.Success = false
|
||||||
|
mu.Lock()
|
||||||
|
results[idx] = result
|
||||||
|
mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result.OutputFile = outputFile
|
||||||
|
|
||||||
|
var coverArtPath string
|
||||||
|
var lyrics string
|
||||||
|
var inputMetadata Metadata
|
||||||
|
|
||||||
|
inputMetadata, err = ExtractFullMetadataFromFile(inputFile)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("[FFmpeg] Warning: Failed to extract metadata from %s: %v\n", inputFile, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
coverArtPath, _ = ExtractCoverArt(inputFile)
|
||||||
|
lyrics, err = ExtractLyrics(inputFile)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("[FFmpeg] Warning: Failed to extract lyrics from %s: %v\n", inputFile, err)
|
||||||
|
} else if lyrics != "" {
|
||||||
|
fmt.Printf("[FFmpeg] Lyrics extracted from %s: %d characters\n", inputFile, len(lyrics))
|
||||||
|
} else {
|
||||||
|
fmt.Printf("[FFmpeg] No lyrics found in %s\n", inputFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
inputMetadata.Lyrics = lyrics
|
||||||
|
|
||||||
|
args := []string{
|
||||||
|
"-i", inputFile,
|
||||||
|
"-y",
|
||||||
|
}
|
||||||
|
|
||||||
|
switch req.OutputFormat {
|
||||||
|
case "mp3":
|
||||||
|
args = append(args,
|
||||||
|
"-codec:a", "libmp3lame",
|
||||||
|
"-b:a", req.Bitrate,
|
||||||
|
"-map", "0:a",
|
||||||
|
"-id3v2_version", "3",
|
||||||
|
)
|
||||||
|
case "m4a":
|
||||||
|
|
||||||
|
codec := req.Codec
|
||||||
|
if codec == "" {
|
||||||
|
codec = "aac"
|
||||||
|
}
|
||||||
|
|
||||||
|
if codec == "alac" {
|
||||||
|
|
||||||
|
args = append(args,
|
||||||
|
"-codec:a", "alac",
|
||||||
|
"-map", "0:a",
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
|
||||||
|
args = append(args,
|
||||||
|
"-codec:a", "aac",
|
||||||
|
"-b:a", req.Bitrate,
|
||||||
|
"-map", "0:a",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
args = append(args, outputFile)
|
||||||
|
|
||||||
|
fmt.Printf("[FFmpeg] Converting: %s -> %s\n", inputFile, outputFile)
|
||||||
|
|
||||||
|
cmd := exec.Command(ffmpegPath, args...)
|
||||||
|
|
||||||
|
setHideWindow(cmd)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
result.Error = fmt.Sprintf("conversion failed: %s - %s", err.Error(), string(output))
|
||||||
|
result.Success = false
|
||||||
|
mu.Lock()
|
||||||
|
results[idx] = result
|
||||||
|
mu.Unlock()
|
||||||
|
|
||||||
|
if coverArtPath != "" {
|
||||||
|
os.Remove(coverArtPath)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := EmbedMetadataToConvertedFile(outputFile, inputMetadata, coverArtPath); err != nil {
|
||||||
|
fmt.Printf("[FFmpeg] Warning: Failed to embed metadata: %v\n", err)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("[FFmpeg] Metadata embedded successfully\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
if lyrics != "" {
|
||||||
|
if err := EmbedLyricsOnlyUniversal(outputFile, lyrics); err != nil {
|
||||||
|
fmt.Printf("[FFmpeg] Warning: Failed to embed lyrics: %v\n", err)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("[FFmpeg] Lyrics embedded successfully\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if coverArtPath != "" {
|
||||||
|
os.Remove(coverArtPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Success = true
|
||||||
|
fmt.Printf("[FFmpeg] Successfully converted: %s\n", outputFile)
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
results[idx] = result
|
||||||
|
mu.Unlock()
|
||||||
|
}(i, inputFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type AudioFileInfo struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
Format string `json:"format"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetAudioFileInfo(filePath string) (*AudioFileInfo, error) {
|
||||||
|
info, err := os.Stat(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(filePath), "."))
|
||||||
|
return &AudioFileInfo{
|
||||||
|
Path: filePath,
|
||||||
|
Filename: filepath.Base(filePath),
|
||||||
|
Format: ext,
|
||||||
|
Size: info.Size(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
//go:build !windows
|
||||||
|
// +build !windows
|
||||||
|
|
||||||
|
package backend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os/exec"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setHideWindow(cmd *exec.Cmd) {
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
//go:build windows
|
||||||
|
// +build windows
|
||||||
|
|
||||||
|
package backend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os/exec"
|
||||||
|
"syscall"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setHideWindow(cmd *exec.Cmd) {
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||||
|
HideWindow: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package backend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
func SelectMultipleFiles(ctx context.Context) ([]string, error) {
|
||||||
|
files, err := runtime.OpenMultipleFilesDialog(ctx, runtime.OpenDialogOptions{
|
||||||
|
Title: "Select Audio Files",
|
||||||
|
Filters: []runtime.FileFilter{
|
||||||
|
{
|
||||||
|
DisplayName: "Audio Files (*.mp3, *.m4a, *.flac)",
|
||||||
|
Pattern: "*.mp3;*.m4a;*.flac",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
DisplayName: "MP3 Files (*.mp3)",
|
||||||
|
Pattern: "*.mp3",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
DisplayName: "M4A Files (*.m4a)",
|
||||||
|
Pattern: "*.m4a",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
DisplayName: "FLAC Files (*.flac)",
|
||||||
|
Pattern: "*.flac",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
DisplayName: "All Files (*.*)",
|
||||||
|
Pattern: "*.*",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return files, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func SelectOutputDirectory(ctx context.Context) (string, error) {
|
||||||
|
dir, err := runtime.OpenDirectoryDialog(ctx, runtime.OpenDialogOptions{
|
||||||
|
Title: "Select Output Directory",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return dir, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,467 @@
|
|||||||
|
package backend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
id3v2 "github.com/bogem/id3v2/v2"
|
||||||
|
"github.com/go-flac/flacvorbis"
|
||||||
|
"github.com/go-flac/go-flac"
|
||||||
|
)
|
||||||
|
|
||||||
|
type FileInfo struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
IsDir bool `json:"is_dir"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
Children []FileInfo `json:"children,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AudioMetadata struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Artist string `json:"artist"`
|
||||||
|
Album string `json:"album"`
|
||||||
|
AlbumArtist string `json:"album_artist"`
|
||||||
|
TrackNumber int `json:"track_number"`
|
||||||
|
DiscNumber int `json:"disc_number"`
|
||||||
|
Year string `json:"year"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RenamePreview struct {
|
||||||
|
OldPath string `json:"old_path"`
|
||||||
|
OldName string `json:"old_name"`
|
||||||
|
NewName string `json:"new_name"`
|
||||||
|
NewPath string `json:"new_path"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
Metadata AudioMetadata `json:"metadata"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RenameResult struct {
|
||||||
|
OldPath string `json:"old_path"`
|
||||||
|
NewPath string `json:"new_path"`
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func ListDirectory(dirPath string) ([]FileInfo, error) {
|
||||||
|
entries, err := os.ReadDir(dirPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result []FileInfo
|
||||||
|
for _, entry := range entries {
|
||||||
|
info, err := entry.Info()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fileInfo := FileInfo{
|
||||||
|
Name: entry.Name(),
|
||||||
|
Path: filepath.Join(dirPath, entry.Name()),
|
||||||
|
IsDir: entry.IsDir(),
|
||||||
|
Size: info.Size(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if entry.IsDir() {
|
||||||
|
children, err := ListDirectory(fileInfo.Path)
|
||||||
|
if err == nil {
|
||||||
|
fileInfo.Children = children
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result = append(result, fileInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ListAudioFiles(dirPath string) ([]FileInfo, error) {
|
||||||
|
var result []FileInfo
|
||||||
|
|
||||||
|
err := filepath.Walk(dirPath, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ext := strings.ToLower(filepath.Ext(path))
|
||||||
|
if ext == ".flac" || ext == ".mp3" || ext == ".m4a" {
|
||||||
|
result = append(result, FileInfo{
|
||||||
|
Name: info.Name(),
|
||||||
|
Path: path,
|
||||||
|
IsDir: false,
|
||||||
|
Size: info.Size(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to walk directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ReadAudioMetadata(filePath string) (*AudioMetadata, error) {
|
||||||
|
if !fileExists(filePath) {
|
||||||
|
return nil, fmt.Errorf("file does not exist")
|
||||||
|
}
|
||||||
|
|
||||||
|
ext := strings.ToLower(filepath.Ext(filePath))
|
||||||
|
|
||||||
|
switch ext {
|
||||||
|
case ".flac":
|
||||||
|
return readFlacMetadata(filePath)
|
||||||
|
case ".mp3":
|
||||||
|
return readMp3Metadata(filePath)
|
||||||
|
case ".m4a":
|
||||||
|
return readM4aMetadata(filePath)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported file format: %s", ext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readFlacMetadata(filePath string) (*AudioMetadata, error) {
|
||||||
|
f, err := flac.ParseFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse FLAC file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata := &AudioMetadata{}
|
||||||
|
|
||||||
|
for _, block := range f.Meta {
|
||||||
|
if block.Type == flac.VorbisComment {
|
||||||
|
cmt, err := flacvorbis.ParseFromMetaDataBlock(*block)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, comment := range cmt.Comments {
|
||||||
|
parts := strings.SplitN(comment, "=", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fieldName := strings.ToUpper(parts[0])
|
||||||
|
value := parts[1]
|
||||||
|
|
||||||
|
switch fieldName {
|
||||||
|
case "TITLE":
|
||||||
|
metadata.Title = value
|
||||||
|
case "ARTIST":
|
||||||
|
metadata.Artist = value
|
||||||
|
case "ALBUM":
|
||||||
|
metadata.Album = value
|
||||||
|
case "ALBUMARTIST":
|
||||||
|
metadata.AlbumArtist = value
|
||||||
|
case "TRACKNUMBER":
|
||||||
|
if num, err := strconv.Atoi(value); err == nil {
|
||||||
|
metadata.TrackNumber = num
|
||||||
|
}
|
||||||
|
case "DISCNUMBER":
|
||||||
|
if num, err := strconv.Atoi(value); err == nil {
|
||||||
|
metadata.DiscNumber = num
|
||||||
|
}
|
||||||
|
case "DATE", "YEAR":
|
||||||
|
metadata.Year = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return metadata, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readMp3Metadata(filePath string) (*AudioMetadata, error) {
|
||||||
|
tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to open MP3 file: %w", err)
|
||||||
|
}
|
||||||
|
defer tag.Close()
|
||||||
|
|
||||||
|
metadata := &AudioMetadata{
|
||||||
|
Title: tag.Title(),
|
||||||
|
Artist: tag.Artist(),
|
||||||
|
Album: tag.Album(),
|
||||||
|
Year: tag.Year(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if frames := tag.GetFrames("TPE2"); len(frames) > 0 {
|
||||||
|
if textFrame, ok := frames[0].(id3v2.TextFrame); ok {
|
||||||
|
metadata.AlbumArtist = textFrame.Text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if frames := tag.GetFrames(tag.CommonID("Track number/Position in set")); len(frames) > 0 {
|
||||||
|
if textFrame, ok := frames[0].(id3v2.TextFrame); ok {
|
||||||
|
trackStr := strings.Split(textFrame.Text, "/")[0]
|
||||||
|
if num, err := strconv.Atoi(trackStr); err == nil {
|
||||||
|
metadata.TrackNumber = num
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if frames := tag.GetFrames(tag.CommonID("Part of a set")); len(frames) > 0 {
|
||||||
|
if textFrame, ok := frames[0].(id3v2.TextFrame); ok {
|
||||||
|
discStr := strings.Split(textFrame.Text, "/")[0]
|
||||||
|
if num, err := strconv.Atoi(discStr); err == nil {
|
||||||
|
metadata.DiscNumber = num
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return metadata, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readMetadataWithFFprobe(filePath string) (*AudioMetadata, error) {
|
||||||
|
ffprobePath, err := GetFFprobePath()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ValidateExecutable(ffprobePath); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid ffprobe executable: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command(ffprobePath,
|
||||||
|
"-v", "quiet",
|
||||||
|
"-print_format", "json",
|
||||||
|
"-show_format",
|
||||||
|
"-show_streams",
|
||||||
|
filePath,
|
||||||
|
)
|
||||||
|
|
||||||
|
setHideWindow(cmd)
|
||||||
|
|
||||||
|
output, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Format struct {
|
||||||
|
Tags map[string]string `json:"tags"`
|
||||||
|
} `json:"format"`
|
||||||
|
Streams []struct {
|
||||||
|
Tags map[string]string `json:"tags"`
|
||||||
|
} `json:"streams"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(output, &result); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata := &AudioMetadata{}
|
||||||
|
|
||||||
|
allTags := make(map[string]string)
|
||||||
|
|
||||||
|
for _, stream := range result.Streams {
|
||||||
|
for key, value := range stream.Tags {
|
||||||
|
allTags[strings.ToLower(key)] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, value := range result.Format.Tags {
|
||||||
|
allTags[strings.ToLower(key)] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, value := range allTags {
|
||||||
|
switch key {
|
||||||
|
case "title":
|
||||||
|
metadata.Title = value
|
||||||
|
case "artist":
|
||||||
|
metadata.Artist = value
|
||||||
|
case "album":
|
||||||
|
metadata.Album = value
|
||||||
|
case "album_artist", "albumartist":
|
||||||
|
metadata.AlbumArtist = value
|
||||||
|
case "track":
|
||||||
|
|
||||||
|
trackStr := strings.Split(value, "/")[0]
|
||||||
|
if num, err := strconv.Atoi(trackStr); err == nil {
|
||||||
|
metadata.TrackNumber = num
|
||||||
|
}
|
||||||
|
case "disc":
|
||||||
|
discStr := strings.Split(value, "/")[0]
|
||||||
|
if num, err := strconv.Atoi(discStr); err == nil {
|
||||||
|
metadata.DiscNumber = num
|
||||||
|
}
|
||||||
|
case "date", "year":
|
||||||
|
if metadata.Year == "" || len(value) > len(metadata.Year) {
|
||||||
|
metadata.Year = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return metadata, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readM4aMetadata(filePath string) (*AudioMetadata, error) {
|
||||||
|
metadata, err := readMetadataWithFFprobe(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return &AudioMetadata{}, nil
|
||||||
|
}
|
||||||
|
return metadata, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GenerateFilename(metadata *AudioMetadata, format string, ext string) string {
|
||||||
|
if metadata == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
result := format
|
||||||
|
|
||||||
|
year := metadata.Year
|
||||||
|
if len(year) >= 4 {
|
||||||
|
year = year[:4]
|
||||||
|
}
|
||||||
|
|
||||||
|
result = strings.ReplaceAll(result, "{title}", sanitizeFilenameForRename(metadata.Title))
|
||||||
|
result = strings.ReplaceAll(result, "{artist}", sanitizeFilenameForRename(metadata.Artist))
|
||||||
|
result = strings.ReplaceAll(result, "{album}", sanitizeFilenameForRename(metadata.Album))
|
||||||
|
result = strings.ReplaceAll(result, "{album_artist}", sanitizeFilenameForRename(metadata.AlbumArtist))
|
||||||
|
result = strings.ReplaceAll(result, "{year}", sanitizeFilenameForRename(year))
|
||||||
|
|
||||||
|
if metadata.TrackNumber > 0 {
|
||||||
|
result = strings.ReplaceAll(result, "{track}", fmt.Sprintf("%02d", metadata.TrackNumber))
|
||||||
|
} else {
|
||||||
|
result = strings.ReplaceAll(result, "{track}", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
if metadata.DiscNumber > 0 {
|
||||||
|
result = strings.ReplaceAll(result, "{disc}", fmt.Sprintf("%d", metadata.DiscNumber))
|
||||||
|
} else {
|
||||||
|
result = strings.ReplaceAll(result, "{disc}", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
result = strings.TrimSpace(result)
|
||||||
|
result = strings.Join(strings.Fields(result), " ")
|
||||||
|
|
||||||
|
result = strings.Trim(result, " -._")
|
||||||
|
|
||||||
|
if result == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return result + ext
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeFilenameForRename(name string) string {
|
||||||
|
|
||||||
|
invalid := []string{"<", ">", ":", "\"", "/", "\\", "|", "?", "*"}
|
||||||
|
result := name
|
||||||
|
for _, char := range invalid {
|
||||||
|
result = strings.ReplaceAll(result, char, "")
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func PreviewRename(files []string, format string) []RenamePreview {
|
||||||
|
var previews []RenamePreview
|
||||||
|
|
||||||
|
for _, filePath := range files {
|
||||||
|
preview := RenamePreview{
|
||||||
|
OldPath: filePath,
|
||||||
|
OldName: filepath.Base(filePath),
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata, err := ReadAudioMetadata(filePath)
|
||||||
|
if err != nil {
|
||||||
|
preview.Error = err.Error()
|
||||||
|
previews = append(previews, preview)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
preview.Metadata = *metadata
|
||||||
|
|
||||||
|
ext := filepath.Ext(filePath)
|
||||||
|
newName := GenerateFilename(metadata, format, ext)
|
||||||
|
|
||||||
|
if newName == "" {
|
||||||
|
preview.Error = "Could not generate filename (missing metadata)"
|
||||||
|
previews = append(previews, preview)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
preview.NewName = newName
|
||||||
|
preview.NewPath = filepath.Join(filepath.Dir(filePath), newName)
|
||||||
|
|
||||||
|
previews = append(previews, preview)
|
||||||
|
}
|
||||||
|
|
||||||
|
return previews
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetFileSizes(files []string) map[string]int64 {
|
||||||
|
result := make(map[string]int64)
|
||||||
|
for _, filePath := range files {
|
||||||
|
info, err := os.Stat(filePath)
|
||||||
|
if err == nil {
|
||||||
|
result[filePath] = info.Size()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func RenameFiles(files []string, format string) []RenameResult {
|
||||||
|
var results []RenameResult
|
||||||
|
|
||||||
|
for _, filePath := range files {
|
||||||
|
result := RenameResult{
|
||||||
|
OldPath: filePath,
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata, err := ReadAudioMetadata(filePath)
|
||||||
|
if err != nil {
|
||||||
|
result.Error = err.Error()
|
||||||
|
result.Success = false
|
||||||
|
results = append(results, result)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
ext := filepath.Ext(filePath)
|
||||||
|
newName := GenerateFilename(metadata, format, ext)
|
||||||
|
|
||||||
|
if newName == "" {
|
||||||
|
result.Error = "Could not generate filename (missing metadata)"
|
||||||
|
result.Success = false
|
||||||
|
results = append(results, result)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
newPath := filepath.Join(filepath.Dir(filePath), newName)
|
||||||
|
result.NewPath = newPath
|
||||||
|
|
||||||
|
if newPath != filePath {
|
||||||
|
if _, err := os.Stat(newPath); err == nil {
|
||||||
|
result.Error = "File already exists"
|
||||||
|
result.Success = false
|
||||||
|
results = append(results, result)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Rename(filePath, newPath); err != nil {
|
||||||
|
result.Error = err.Error()
|
||||||
|
result.Success = false
|
||||||
|
results = append(results, result)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Success = true
|
||||||
|
results = append(results, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
+128
-20
@@ -2,45 +2,153 @@ package backend
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
"unicode/utf8"
|
||||||
)
|
)
|
||||||
|
|
||||||
// BuildExpectedFilename builds the expected filename based on track metadata and settings
|
func BuildExpectedFilename(trackName, artistName, albumName, albumArtist, releaseDate, filenameFormat string, includeTrackNumber bool, position, discNumber int, useAlbumTrackNumber bool) string {
|
||||||
func BuildExpectedFilename(trackName, artistName, filenameFormat string, includeTrackNumber bool, position int, useAlbumTrackNumber bool) string {
|
|
||||||
// Sanitize track name and artist name
|
|
||||||
safeTitle := sanitizeFilename(trackName)
|
safeTitle := sanitizeFilename(trackName)
|
||||||
safeArtist := sanitizeFilename(artistName)
|
safeArtist := sanitizeFilename(artistName)
|
||||||
|
safeAlbum := sanitizeFilename(albumName)
|
||||||
|
safeAlbumArtist := sanitizeFilename(albumArtist)
|
||||||
|
|
||||||
|
year := ""
|
||||||
|
if len(releaseDate) >= 4 {
|
||||||
|
year = releaseDate[:4]
|
||||||
|
}
|
||||||
|
|
||||||
var filename string
|
var filename string
|
||||||
|
|
||||||
// Build base filename based on format
|
if strings.Contains(filenameFormat, "{") {
|
||||||
switch filenameFormat {
|
filename = filenameFormat
|
||||||
case "artist-title":
|
filename = strings.ReplaceAll(filename, "{title}", safeTitle)
|
||||||
filename = fmt.Sprintf("%s - %s", safeArtist, safeTitle)
|
filename = strings.ReplaceAll(filename, "{artist}", safeArtist)
|
||||||
case "title":
|
filename = strings.ReplaceAll(filename, "{album}", safeAlbum)
|
||||||
filename = safeTitle
|
filename = strings.ReplaceAll(filename, "{album_artist}", safeAlbumArtist)
|
||||||
default: // "title-artist"
|
filename = strings.ReplaceAll(filename, "{year}", year)
|
||||||
filename = fmt.Sprintf("%s - %s", safeTitle, safeArtist)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add track number prefix if enabled
|
if discNumber > 0 {
|
||||||
// Note: We can't determine the exact track number without fetching from API
|
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
|
||||||
// So we only add it if position > 0 (bulk download)
|
} else {
|
||||||
if includeTrackNumber && position > 0 {
|
filename = strings.ReplaceAll(filename, "{disc}", "")
|
||||||
filename = fmt.Sprintf("%02d. %s", position, filename)
|
}
|
||||||
|
|
||||||
|
if position > 0 {
|
||||||
|
filename = strings.ReplaceAll(filename, "{track}", fmt.Sprintf("%02d", position))
|
||||||
|
} else {
|
||||||
|
|
||||||
|
filename = regexp.MustCompile(`\{track\}\.\s*`).ReplaceAllString(filename, "")
|
||||||
|
filename = regexp.MustCompile(`\{track\}\s*-\s*`).ReplaceAllString(filename, "")
|
||||||
|
filename = regexp.MustCompile(`\{track\}\s*`).ReplaceAllString(filename, "")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
|
||||||
|
switch filenameFormat {
|
||||||
|
case "artist-title":
|
||||||
|
filename = fmt.Sprintf("%s - %s", safeArtist, safeTitle)
|
||||||
|
case "title":
|
||||||
|
filename = safeTitle
|
||||||
|
default:
|
||||||
|
filename = fmt.Sprintf("%s - %s", safeTitle, safeArtist)
|
||||||
|
}
|
||||||
|
|
||||||
|
if includeTrackNumber && position > 0 {
|
||||||
|
filename = fmt.Sprintf("%02d. %s", position, filename)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return filename + ".flac"
|
return filename + ".flac"
|
||||||
}
|
}
|
||||||
|
|
||||||
// sanitizeFilename removes invalid characters from filename
|
|
||||||
func sanitizeFilename(name string) string {
|
func sanitizeFilename(name string) string {
|
||||||
re := regexp.MustCompile(`[<>:"/\\|?*]`)
|
|
||||||
sanitized := re.ReplaceAllString(name, "_")
|
sanitized := strings.ReplaceAll(name, "/", " ")
|
||||||
|
|
||||||
|
re := regexp.MustCompile(`[<>:"\\|?*]`)
|
||||||
|
sanitized = re.ReplaceAllString(sanitized, " ")
|
||||||
|
|
||||||
|
var result strings.Builder
|
||||||
|
for _, r := range sanitized {
|
||||||
|
|
||||||
|
if r < 0x20 && r != 0x09 && r != 0x0A && r != 0x0D {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if r == 0x7F {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if unicode.IsControl(r) && r != 0x09 && r != 0x0A && r != 0x0D {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
result.WriteRune(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
sanitized = result.String()
|
||||||
sanitized = strings.TrimSpace(sanitized)
|
sanitized = strings.TrimSpace(sanitized)
|
||||||
|
|
||||||
|
sanitized = strings.Trim(sanitized, ". ")
|
||||||
|
|
||||||
|
re = regexp.MustCompile(`\s+`)
|
||||||
|
sanitized = re.ReplaceAllString(sanitized, " ")
|
||||||
|
|
||||||
|
re = regexp.MustCompile(`_+`)
|
||||||
|
sanitized = re.ReplaceAllString(sanitized, "_")
|
||||||
|
|
||||||
|
sanitized = strings.Trim(sanitized, "_ ")
|
||||||
|
|
||||||
if sanitized == "" {
|
if sanitized == "" {
|
||||||
return "Unknown"
|
return "Unknown"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !utf8.ValidString(sanitized) {
|
||||||
|
|
||||||
|
sanitized = strings.ToValidUTF8(sanitized, "_")
|
||||||
|
}
|
||||||
|
|
||||||
return sanitized
|
return sanitized
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func NormalizePath(folderPath string) string {
|
||||||
|
|
||||||
|
return strings.ReplaceAll(folderPath, "/", string(filepath.Separator))
|
||||||
|
}
|
||||||
|
|
||||||
|
func SanitizeFolderPath(folderPath string) string {
|
||||||
|
|
||||||
|
normalizedPath := strings.ReplaceAll(folderPath, "/", string(filepath.Separator))
|
||||||
|
|
||||||
|
sep := string(filepath.Separator)
|
||||||
|
|
||||||
|
parts := strings.Split(normalizedPath, sep)
|
||||||
|
sanitizedParts := make([]string, 0, len(parts))
|
||||||
|
|
||||||
|
for i, part := range parts {
|
||||||
|
|
||||||
|
if i == 0 && len(part) == 2 && part[1] == ':' {
|
||||||
|
sanitizedParts = append(sanitizedParts, part)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if i == 0 && part == "" {
|
||||||
|
sanitizedParts = append(sanitizedParts, part)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
sanitized := sanitizeFolderName(part)
|
||||||
|
if sanitized != "" {
|
||||||
|
sanitizedParts = append(sanitizedParts, sanitized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(sanitizedParts, sep)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeFolderName(name string) string {
|
||||||
|
|
||||||
|
return sanitizeFilename(name)
|
||||||
|
}
|
||||||
|
|||||||
+2
-4
@@ -14,7 +14,7 @@ func OpenFolderInExplorer(path string) error {
|
|||||||
switch runtime.GOOS {
|
switch runtime.GOOS {
|
||||||
case "windows":
|
case "windows":
|
||||||
cmd = exec.Command("explorer", path)
|
cmd = exec.Command("explorer", path)
|
||||||
case "darwin": // macOS
|
case "darwin":
|
||||||
cmd = exec.Command("open", path)
|
cmd = exec.Command("open", path)
|
||||||
case "linux":
|
case "linux":
|
||||||
cmd = exec.Command("xdg-open", path)
|
cmd = exec.Command("xdg-open", path)
|
||||||
@@ -26,7 +26,7 @@ func OpenFolderInExplorer(path string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func SelectFolderDialog(ctx context.Context, defaultPath string) (string, error) {
|
func SelectFolderDialog(ctx context.Context, defaultPath string) (string, error) {
|
||||||
// If defaultPath is empty, use default music path
|
|
||||||
if defaultPath == "" {
|
if defaultPath == "" {
|
||||||
defaultPath = GetDefaultMusicPath()
|
defaultPath = GetDefaultMusicPath()
|
||||||
}
|
}
|
||||||
@@ -41,7 +41,6 @@ func SelectFolderDialog(ctx context.Context, defaultPath string) (string, error)
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
// If user cancelled, selectedPath will be empty
|
|
||||||
if selectedPath == "" {
|
if selectedPath == "" {
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
@@ -69,7 +68,6 @@ func SelectFileDialog(ctx context.Context) (string, error) {
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
// If user cancelled, selectedFile will be empty
|
|
||||||
if selectedFile == "" {
|
if selectedFile == "" {
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|||||||
+318
-42
@@ -6,35 +6,53 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// LyricsLine represents a single line of lyrics
|
type LRCLibResponse struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
TrackName string `json:"trackName"`
|
||||||
|
ArtistName string `json:"artistName"`
|
||||||
|
AlbumName string `json:"albumName"`
|
||||||
|
Duration float64 `json:"duration"`
|
||||||
|
Instrumental bool `json:"instrumental"`
|
||||||
|
PlainLyrics string `json:"plainLyrics"`
|
||||||
|
SyncedLyrics string `json:"syncedLyrics"`
|
||||||
|
}
|
||||||
|
|
||||||
type LyricsLine struct {
|
type LyricsLine struct {
|
||||||
StartTimeMs string `json:"startTimeMs"`
|
StartTimeMs string `json:"startTimeMs"`
|
||||||
Words string `json:"words"`
|
Words string `json:"words"`
|
||||||
EndTimeMs string `json:"endTimeMs"`
|
EndTimeMs string `json:"endTimeMs"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// LyricsResponse represents the API response
|
|
||||||
type LyricsResponse struct {
|
type LyricsResponse struct {
|
||||||
Error bool `json:"error"`
|
Error bool `json:"error"`
|
||||||
SyncType string `json:"syncType"`
|
SyncType string `json:"syncType"`
|
||||||
Lines []LyricsLine `json:"lines"`
|
Lines []LyricsLine `json:"lines"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// LyricsDownloadRequest represents a request to download lyrics
|
|
||||||
type LyricsDownloadRequest struct {
|
type LyricsDownloadRequest struct {
|
||||||
SpotifyID string `json:"spotify_id"`
|
SpotifyID string `json:"spotify_id"`
|
||||||
TrackName string `json:"track_name"`
|
TrackName string `json:"track_name"`
|
||||||
ArtistName string `json:"artist_name"`
|
ArtistName string `json:"artist_name"`
|
||||||
OutputDir string `json:"output_dir"`
|
AlbumName string `json:"album_name"`
|
||||||
|
AlbumArtist string `json:"album_artist"`
|
||||||
|
ReleaseDate string `json:"release_date"`
|
||||||
|
OutputDir string `json:"output_dir"`
|
||||||
|
FilenameFormat string `json:"filename_format"`
|
||||||
|
TrackNumber bool `json:"track_number"`
|
||||||
|
Position int `json:"position"`
|
||||||
|
UseAlbumTrackNumber bool `json:"use_album_track_number"`
|
||||||
|
DiscNumber int `json:"disc_number"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// LyricsDownloadResponse represents the response from lyrics download
|
|
||||||
type LyricsDownloadResponse struct {
|
type LyricsDownloadResponse struct {
|
||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
@@ -43,72 +61,226 @@ type LyricsDownloadResponse struct {
|
|||||||
AlreadyExists bool `json:"already_exists,omitempty"`
|
AlreadyExists bool `json:"already_exists,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// LyricsClient handles lyrics fetching
|
|
||||||
type LyricsClient struct {
|
type LyricsClient struct {
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewLyricsClient creates a new lyrics client
|
|
||||||
func NewLyricsClient() *LyricsClient {
|
func NewLyricsClient() *LyricsClient {
|
||||||
return &LyricsClient{
|
return &LyricsClient{
|
||||||
httpClient: &http.Client{Timeout: 15 * time.Second},
|
httpClient: &http.Client{Timeout: 15 * time.Second},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// FetchLyrics fetches lyrics from the Spotify Lyrics API
|
func (c *LyricsClient) FetchLyricsWithMetadata(trackName, artistName string, duration int) (*LyricsResponse, error) {
|
||||||
func (c *LyricsClient) FetchLyrics(spotifyID string) (*LyricsResponse, error) {
|
|
||||||
// Decode base64 API URL
|
|
||||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9zcG90aWZ5LWx5cmljcy1hcGktcGkudmVyY2VsLmFwcC8/dHJhY2tpZD0=")
|
|
||||||
url := fmt.Sprintf("%s%s", string(apiBase), spotifyID)
|
|
||||||
|
|
||||||
resp, err := c.httpClient.Get(url)
|
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9scmNsaWIubmV0L2FwaS9nZXQ/YXJ0aXN0X25hbWU9")
|
||||||
|
apiURL := fmt.Sprintf("%s%s&track_name=%s",
|
||||||
|
string(apiBase),
|
||||||
|
url.QueryEscape(artistName),
|
||||||
|
url.QueryEscape(trackName))
|
||||||
|
|
||||||
|
if duration > 0 {
|
||||||
|
apiURL = fmt.Sprintf("%s&duration=%d", apiURL, duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Get(apiURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to fetch lyrics: %v", err)
|
return nil, fmt.Errorf("failed to fetch from LRCLIB: %v", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return nil, fmt.Errorf("LRCLIB returned status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to read response: %v", err)
|
return nil, fmt.Errorf("failed to read LRCLIB response: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var lyricsResp LyricsResponse
|
var lrcLibResp LRCLibResponse
|
||||||
if err := json.Unmarshal(body, &lyricsResp); err != nil {
|
if err := json.Unmarshal(body, &lrcLibResp); err != nil {
|
||||||
return nil, fmt.Errorf("failed to parse lyrics response: %v", err)
|
return nil, fmt.Errorf("failed to parse LRCLIB response: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if lyricsResp.Error {
|
return c.convertLRCLibToLyricsResponse(&lrcLibResp), nil
|
||||||
return nil, fmt.Errorf("lyrics not found for this track")
|
}
|
||||||
}
|
|
||||||
|
func (c *LyricsClient) convertLRCLibToLyricsResponse(lrcLib *LRCLibResponse) *LyricsResponse {
|
||||||
return &lyricsResp, nil
|
resp := &LyricsResponse{
|
||||||
|
Error: false,
|
||||||
|
SyncType: "LINE_SYNCED",
|
||||||
|
Lines: []LyricsLine{},
|
||||||
|
}
|
||||||
|
|
||||||
|
lyricsText := lrcLib.SyncedLyrics
|
||||||
|
if lyricsText == "" {
|
||||||
|
lyricsText = lrcLib.PlainLyrics
|
||||||
|
resp.SyncType = "UNSYNCED"
|
||||||
|
}
|
||||||
|
|
||||||
|
if lyricsText == "" {
|
||||||
|
resp.Error = true
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := strings.Split(lyricsText, "\n")
|
||||||
|
for _, line := range lines {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(line, "[") && len(line) > 10 {
|
||||||
|
closeBracket := strings.Index(line, "]")
|
||||||
|
if closeBracket > 0 {
|
||||||
|
timestamp := line[1:closeBracket]
|
||||||
|
words := strings.TrimSpace(line[closeBracket+1:])
|
||||||
|
|
||||||
|
ms := lrcTimestampToMs(timestamp)
|
||||||
|
resp.Lines = append(resp.Lines, LyricsLine{
|
||||||
|
StartTimeMs: fmt.Sprintf("%d", ms),
|
||||||
|
Words: words,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resp.Lines = append(resp.Lines, LyricsLine{
|
||||||
|
StartTimeMs: "",
|
||||||
|
Words: line,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
func lrcTimestampToMs(timestamp string) int64 {
|
||||||
|
var minutes, seconds, centiseconds int64
|
||||||
|
|
||||||
|
n, _ := fmt.Sscanf(timestamp, "%d:%d.%d", &minutes, &seconds, ¢iseconds)
|
||||||
|
if n >= 2 {
|
||||||
|
return minutes*60*1000 + seconds*1000 + centiseconds*10
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
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))
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Get(apiURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("request failed: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return nil, fmt.Errorf("status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var results []LRCLibResponse
|
||||||
|
if err := json.Unmarshal(body, &results); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(results) == 0 {
|
||||||
|
return nil, fmt.Errorf("no results found")
|
||||||
|
}
|
||||||
|
|
||||||
|
var best *LRCLibResponse
|
||||||
|
for i := range results {
|
||||||
|
if results[i].SyncedLyrics != "" {
|
||||||
|
best = &results[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if best == nil && results[i].PlainLyrics != "" {
|
||||||
|
best = &results[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if best == nil {
|
||||||
|
best = &results[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.convertLRCLibToLyricsResponse(best), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func simplifyTrackName(name string) string {
|
||||||
|
|
||||||
|
if idx := strings.Index(name, "("); idx > 0 {
|
||||||
|
name = strings.TrimSpace(name[:idx])
|
||||||
|
}
|
||||||
|
|
||||||
|
if idx := strings.Index(name, " - "); idx > 0 {
|
||||||
|
name = strings.TrimSpace(name[:idx])
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName string, duration int) (*LyricsResponse, string, error) {
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
resp, err = c.FetchLyricsFromLRCLibSearch(trackName, artistName)
|
||||||
|
if err == nil && resp != nil && !resp.Error && len(resp.Lines) > 0 {
|
||||||
|
return resp, "LRCLIB Search", nil
|
||||||
|
}
|
||||||
|
fmt.Printf(" LRCLIB search: %v\n", err)
|
||||||
|
|
||||||
|
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, err = c.FetchLyricsFromLRCLibSearch(simplifiedTrack, artistName)
|
||||||
|
if err == nil && resp != nil && !resp.Error && len(resp.Lines) > 0 {
|
||||||
|
return resp, "LRCLIB Search (simplified)", nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, "", fmt.Errorf("lyrics not found in any source")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConvertToLRC converts lyrics response to LRC format
|
|
||||||
func (c *LyricsClient) ConvertToLRC(lyrics *LyricsResponse, trackName, artistName string) string {
|
func (c *LyricsClient) ConvertToLRC(lyrics *LyricsResponse, trackName, artistName string) string {
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
|
|
||||||
// Add metadata
|
|
||||||
sb.WriteString(fmt.Sprintf("[ti:%s]\n", trackName))
|
sb.WriteString(fmt.Sprintf("[ti:%s]\n", trackName))
|
||||||
sb.WriteString(fmt.Sprintf("[ar:%s]\n", artistName))
|
sb.WriteString(fmt.Sprintf("[ar:%s]\n", artistName))
|
||||||
sb.WriteString("[by:SpotiFlac]\n")
|
sb.WriteString("[by:SpotiFlac]\n")
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
|
|
||||||
// Add lyrics lines
|
|
||||||
for _, line := range lyrics.Lines {
|
for _, line := range lyrics.Lines {
|
||||||
if line.Words == "" {
|
if line.Words == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert milliseconds to LRC timestamp format [mm:ss.xx]
|
if line.StartTimeMs == "" {
|
||||||
timestamp := msToLRCTimestamp(line.StartTimeMs)
|
sb.WriteString(fmt.Sprintf("%s\n", line.Words))
|
||||||
sb.WriteString(fmt.Sprintf("%s%s\n", timestamp, line.Words))
|
} else {
|
||||||
|
|
||||||
|
timestamp := msToLRCTimestamp(line.StartTimeMs)
|
||||||
|
sb.WriteString(fmt.Sprintf("%s%s\n", timestamp, line.Words))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// msToLRCTimestamp converts milliseconds string to LRC timestamp format [mm:ss.xx]
|
|
||||||
func msToLRCTimestamp(msStr string) string {
|
func msToLRCTimestamp(msStr string) string {
|
||||||
var ms int64
|
var ms int64
|
||||||
fmt.Sscanf(msStr, "%d", &ms)
|
fmt.Sscanf(msStr, "%d", &ms)
|
||||||
@@ -121,7 +293,101 @@ func msToLRCTimestamp(msStr string) string {
|
|||||||
return fmt.Sprintf("[%02d:%02d.%02d]", minutes, seconds, centiseconds)
|
return fmt.Sprintf("[%02d:%02d.%02d]", minutes, seconds, centiseconds)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DownloadLyrics downloads lyrics for a single track
|
func buildLyricsFilename(trackName, artistName, albumName, albumArtist, releaseDate, filenameFormat string, includeTrackNumber bool, position, discNumber int) string {
|
||||||
|
safeTitle := sanitizeFilename(trackName)
|
||||||
|
safeArtist := sanitizeFilename(artistName)
|
||||||
|
safeAlbum := sanitizeFilename(albumName)
|
||||||
|
safeAlbumArtist := sanitizeFilename(albumArtist)
|
||||||
|
|
||||||
|
year := ""
|
||||||
|
if len(releaseDate) >= 4 {
|
||||||
|
year = releaseDate[:4]
|
||||||
|
}
|
||||||
|
|
||||||
|
var filename string
|
||||||
|
|
||||||
|
if strings.Contains(filenameFormat, "{") {
|
||||||
|
filename = filenameFormat
|
||||||
|
filename = strings.ReplaceAll(filename, "{title}", safeTitle)
|
||||||
|
filename = strings.ReplaceAll(filename, "{artist}", safeArtist)
|
||||||
|
filename = strings.ReplaceAll(filename, "{album}", safeAlbum)
|
||||||
|
filename = strings.ReplaceAll(filename, "{album_artist}", safeAlbumArtist)
|
||||||
|
filename = strings.ReplaceAll(filename, "{year}", year)
|
||||||
|
|
||||||
|
if discNumber > 0 {
|
||||||
|
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
|
||||||
|
} else {
|
||||||
|
filename = strings.ReplaceAll(filename, "{disc}", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
if position > 0 {
|
||||||
|
filename = strings.ReplaceAll(filename, "{track}", fmt.Sprintf("%02d", position))
|
||||||
|
} else {
|
||||||
|
|
||||||
|
filename = regexp.MustCompile(`\{track\}\.\s*`).ReplaceAllString(filename, "")
|
||||||
|
filename = regexp.MustCompile(`\{track\}\s*-\s*`).ReplaceAllString(filename, "")
|
||||||
|
filename = regexp.MustCompile(`\{track\}\s*`).ReplaceAllString(filename, "")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
|
||||||
|
switch filenameFormat {
|
||||||
|
case "artist-title":
|
||||||
|
filename = fmt.Sprintf("%s - %s", safeArtist, safeTitle)
|
||||||
|
case "title":
|
||||||
|
filename = safeTitle
|
||||||
|
default:
|
||||||
|
filename = fmt.Sprintf("%s - %s", safeTitle, safeArtist)
|
||||||
|
}
|
||||||
|
|
||||||
|
if includeTrackNumber && position > 0 {
|
||||||
|
filename = fmt.Sprintf("%02d. %s", position, filename)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return filename + ".lrc"
|
||||||
|
}
|
||||||
|
|
||||||
|
func findAudioFileForLyrics(dir, trackName, artistName string) string {
|
||||||
|
|
||||||
|
safeTitle := sanitizeFilename(trackName)
|
||||||
|
safeArtist := sanitizeFilename(artistName)
|
||||||
|
|
||||||
|
audioExts := []string{".flac", ".mp3", ".m4a", ".FLAC", ".MP3", ".M4A"}
|
||||||
|
|
||||||
|
patterns := []string{
|
||||||
|
fmt.Sprintf("%s - %s", safeTitle, safeArtist),
|
||||||
|
fmt.Sprintf("%s - %s", safeArtist, safeTitle),
|
||||||
|
safeTitle,
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
filename := entry.Name()
|
||||||
|
baseName := strings.TrimSuffix(filename, filepath.Ext(filename))
|
||||||
|
|
||||||
|
for _, pattern := range patterns {
|
||||||
|
if strings.HasPrefix(baseName, pattern) || strings.Contains(baseName, pattern) {
|
||||||
|
ext := strings.ToLower(filepath.Ext(filename))
|
||||||
|
for _, audioExt := range audioExts {
|
||||||
|
if ext == strings.ToLower(audioExt) {
|
||||||
|
return filepath.Join(dir, filename)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func (c *LyricsClient) DownloadLyrics(req LyricsDownloadRequest) (*LyricsDownloadResponse, error) {
|
func (c *LyricsClient) DownloadLyrics(req LyricsDownloadRequest) (*LyricsDownloadResponse, error) {
|
||||||
if req.SpotifyID == "" {
|
if req.SpotifyID == "" {
|
||||||
return &LyricsDownloadResponse{
|
return &LyricsDownloadResponse{
|
||||||
@@ -130,10 +396,11 @@ func (c *LyricsClient) DownloadLyrics(req LyricsDownloadRequest) (*LyricsDownloa
|
|||||||
}, fmt.Errorf("spotify ID is required")
|
}, fmt.Errorf("spotify ID is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create output directory if it doesn't exist
|
|
||||||
outputDir := req.OutputDir
|
outputDir := req.OutputDir
|
||||||
if outputDir == "" {
|
if outputDir == "" {
|
||||||
outputDir = GetDefaultMusicPath()
|
outputDir = GetDefaultMusicPath()
|
||||||
|
} else {
|
||||||
|
outputDir = NormalizePath(outputDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||||
@@ -143,11 +410,13 @@ func (c *LyricsClient) DownloadLyrics(req LyricsDownloadRequest) (*LyricsDownloa
|
|||||||
}, err
|
}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate filename
|
filenameFormat := req.FilenameFormat
|
||||||
filename := sanitizeFilename(fmt.Sprintf("%s - %s.lrc", req.TrackName, req.ArtistName))
|
if filenameFormat == "" {
|
||||||
|
filenameFormat = "title-artist"
|
||||||
|
}
|
||||||
|
filename := buildLyricsFilename(req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, filenameFormat, req.TrackNumber, req.Position, req.DiscNumber)
|
||||||
filePath := filepath.Join(outputDir, filename)
|
filePath := filepath.Join(outputDir, filename)
|
||||||
|
|
||||||
// Check if file already exists
|
|
||||||
if fileInfo, err := os.Stat(filePath); err == nil && fileInfo.Size() > 0 {
|
if fileInfo, err := os.Stat(filePath); err == nil && fileInfo.Size() > 0 {
|
||||||
return &LyricsDownloadResponse{
|
return &LyricsDownloadResponse{
|
||||||
Success: true,
|
Success: true,
|
||||||
@@ -157,8 +426,17 @@ func (c *LyricsClient) DownloadLyrics(req LyricsDownloadRequest) (*LyricsDownloa
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch lyrics
|
audioDuration := 0
|
||||||
lyrics, err := c.FetchLyrics(req.SpotifyID)
|
audioFile := findAudioFileForLyrics(outputDir, req.TrackName, req.ArtistName)
|
||||||
|
if audioFile != "" {
|
||||||
|
duration, err := GetAudioDuration(audioFile)
|
||||||
|
if err == nil && duration > 0 {
|
||||||
|
audioDuration = int(duration)
|
||||||
|
fmt.Printf("[DownloadLyrics] Found audio file, duration: %d seconds\n", audioDuration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lyrics, _, err := c.FetchLyricsAllSources(req.SpotifyID, req.TrackName, req.ArtistName, audioDuration)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &LyricsDownloadResponse{
|
return &LyricsDownloadResponse{
|
||||||
Success: false,
|
Success: false,
|
||||||
@@ -166,10 +444,8 @@ func (c *LyricsClient) DownloadLyrics(req LyricsDownloadRequest) (*LyricsDownloa
|
|||||||
}, err
|
}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert to LRC format
|
|
||||||
lrcContent := c.ConvertToLRC(lyrics, req.TrackName, req.ArtistName)
|
lrcContent := c.ConvertToLRC(lyrics, req.TrackName, req.ArtistName)
|
||||||
|
|
||||||
// Write LRC file
|
|
||||||
if err := os.WriteFile(filePath, []byte(lrcContent), 0644); err != nil {
|
if err := os.WriteFile(filePath, []byte(lrcContent), 0644); err != nil {
|
||||||
return &LyricsDownloadResponse{
|
return &LyricsDownloadResponse{
|
||||||
Success: false,
|
Success: false,
|
||||||
|
|||||||
+826
-43
@@ -1,11 +1,15 @@
|
|||||||
package backend
|
package backend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
pathfilepath "path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
id3v2 "github.com/bogem/id3v2/v2"
|
||||||
"github.com/go-flac/flacpicture"
|
"github.com/go-flac/flacpicture"
|
||||||
"github.com/go-flac/flacvorbis"
|
"github.com/go-flac/flacvorbis"
|
||||||
"github.com/go-flac/go-flac"
|
"github.com/go-flac/go-flac"
|
||||||
@@ -15,10 +19,18 @@ type Metadata struct {
|
|||||||
Title string
|
Title string
|
||||||
Artist string
|
Artist string
|
||||||
Album string
|
Album string
|
||||||
|
AlbumArtist string
|
||||||
Date string
|
Date string
|
||||||
|
ReleaseDate string
|
||||||
TrackNumber int
|
TrackNumber int
|
||||||
|
TotalTracks int
|
||||||
DiscNumber int
|
DiscNumber int
|
||||||
ISRC string
|
TotalDiscs int
|
||||||
|
URL string
|
||||||
|
Copyright string
|
||||||
|
Publisher string
|
||||||
|
Lyrics string
|
||||||
|
Description string
|
||||||
}
|
}
|
||||||
|
|
||||||
func EmbedMetadata(filepath string, metadata Metadata, coverPath string) error {
|
func EmbedMetadata(filepath string, metadata Metadata, coverPath string) error {
|
||||||
@@ -46,17 +58,36 @@ func EmbedMetadata(filepath string, metadata Metadata, coverPath string) error {
|
|||||||
if metadata.Album != "" {
|
if metadata.Album != "" {
|
||||||
_ = cmt.Add(flacvorbis.FIELD_ALBUM, metadata.Album)
|
_ = cmt.Add(flacvorbis.FIELD_ALBUM, metadata.Album)
|
||||||
}
|
}
|
||||||
|
if metadata.AlbumArtist != "" {
|
||||||
|
_ = cmt.Add("ALBUMARTIST", metadata.AlbumArtist)
|
||||||
|
}
|
||||||
if metadata.Date != "" {
|
if metadata.Date != "" {
|
||||||
_ = cmt.Add(flacvorbis.FIELD_DATE, metadata.Date)
|
_ = cmt.Add(flacvorbis.FIELD_DATE, metadata.Date)
|
||||||
}
|
}
|
||||||
if metadata.TrackNumber > 0 {
|
if metadata.TrackNumber > 0 {
|
||||||
_ = cmt.Add(flacvorbis.FIELD_TRACKNUMBER, strconv.Itoa(metadata.TrackNumber))
|
_ = cmt.Add(flacvorbis.FIELD_TRACKNUMBER, strconv.Itoa(metadata.TrackNumber))
|
||||||
}
|
}
|
||||||
|
if metadata.TotalTracks > 0 {
|
||||||
|
_ = cmt.Add("TOTALTRACKS", strconv.Itoa(metadata.TotalTracks))
|
||||||
|
}
|
||||||
if metadata.DiscNumber > 0 {
|
if metadata.DiscNumber > 0 {
|
||||||
_ = cmt.Add("DISCNUMBER", strconv.Itoa(metadata.DiscNumber))
|
_ = cmt.Add("DISCNUMBER", strconv.Itoa(metadata.DiscNumber))
|
||||||
}
|
}
|
||||||
if metadata.ISRC != "" {
|
if metadata.TotalDiscs > 0 {
|
||||||
_ = cmt.Add(flacvorbis.FIELD_ISRC, metadata.ISRC)
|
_ = cmt.Add("TOTALDISCS", strconv.Itoa(metadata.TotalDiscs))
|
||||||
|
}
|
||||||
|
if metadata.Copyright != "" {
|
||||||
|
_ = cmt.Add("COPYRIGHT", metadata.Copyright)
|
||||||
|
}
|
||||||
|
if metadata.Publisher != "" {
|
||||||
|
_ = cmt.Add("PUBLISHER", metadata.Publisher)
|
||||||
|
}
|
||||||
|
if metadata.Description != "" {
|
||||||
|
_ = cmt.Add("DESCRIPTION", metadata.Description)
|
||||||
|
}
|
||||||
|
|
||||||
|
if metadata.Lyrics != "" {
|
||||||
|
_ = cmt.Add("LYRICS", metadata.Lyrics)
|
||||||
}
|
}
|
||||||
|
|
||||||
cmtBlock := cmt.Marshal()
|
cmtBlock := cmt.Marshal()
|
||||||
@@ -113,18 +144,199 @@ func fileExists(path string) bool {
|
|||||||
return err == nil
|
return err == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReadISRCFromFile reads ISRC metadata from a FLAC file
|
func extractYear(releaseDate string) string {
|
||||||
func ReadISRCFromFile(filepath string) (string, error) {
|
if releaseDate == "" {
|
||||||
if !fileExists(filepath) {
|
return ""
|
||||||
return "", fmt.Errorf("file does not exist")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(releaseDate) >= 4 {
|
||||||
|
return releaseDate[:4]
|
||||||
|
}
|
||||||
|
return releaseDate
|
||||||
|
}
|
||||||
|
|
||||||
|
func EmbedLyricsOnly(filepath string, lyrics string) error {
|
||||||
|
if lyrics == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
f, err := flac.ParseFile(filepath)
|
f, err := flac.ParseFile(filepath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to parse FLAC file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var cmtIdx = -1
|
||||||
|
var existingCmt *flacvorbis.MetaDataBlockVorbisComment
|
||||||
|
for idx, block := range f.Meta {
|
||||||
|
if block.Type == flac.VorbisComment {
|
||||||
|
cmtIdx = idx
|
||||||
|
existingCmt, err = flacvorbis.ParseFromMetaDataBlock(*block)
|
||||||
|
if err != nil {
|
||||||
|
existingCmt = nil
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cmt := flacvorbis.New()
|
||||||
|
|
||||||
|
if existingCmt != nil {
|
||||||
|
for _, comment := range existingCmt.Comments {
|
||||||
|
parts := strings.SplitN(comment, "=", 2)
|
||||||
|
if len(parts) == 2 {
|
||||||
|
fieldName := strings.ToUpper(parts[0])
|
||||||
|
if fieldName != "LYRICS" && fieldName != "UNSYNCEDLYRICS" && fieldName != "SYNCEDLYRICS" {
|
||||||
|
_ = cmt.Add(parts[0], parts[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = cmt.Add("LYRICS", lyrics)
|
||||||
|
|
||||||
|
cmtBlock := cmt.Marshal()
|
||||||
|
if cmtIdx < 0 {
|
||||||
|
f.Meta = append(f.Meta, &cmtBlock)
|
||||||
|
} else {
|
||||||
|
f.Meta[cmtIdx] = &cmtBlock
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := f.Save(filepath); err != nil {
|
||||||
|
return fmt.Errorf("failed to save FLAC file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExtractCoverArt(filePath string) (string, error) {
|
||||||
|
ext := strings.ToLower(pathfilepath.Ext(filePath))
|
||||||
|
|
||||||
|
switch ext {
|
||||||
|
case ".mp3":
|
||||||
|
return extractCoverFromMp3(filePath)
|
||||||
|
case ".m4a", ".flac":
|
||||||
|
return extractCoverFromM4AOrFlac(filePath)
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("unsupported file format: %s", ext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractCoverFromMp3(filePath string) (string, error) {
|
||||||
|
tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true})
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to open MP3 file: %w", err)
|
||||||
|
}
|
||||||
|
defer tag.Close()
|
||||||
|
|
||||||
|
pictures := tag.GetFrames(tag.CommonID("Attached picture"))
|
||||||
|
if len(pictures) == 0 {
|
||||||
|
return "", fmt.Errorf("no cover art found")
|
||||||
|
}
|
||||||
|
|
||||||
|
pic, ok := pictures[0].(id3v2.PictureFrame)
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("invalid picture frame")
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpFile, err := os.CreateTemp("", "cover-*.jpg")
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create temp file: %w", err)
|
||||||
|
}
|
||||||
|
defer tmpFile.Close()
|
||||||
|
|
||||||
|
if _, err := tmpFile.Write(pic.Picture); err != nil {
|
||||||
|
os.Remove(tmpFile.Name())
|
||||||
|
return "", fmt.Errorf("failed to write cover art: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tmpFile.Name(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractCoverFromM4AOrFlac(filePath string) (string, error) {
|
||||||
|
ext := strings.ToLower(pathfilepath.Ext(filePath))
|
||||||
|
|
||||||
|
if ext == ".flac" {
|
||||||
|
f, err := flac.ParseFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to parse FLAC file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, block := range f.Meta {
|
||||||
|
if block.Type == flac.Picture {
|
||||||
|
pic, err := flacpicture.ParseFromMetaDataBlock(*block)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpFile, err := os.CreateTemp("", "cover-*.jpg")
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create temp file: %w", err)
|
||||||
|
}
|
||||||
|
defer tmpFile.Close()
|
||||||
|
|
||||||
|
if _, err := tmpFile.Write(pic.ImageData); err != nil {
|
||||||
|
os.Remove(tmpFile.Name())
|
||||||
|
return "", fmt.Errorf("failed to write cover art: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tmpFile.Name(), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("no cover art found")
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExtractLyrics(filePath string) (string, error) {
|
||||||
|
ext := strings.ToLower(pathfilepath.Ext(filePath))
|
||||||
|
|
||||||
|
switch ext {
|
||||||
|
case ".mp3":
|
||||||
|
return extractLyricsFromMp3(filePath)
|
||||||
|
case ".flac":
|
||||||
|
return extractLyricsFromFlac(filePath)
|
||||||
|
case ".m4a":
|
||||||
|
|
||||||
|
return "", nil
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("unsupported file format: %s", ext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractLyricsFromMp3(filePath string) (string, error) {
|
||||||
|
tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true})
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to open MP3 file: %w", err)
|
||||||
|
}
|
||||||
|
defer tag.Close()
|
||||||
|
|
||||||
|
usltFrames := tag.GetFrames(tag.CommonID("Unsynchronised lyrics/text transcription"))
|
||||||
|
if len(usltFrames) == 0 {
|
||||||
|
fmt.Printf("[ExtractLyrics] No USLT frames found in MP3: %s\n", filePath)
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
uslt, ok := usltFrames[0].(id3v2.UnsynchronisedLyricsFrame)
|
||||||
|
if !ok {
|
||||||
|
fmt.Printf("[ExtractLyrics] USLT frame type assertion failed in MP3: %s\n", filePath)
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if uslt.Lyrics == "" {
|
||||||
|
fmt.Printf("[ExtractLyrics] USLT frame has empty lyrics in MP3: %s\n", filePath)
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("[ExtractLyrics] Successfully extracted lyrics from MP3: %s (%d characters)\n", filePath, len(uslt.Lyrics))
|
||||||
|
return uslt.Lyrics, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractLyricsFromFlac(filePath string) (string, error) {
|
||||||
|
f, err := flac.ParseFile(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to parse FLAC file: %w", err)
|
return "", fmt.Errorf("failed to parse FLAC file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find VorbisComment block
|
|
||||||
for _, block := range f.Meta {
|
for _, block := range f.Meta {
|
||||||
if block.Type == flac.VorbisComment {
|
if block.Type == flac.VorbisComment {
|
||||||
cmt, err := flacvorbis.ParseFromMetaDataBlock(*block)
|
cmt, err := flacvorbis.ParseFromMetaDataBlock(*block)
|
||||||
@@ -132,53 +344,624 @@ func ReadISRCFromFile(filepath string) (string, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get ISRC field
|
for _, comment := range cmt.Comments {
|
||||||
isrcValues, err := cmt.Get(flacvorbis.FIELD_ISRC)
|
parts := strings.SplitN(comment, "=", 2)
|
||||||
if err == nil && len(isrcValues) > 0 {
|
if len(parts) == 2 {
|
||||||
return isrcValues[0], nil
|
fieldName := strings.ToUpper(parts[0])
|
||||||
|
if fieldName == "LYRICS" || fieldName == "UNSYNCEDLYRICS" {
|
||||||
|
lyrics := parts[1]
|
||||||
|
fmt.Printf("[ExtractLyrics] Successfully extracted lyrics from FLAC: %s (%d characters)\n", filePath, len(lyrics))
|
||||||
|
return lyrics, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return "", nil // No ISRC found
|
fmt.Printf("[ExtractLyrics] No lyrics found in FLAC: %s\n", filePath)
|
||||||
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckISRCExists checks if a file with the given ISRC already exists in the directory
|
func EmbedCoverArtOnly(filePath string, coverPath string) error {
|
||||||
func CheckISRCExists(outputDir string, targetISRC string) (string, bool) {
|
if coverPath == "" || !fileExists(coverPath) {
|
||||||
if targetISRC == "" {
|
return nil
|
||||||
return "", false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read all .flac files in directory
|
ext := strings.ToLower(pathfilepath.Ext(filePath))
|
||||||
entries, err := os.ReadDir(outputDir)
|
|
||||||
|
switch ext {
|
||||||
|
case ".mp3":
|
||||||
|
return embedCoverToMp3(filePath, coverPath)
|
||||||
|
case ".m4a":
|
||||||
|
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unsupported file format: %s", ext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func embedCoverToMp3(filePath string, coverPath string) error {
|
||||||
|
tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", false
|
return fmt.Errorf("failed to open MP3 file: %w", err)
|
||||||
|
}
|
||||||
|
defer tag.Close()
|
||||||
|
|
||||||
|
tag.DeleteFrames(tag.CommonID("Attached picture"))
|
||||||
|
|
||||||
|
artwork, err := os.ReadFile(coverPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read cover art: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, entry := range entries {
|
pic := id3v2.PictureFrame{
|
||||||
if entry.IsDir() {
|
Encoding: id3v2.EncodingUTF8,
|
||||||
continue
|
MimeType: "image/jpeg",
|
||||||
}
|
PictureType: id3v2.PTFrontCover,
|
||||||
|
Description: "Front cover",
|
||||||
|
Picture: artwork,
|
||||||
|
}
|
||||||
|
tag.AddAttachedPicture(pic)
|
||||||
|
|
||||||
// Check only .flac files
|
if err := tag.Save(); err != nil {
|
||||||
filename := entry.Name()
|
return fmt.Errorf("failed to save MP3 tags: %w", err)
|
||||||
if len(filename) < 5 || filename[len(filename)-5:] != ".flac" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
filepath := fmt.Sprintf("%s/%s", outputDir, filename)
|
|
||||||
|
|
||||||
// Read ISRC from file
|
|
||||||
isrc, err := ReadISRCFromFile(filepath)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compare ISRC (case-insensitive)
|
|
||||||
if isrc != "" && strings.EqualFold(isrc, targetISRC) {
|
|
||||||
return filepath, true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return "", false
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func EmbedLyricsOnlyMP3(filepath string, lyrics string) error {
|
||||||
|
if lyrics == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
validatedLyrics, err := validateLyricsDuration(lyrics, filepath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("[EmbedLyricsOnlyMP3] Warning: Failed to validate lyrics duration: %v, using original lyrics\n", err)
|
||||||
|
validatedLyrics = lyrics
|
||||||
|
}
|
||||||
|
lyrics = validatedLyrics
|
||||||
|
|
||||||
|
tag, err := id3v2.Open(filepath, id3v2.Options{Parse: true})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to open MP3 file: %w", err)
|
||||||
|
}
|
||||||
|
defer tag.Close()
|
||||||
|
|
||||||
|
tag.DeleteFrames(tag.CommonID("Unsynchronised lyrics/text transcription"))
|
||||||
|
|
||||||
|
usltFrame := id3v2.UnsynchronisedLyricsFrame{
|
||||||
|
Encoding: id3v2.EncodingUTF8,
|
||||||
|
Language: "eng",
|
||||||
|
ContentDescriptor: "",
|
||||||
|
Lyrics: lyrics,
|
||||||
|
}
|
||||||
|
tag.AddUnsynchronisedLyricsFrame(usltFrame)
|
||||||
|
|
||||||
|
if err := tag.Save(); err != nil {
|
||||||
|
return fmt.Errorf("failed to save MP3 tags: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func embedLyricsToM4A(filepath string, lyrics string) error {
|
||||||
|
|
||||||
|
validatedLyrics, err := validateLyricsDuration(lyrics, filepath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("[embedLyricsToM4A] Warning: Failed to validate lyrics duration: %v, using original lyrics\n", err)
|
||||||
|
validatedLyrics = lyrics
|
||||||
|
}
|
||||||
|
lyrics = validatedLyrics
|
||||||
|
|
||||||
|
ffmpegPath, err := GetFFmpegPath()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ffmpeg not found: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ValidateExecutable(ffmpegPath); err != nil {
|
||||||
|
return fmt.Errorf("invalid ffmpeg executable: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpOutputFile := strings.TrimSuffix(filepath, pathfilepath.Ext(filepath)) + ".tmp" + pathfilepath.Ext(filepath)
|
||||||
|
defer func() {
|
||||||
|
|
||||||
|
if _, err := os.Stat(tmpOutputFile); err == nil {
|
||||||
|
os.Remove(tmpOutputFile)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
cmd := exec.Command(ffmpegPath,
|
||||||
|
"-i", filepath,
|
||||||
|
"-map", "0",
|
||||||
|
"-map_metadata", "0",
|
||||||
|
"-metadata", "lyrics-eng="+lyrics,
|
||||||
|
"-metadata", "lyrics="+lyrics,
|
||||||
|
"-codec", "copy",
|
||||||
|
"-f", "ipod",
|
||||||
|
"-y",
|
||||||
|
tmpOutputFile,
|
||||||
|
)
|
||||||
|
|
||||||
|
setHideWindow(cmd)
|
||||||
|
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("[FFmpeg] Error embedding lyrics to M4A: %s\n", string(output))
|
||||||
|
return fmt.Errorf("ffmpeg failed to embed lyrics: %s - %w", string(output), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Rename(tmpOutputFile, filepath); err != nil {
|
||||||
|
return fmt.Errorf("failed to replace original file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("[FFmpeg] Lyrics embedded to M4A successfully: %d characters\n", len(lyrics))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func EmbedLyricsOnlyUniversal(filepath string, lyrics string) error {
|
||||||
|
if lyrics == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ext := strings.ToLower(pathfilepath.Ext(filepath))
|
||||||
|
switch ext {
|
||||||
|
case ".mp3":
|
||||||
|
return EmbedLyricsOnlyMP3(filepath, lyrics)
|
||||||
|
case ".flac":
|
||||||
|
return EmbedLyricsOnly(filepath, lyrics)
|
||||||
|
case ".m4a":
|
||||||
|
return embedLyricsToM4A(filepath, lyrics)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unsupported file format for lyrics embedding: %s", ext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetAudioDuration(filepath string) (float64, error) {
|
||||||
|
ext := strings.ToLower(pathfilepath.Ext(filepath))
|
||||||
|
|
||||||
|
if ext == ".flac" {
|
||||||
|
duration, err := getFlacDuration(filepath)
|
||||||
|
if err == nil && duration > 0 {
|
||||||
|
return duration, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return getDurationWithFFprobe(filepath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getFlacDuration(filepath string) (float64, error) {
|
||||||
|
f, err := flac.ParseFile(filepath)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(f.Meta) > 0 {
|
||||||
|
streamInfo := f.Meta[0]
|
||||||
|
if streamInfo.Type == flac.StreamInfo {
|
||||||
|
data := streamInfo.Data
|
||||||
|
if len(data) >= 18 {
|
||||||
|
|
||||||
|
sampleRate := uint32(data[10])<<12 | uint32(data[11])<<4 | uint32(data[12])>>4
|
||||||
|
|
||||||
|
totalSamples := uint64(data[13]&0x0F)<<32 |
|
||||||
|
uint64(data[14])<<24 |
|
||||||
|
uint64(data[15])<<16 |
|
||||||
|
uint64(data[16])<<8 |
|
||||||
|
uint64(data[17])
|
||||||
|
|
||||||
|
if sampleRate > 0 {
|
||||||
|
return float64(totalSamples) / float64(sampleRate), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0, fmt.Errorf("could not extract duration from FLAC file")
|
||||||
|
}
|
||||||
|
|
||||||
|
func getDurationWithFFprobe(filepath string) (float64, error) {
|
||||||
|
ffprobePath, err := GetFFprobePath()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ValidateExecutable(ffprobePath); err != nil {
|
||||||
|
return 0, fmt.Errorf("invalid ffprobe executable: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command(ffprobePath,
|
||||||
|
"-v", "quiet",
|
||||||
|
"-print_format", "json",
|
||||||
|
"-show_format",
|
||||||
|
filepath,
|
||||||
|
)
|
||||||
|
|
||||||
|
setHideWindow(cmd)
|
||||||
|
|
||||||
|
output, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Format struct {
|
||||||
|
Duration string `json:"duration"`
|
||||||
|
} `json:"format"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(output, &result); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Format.Duration == "" {
|
||||||
|
return 0, fmt.Errorf("duration not found in ffprobe output")
|
||||||
|
}
|
||||||
|
|
||||||
|
duration, err := strconv.ParseFloat(result.Format.Duration, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return duration, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateLyricsDuration(lyrics string, filepath string) (string, error) {
|
||||||
|
|
||||||
|
duration, err := GetAudioDuration(filepath)
|
||||||
|
if err != nil {
|
||||||
|
|
||||||
|
fmt.Printf("[ValidateLyrics] Warning: Could not get audio duration: %v, skipping validation\n", err)
|
||||||
|
return lyrics, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if duration <= 0 {
|
||||||
|
|
||||||
|
fmt.Printf("[ValidateLyrics] Warning: Invalid duration (%f seconds), skipping validation\n", duration)
|
||||||
|
return lyrics, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
durationMs := int64(duration * 1000)
|
||||||
|
|
||||||
|
lines := strings.Split(lyrics, "\n")
|
||||||
|
var validLines []string
|
||||||
|
|
||||||
|
for _, line := range lines {
|
||||||
|
trimmedLine := strings.TrimSpace(line)
|
||||||
|
if trimmedLine == "" {
|
||||||
|
validLines = append(validLines, line)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
} else {
|
||||||
|
|
||||||
|
fmt.Printf("[ValidateLyrics] Filtered out line with timestamp %s (exceeds duration %d ms): %s\n", timestampStr, durationMs, trimmedLine)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
|
||||||
|
validLines = append(validLines, line)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
|
||||||
|
validLines = append(validLines, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(validLines, "\n"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseLRCTimestamp(timestamp string) int64 {
|
||||||
|
var minutes, seconds, centiseconds int64
|
||||||
|
n, _ := fmt.Sscanf(timestamp, "%d:%d.%d", &minutes, &seconds, ¢iseconds)
|
||||||
|
if n >= 2 {
|
||||||
|
return minutes*60*1000 + seconds*1000 + centiseconds*10
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExtractFullMetadataFromFile(filePath string) (Metadata, error) {
|
||||||
|
var metadata Metadata
|
||||||
|
|
||||||
|
ffprobePath, err := GetFFprobePath()
|
||||||
|
if err != nil {
|
||||||
|
return metadata, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ValidateExecutable(ffprobePath); err != nil {
|
||||||
|
return metadata, fmt.Errorf("invalid ffprobe executable: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command(ffprobePath,
|
||||||
|
"-v", "quiet",
|
||||||
|
"-print_format", "json",
|
||||||
|
"-show_format",
|
||||||
|
"-show_streams",
|
||||||
|
filePath,
|
||||||
|
)
|
||||||
|
|
||||||
|
setHideWindow(cmd)
|
||||||
|
|
||||||
|
output, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return metadata, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Format struct {
|
||||||
|
Tags map[string]string `json:"tags"`
|
||||||
|
} `json:"format"`
|
||||||
|
Streams []struct {
|
||||||
|
Tags map[string]string `json:"tags"`
|
||||||
|
} `json:"streams"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(output, &result); err != nil {
|
||||||
|
return metadata, err
|
||||||
|
}
|
||||||
|
|
||||||
|
allTags := make(map[string]string)
|
||||||
|
|
||||||
|
for _, stream := range result.Streams {
|
||||||
|
for key, value := range stream.Tags {
|
||||||
|
allTags[strings.ToLower(key)] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, value := range result.Format.Tags {
|
||||||
|
allTags[strings.ToLower(key)] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, value := range allTags {
|
||||||
|
switch key {
|
||||||
|
case "title":
|
||||||
|
metadata.Title = value
|
||||||
|
case "artist":
|
||||||
|
metadata.Artist = value
|
||||||
|
case "album":
|
||||||
|
metadata.Album = value
|
||||||
|
case "album_artist", "albumartist":
|
||||||
|
metadata.AlbumArtist = value
|
||||||
|
case "date", "year":
|
||||||
|
if metadata.Date == "" || len(value) > len(metadata.Date) {
|
||||||
|
metadata.Date = value
|
||||||
|
}
|
||||||
|
case "track":
|
||||||
|
|
||||||
|
parts := strings.Split(value, "/")
|
||||||
|
if len(parts) > 0 {
|
||||||
|
if num, err := strconv.Atoi(parts[0]); err == nil {
|
||||||
|
metadata.TrackNumber = num
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(parts) > 1 {
|
||||||
|
if num, err := strconv.Atoi(parts[1]); err == nil {
|
||||||
|
metadata.TotalTracks = num
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "disc":
|
||||||
|
|
||||||
|
parts := strings.Split(value, "/")
|
||||||
|
if len(parts) > 0 {
|
||||||
|
if num, err := strconv.Atoi(parts[0]); err == nil {
|
||||||
|
metadata.DiscNumber = num
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(parts) > 1 {
|
||||||
|
if num, err := strconv.Atoi(parts[1]); err == nil {
|
||||||
|
metadata.TotalDiscs = num
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "copyright", "tcop":
|
||||||
|
metadata.Copyright = value
|
||||||
|
case "publisher", "tpub", "label":
|
||||||
|
metadata.Publisher = value
|
||||||
|
case "url":
|
||||||
|
metadata.URL = value
|
||||||
|
case "description", "comment":
|
||||||
|
if metadata.Description == "" {
|
||||||
|
metadata.Description = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return metadata, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func EmbedMetadataToConvertedFile(filePath string, metadata Metadata, coverPath string) error {
|
||||||
|
ext := strings.ToLower(pathfilepath.Ext(filePath))
|
||||||
|
|
||||||
|
switch ext {
|
||||||
|
case ".flac":
|
||||||
|
|
||||||
|
return EmbedMetadata(filePath, metadata, coverPath)
|
||||||
|
case ".mp3":
|
||||||
|
return embedMetadataToMP3(filePath, metadata, coverPath)
|
||||||
|
case ".m4a":
|
||||||
|
return embedMetadataToM4A(filePath, metadata, coverPath)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unsupported file format: %s", ext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func embedMetadataToMP3(filePath string, metadata Metadata, coverPath string) error {
|
||||||
|
tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to open MP3 file: %w", err)
|
||||||
|
}
|
||||||
|
defer tag.Close()
|
||||||
|
|
||||||
|
tag.DeleteFrames("TXXX")
|
||||||
|
|
||||||
|
if metadata.Title != "" {
|
||||||
|
tag.SetTitle(metadata.Title)
|
||||||
|
}
|
||||||
|
if metadata.Artist != "" {
|
||||||
|
tag.SetArtist(metadata.Artist)
|
||||||
|
}
|
||||||
|
if metadata.Album != "" {
|
||||||
|
tag.SetAlbum(metadata.Album)
|
||||||
|
}
|
||||||
|
if metadata.Date != "" {
|
||||||
|
year := metadata.Date
|
||||||
|
if len(year) >= 4 {
|
||||||
|
year = year[:4]
|
||||||
|
}
|
||||||
|
tag.SetYear(year)
|
||||||
|
}
|
||||||
|
|
||||||
|
if metadata.AlbumArtist != "" {
|
||||||
|
tag.DeleteFrames("TPE2")
|
||||||
|
tag.AddTextFrame("TPE2", id3v2.EncodingUTF8, metadata.AlbumArtist)
|
||||||
|
}
|
||||||
|
|
||||||
|
if metadata.TrackNumber > 0 {
|
||||||
|
tag.DeleteFrames(tag.CommonID("Track number/Position in set"))
|
||||||
|
trackStr := strconv.Itoa(metadata.TrackNumber)
|
||||||
|
if metadata.TotalTracks > 0 {
|
||||||
|
trackStr = fmt.Sprintf("%d/%d", metadata.TrackNumber, metadata.TotalTracks)
|
||||||
|
}
|
||||||
|
tag.AddTextFrame(tag.CommonID("Track number/Position in set"), id3v2.EncodingUTF8, trackStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if metadata.DiscNumber > 0 {
|
||||||
|
tag.DeleteFrames(tag.CommonID("Part of a set"))
|
||||||
|
discStr := strconv.Itoa(metadata.DiscNumber)
|
||||||
|
if metadata.TotalDiscs > 0 {
|
||||||
|
discStr = fmt.Sprintf("%d/%d", metadata.DiscNumber, metadata.TotalDiscs)
|
||||||
|
}
|
||||||
|
tag.AddTextFrame(tag.CommonID("Part of a set"), id3v2.EncodingUTF8, discStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if metadata.Copyright != "" {
|
||||||
|
tag.DeleteFrames("TCOP")
|
||||||
|
tag.AddTextFrame("TCOP", id3v2.EncodingUTF8, metadata.Copyright)
|
||||||
|
}
|
||||||
|
|
||||||
|
if metadata.Publisher != "" {
|
||||||
|
tag.DeleteFrames("TPUB")
|
||||||
|
tag.AddTextFrame("TPUB", id3v2.EncodingUTF8, metadata.Publisher)
|
||||||
|
}
|
||||||
|
|
||||||
|
if coverPath != "" && fileExists(coverPath) {
|
||||||
|
|
||||||
|
tag.DeleteFrames(tag.CommonID("Attached picture"))
|
||||||
|
|
||||||
|
artwork, err := os.ReadFile(coverPath)
|
||||||
|
if err == nil {
|
||||||
|
pic := id3v2.PictureFrame{
|
||||||
|
Encoding: id3v2.EncodingUTF8,
|
||||||
|
MimeType: "image/jpeg",
|
||||||
|
PictureType: id3v2.PTFrontCover,
|
||||||
|
Description: "Cover",
|
||||||
|
Picture: artwork,
|
||||||
|
}
|
||||||
|
tag.AddAttachedPicture(pic)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("[EmbedMetadataToMP3] Warning: Failed to read cover art file: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tag.Save(); err != nil {
|
||||||
|
return fmt.Errorf("failed to save MP3 tags: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func embedMetadataToM4A(filePath string, metadata Metadata, coverPath string) error {
|
||||||
|
ffmpegPath, err := GetFFmpegPath()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ffmpeg not found: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ValidateExecutable(ffmpegPath); err != nil {
|
||||||
|
return fmt.Errorf("invalid ffmpeg executable: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
args := []string{
|
||||||
|
"-i", filePath,
|
||||||
|
"-y",
|
||||||
|
}
|
||||||
|
|
||||||
|
if coverPath != "" && fileExists(coverPath) {
|
||||||
|
args = append(args, "-i", coverPath)
|
||||||
|
args = append(args, "-map", "0:a", "-map", "1", "-c:a", "copy", "-c:v", "copy", "-disposition:v:0", "attached_pic")
|
||||||
|
} else {
|
||||||
|
args = append(args, "-map", "0", "-codec", "copy")
|
||||||
|
}
|
||||||
|
|
||||||
|
if metadata.Title != "" {
|
||||||
|
args = append(args, "-metadata", "title="+metadata.Title)
|
||||||
|
}
|
||||||
|
if metadata.Artist != "" {
|
||||||
|
args = append(args, "-metadata", "artist="+metadata.Artist)
|
||||||
|
}
|
||||||
|
if metadata.Album != "" {
|
||||||
|
args = append(args, "-metadata", "album="+metadata.Album)
|
||||||
|
}
|
||||||
|
if metadata.AlbumArtist != "" {
|
||||||
|
args = append(args, "-metadata", "album_artist="+metadata.AlbumArtist)
|
||||||
|
}
|
||||||
|
if metadata.Date != "" {
|
||||||
|
args = append(args, "-metadata", "date="+metadata.Date)
|
||||||
|
}
|
||||||
|
if metadata.TrackNumber > 0 {
|
||||||
|
trackStr := strconv.Itoa(metadata.TrackNumber)
|
||||||
|
if metadata.TotalTracks > 0 {
|
||||||
|
trackStr = fmt.Sprintf("%d/%d", metadata.TrackNumber, metadata.TotalTracks)
|
||||||
|
}
|
||||||
|
args = append(args, "-metadata", "track="+trackStr)
|
||||||
|
}
|
||||||
|
if metadata.DiscNumber > 0 {
|
||||||
|
discStr := strconv.Itoa(metadata.DiscNumber)
|
||||||
|
if metadata.TotalDiscs > 0 {
|
||||||
|
discStr = fmt.Sprintf("%d/%d", metadata.DiscNumber, metadata.TotalDiscs)
|
||||||
|
}
|
||||||
|
args = append(args, "-metadata", "disk="+discStr)
|
||||||
|
}
|
||||||
|
if metadata.Copyright != "" {
|
||||||
|
args = append(args, "-metadata", "copyright="+metadata.Copyright)
|
||||||
|
}
|
||||||
|
if metadata.Publisher != "" {
|
||||||
|
args = append(args, "-metadata", "publisher="+metadata.Publisher)
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpOutputFile := strings.TrimSuffix(filePath, pathfilepath.Ext(filePath)) + ".tmp" + pathfilepath.Ext(filePath)
|
||||||
|
defer func() {
|
||||||
|
if _, err := os.Stat(tmpOutputFile); err == nil {
|
||||||
|
os.Remove(tmpOutputFile)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
args = append(args, "-f", "ipod", tmpOutputFile)
|
||||||
|
|
||||||
|
cmd := exec.Command(ffmpegPath, args...)
|
||||||
|
setHideWindow(cmd)
|
||||||
|
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ffmpeg failed to embed metadata: %s - %w", string(output), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Rename(tmpOutputFile, filePath); err != nil {
|
||||||
|
return fmt.Errorf("failed to replace original file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
+297
-13
@@ -7,7 +7,32 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Global progress tracker
|
type DownloadStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
StatusQueued DownloadStatus = "queued"
|
||||||
|
StatusDownloading DownloadStatus = "downloading"
|
||||||
|
StatusCompleted DownloadStatus = "completed"
|
||||||
|
StatusFailed DownloadStatus = "failed"
|
||||||
|
StatusSkipped DownloadStatus = "skipped"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DownloadItem struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
TrackName string `json:"track_name"`
|
||||||
|
ArtistName string `json:"artist_name"`
|
||||||
|
AlbumName string `json:"album_name"`
|
||||||
|
ISRC string `json:"isrc"`
|
||||||
|
Status DownloadStatus `json:"status"`
|
||||||
|
Progress float64 `json:"progress"`
|
||||||
|
TotalSize float64 `json:"total_size"`
|
||||||
|
Speed float64 `json:"speed"`
|
||||||
|
StartTime int64 `json:"start_time"`
|
||||||
|
EndTime int64 `json:"end_time"`
|
||||||
|
ErrorMessage string `json:"error_message"`
|
||||||
|
FilePath string `json:"file_path"`
|
||||||
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
currentProgress float64
|
currentProgress float64
|
||||||
currentProgressLock sync.RWMutex
|
currentProgressLock sync.RWMutex
|
||||||
@@ -15,16 +40,35 @@ var (
|
|||||||
downloadingLock sync.RWMutex
|
downloadingLock sync.RWMutex
|
||||||
currentSpeed float64
|
currentSpeed float64
|
||||||
speedLock sync.RWMutex
|
speedLock sync.RWMutex
|
||||||
|
|
||||||
|
downloadQueue []DownloadItem
|
||||||
|
downloadQueueLock sync.RWMutex
|
||||||
|
currentItemID string
|
||||||
|
currentItemLock sync.RWMutex
|
||||||
|
totalDownloaded float64
|
||||||
|
totalDownloadedLock sync.RWMutex
|
||||||
|
sessionStartTime int64
|
||||||
|
sessionStartLock sync.RWMutex
|
||||||
)
|
)
|
||||||
|
|
||||||
// ProgressInfo represents download progress information
|
|
||||||
type ProgressInfo struct {
|
type ProgressInfo struct {
|
||||||
IsDownloading bool `json:"is_downloading"`
|
IsDownloading bool `json:"is_downloading"`
|
||||||
MBDownloaded float64 `json:"mb_downloaded"`
|
MBDownloaded float64 `json:"mb_downloaded"`
|
||||||
SpeedMBps float64 `json:"speed_mbps"`
|
SpeedMBps float64 `json:"speed_mbps"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDownloadProgress returns current download progress
|
type DownloadQueueInfo struct {
|
||||||
|
IsDownloading bool `json:"is_downloading"`
|
||||||
|
Queue []DownloadItem `json:"queue"`
|
||||||
|
CurrentSpeed float64 `json:"current_speed"`
|
||||||
|
TotalDownloaded float64 `json:"total_downloaded"`
|
||||||
|
SessionStartTime int64 `json:"session_start_time"`
|
||||||
|
QueuedCount int `json:"queued_count"`
|
||||||
|
CompletedCount int `json:"completed_count"`
|
||||||
|
FailedCount int `json:"failed_count"`
|
||||||
|
SkippedCount int `json:"skipped_count"`
|
||||||
|
}
|
||||||
|
|
||||||
func GetDownloadProgress() ProgressInfo {
|
func GetDownloadProgress() ProgressInfo {
|
||||||
downloadingLock.RLock()
|
downloadingLock.RLock()
|
||||||
downloading := isDownloading
|
downloading := isDownloading
|
||||||
@@ -45,34 +89,30 @@ func GetDownloadProgress() ProgressInfo {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetDownloadSpeed updates the current download speed
|
|
||||||
func SetDownloadSpeed(mbps float64) {
|
func SetDownloadSpeed(mbps float64) {
|
||||||
speedLock.Lock()
|
speedLock.Lock()
|
||||||
currentSpeed = mbps
|
currentSpeed = mbps
|
||||||
speedLock.Unlock()
|
speedLock.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetDownloadProgress updates the current download progress
|
|
||||||
func SetDownloadProgress(mbDownloaded float64) {
|
func SetDownloadProgress(mbDownloaded float64) {
|
||||||
currentProgressLock.Lock()
|
currentProgressLock.Lock()
|
||||||
currentProgress = mbDownloaded
|
currentProgress = mbDownloaded
|
||||||
currentProgressLock.Unlock()
|
currentProgressLock.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetDownloading sets the downloading state
|
|
||||||
func SetDownloading(downloading bool) {
|
func SetDownloading(downloading bool) {
|
||||||
downloadingLock.Lock()
|
downloadingLock.Lock()
|
||||||
isDownloading = downloading
|
isDownloading = downloading
|
||||||
downloadingLock.Unlock()
|
downloadingLock.Unlock()
|
||||||
|
|
||||||
if !downloading {
|
if !downloading {
|
||||||
// Reset progress when download completes
|
|
||||||
SetDownloadProgress(0)
|
SetDownloadProgress(0)
|
||||||
SetDownloadSpeed(0)
|
SetDownloadSpeed(0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProgressWriter wraps an io.Writer and reports download progress
|
|
||||||
type ProgressWriter struct {
|
type ProgressWriter struct {
|
||||||
writer io.Writer
|
writer io.Writer
|
||||||
total int64
|
total int64
|
||||||
@@ -80,6 +120,7 @@ type ProgressWriter struct {
|
|||||||
startTime int64
|
startTime int64
|
||||||
lastTime int64
|
lastTime int64
|
||||||
lastBytes int64
|
lastBytes int64
|
||||||
|
itemID string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewProgressWriter(writer io.Writer) *ProgressWriter {
|
func NewProgressWriter(writer io.Writer) *ProgressWriter {
|
||||||
@@ -91,9 +132,16 @@ func NewProgressWriter(writer io.Writer) *ProgressWriter {
|
|||||||
startTime: now,
|
startTime: now,
|
||||||
lastTime: now,
|
lastTime: now,
|
||||||
lastBytes: 0,
|
lastBytes: 0,
|
||||||
|
itemID: "",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func NewProgressWriterWithID(writer io.Writer, itemID string) *ProgressWriter {
|
||||||
|
pw := NewProgressWriter(writer)
|
||||||
|
pw.itemID = itemID
|
||||||
|
return pw
|
||||||
|
}
|
||||||
|
|
||||||
func getCurrentTimeMillis() int64 {
|
func getCurrentTimeMillis() int64 {
|
||||||
return time.Now().UnixMilli()
|
return time.Now().UnixMilli()
|
||||||
}
|
}
|
||||||
@@ -102,26 +150,28 @@ func (pw *ProgressWriter) Write(p []byte) (int, error) {
|
|||||||
n, err := pw.writer.Write(p)
|
n, err := pw.writer.Write(p)
|
||||||
pw.total += int64(n)
|
pw.total += int64(n)
|
||||||
|
|
||||||
// Report progress every 256KB for smoother updates
|
|
||||||
if pw.total-pw.lastPrinted >= 256*1024 {
|
if pw.total-pw.lastPrinted >= 256*1024 {
|
||||||
mbDownloaded := float64(pw.total) / (1024 * 1024)
|
mbDownloaded := float64(pw.total) / (1024 * 1024)
|
||||||
|
|
||||||
// Calculate speed (MB/s)
|
|
||||||
now := getCurrentTimeMillis()
|
now := getCurrentTimeMillis()
|
||||||
timeDiff := float64(now-pw.lastTime) / 1000.0 // seconds
|
timeDiff := float64(now-pw.lastTime) / 1000.0
|
||||||
bytesDiff := float64(pw.total - pw.lastBytes)
|
bytesDiff := float64(pw.total - pw.lastBytes)
|
||||||
|
|
||||||
|
var speedMBps float64
|
||||||
if timeDiff > 0 {
|
if timeDiff > 0 {
|
||||||
speedMBps := (bytesDiff / (1024 * 1024)) / timeDiff
|
speedMBps = (bytesDiff / (1024 * 1024)) / timeDiff
|
||||||
SetDownloadSpeed(speedMBps)
|
SetDownloadSpeed(speedMBps)
|
||||||
fmt.Printf("\rDownloaded: %.2f MB (%.2f MB/s)", mbDownloaded, speedMBps)
|
fmt.Printf("\rDownloaded: %.2f MB (%.2f MB/s)", mbDownloaded, speedMBps)
|
||||||
} else {
|
} else {
|
||||||
fmt.Printf("\rDownloaded: %.2f MB", mbDownloaded)
|
fmt.Printf("\rDownloaded: %.2f MB", mbDownloaded)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update global progress
|
|
||||||
SetDownloadProgress(mbDownloaded)
|
SetDownloadProgress(mbDownloaded)
|
||||||
|
|
||||||
|
if pw.itemID != "" {
|
||||||
|
UpdateItemProgress(pw.itemID, mbDownloaded, speedMBps)
|
||||||
|
}
|
||||||
|
|
||||||
pw.lastPrinted = pw.total
|
pw.lastPrinted = pw.total
|
||||||
pw.lastTime = now
|
pw.lastTime = now
|
||||||
pw.lastBytes = pw.total
|
pw.lastBytes = pw.total
|
||||||
@@ -133,3 +183,237 @@ func (pw *ProgressWriter) Write(p []byte) (int, error) {
|
|||||||
func (pw *ProgressWriter) GetTotal() int64 {
|
func (pw *ProgressWriter) GetTotal() int64 {
|
||||||
return pw.total
|
return pw.total
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func AddToQueue(id, trackName, artistName, albumName, isrc string) {
|
||||||
|
downloadQueueLock.Lock()
|
||||||
|
defer downloadQueueLock.Unlock()
|
||||||
|
|
||||||
|
item := DownloadItem{
|
||||||
|
ID: id,
|
||||||
|
TrackName: trackName,
|
||||||
|
ArtistName: artistName,
|
||||||
|
AlbumName: albumName,
|
||||||
|
ISRC: isrc,
|
||||||
|
Status: StatusQueued,
|
||||||
|
Progress: 0,
|
||||||
|
TotalSize: 0,
|
||||||
|
Speed: 0,
|
||||||
|
StartTime: 0,
|
||||||
|
EndTime: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadQueue = append(downloadQueue, item)
|
||||||
|
|
||||||
|
sessionStartLock.Lock()
|
||||||
|
if sessionStartTime == 0 {
|
||||||
|
sessionStartTime = time.Now().Unix()
|
||||||
|
}
|
||||||
|
sessionStartLock.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func StartDownloadItem(id string) {
|
||||||
|
downloadQueueLock.Lock()
|
||||||
|
defer downloadQueueLock.Unlock()
|
||||||
|
|
||||||
|
for i := range downloadQueue {
|
||||||
|
if downloadQueue[i].ID == id {
|
||||||
|
downloadQueue[i].Status = StatusDownloading
|
||||||
|
downloadQueue[i].StartTime = time.Now().Unix()
|
||||||
|
downloadQueue[i].Progress = 0
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
currentItemLock.Lock()
|
||||||
|
currentItemID = id
|
||||||
|
currentItemLock.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func UpdateItemProgress(id string, progress, speed float64) {
|
||||||
|
downloadQueueLock.Lock()
|
||||||
|
defer downloadQueueLock.Unlock()
|
||||||
|
|
||||||
|
for i := range downloadQueue {
|
||||||
|
if downloadQueue[i].ID == id {
|
||||||
|
downloadQueue[i].Progress = progress
|
||||||
|
downloadQueue[i].Speed = speed
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetCurrentItemID() string {
|
||||||
|
currentItemLock.RLock()
|
||||||
|
defer currentItemLock.RUnlock()
|
||||||
|
return currentItemID
|
||||||
|
}
|
||||||
|
|
||||||
|
func CompleteDownloadItem(id, filePath string, finalSize float64) {
|
||||||
|
downloadQueueLock.Lock()
|
||||||
|
defer downloadQueueLock.Unlock()
|
||||||
|
|
||||||
|
for i := range downloadQueue {
|
||||||
|
if downloadQueue[i].ID == id {
|
||||||
|
downloadQueue[i].Status = StatusCompleted
|
||||||
|
downloadQueue[i].EndTime = time.Now().Unix()
|
||||||
|
downloadQueue[i].FilePath = filePath
|
||||||
|
downloadQueue[i].Progress = finalSize
|
||||||
|
downloadQueue[i].TotalSize = finalSize
|
||||||
|
|
||||||
|
totalDownloadedLock.Lock()
|
||||||
|
totalDownloaded += finalSize
|
||||||
|
totalDownloadedLock.Unlock()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func FailDownloadItem(id, errorMsg string) {
|
||||||
|
downloadQueueLock.Lock()
|
||||||
|
defer downloadQueueLock.Unlock()
|
||||||
|
|
||||||
|
for i := range downloadQueue {
|
||||||
|
if downloadQueue[i].ID == id {
|
||||||
|
downloadQueue[i].Status = StatusFailed
|
||||||
|
downloadQueue[i].EndTime = time.Now().Unix()
|
||||||
|
downloadQueue[i].ErrorMessage = errorMsg
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func SkipDownloadItem(id, filePath string) {
|
||||||
|
downloadQueueLock.Lock()
|
||||||
|
defer downloadQueueLock.Unlock()
|
||||||
|
|
||||||
|
for i := range downloadQueue {
|
||||||
|
if downloadQueue[i].ID == id {
|
||||||
|
downloadQueue[i].Status = StatusSkipped
|
||||||
|
downloadQueue[i].EndTime = time.Now().Unix()
|
||||||
|
downloadQueue[i].FilePath = filePath
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetDownloadQueue() DownloadQueueInfo {
|
||||||
|
|
||||||
|
ResetSessionIfComplete()
|
||||||
|
|
||||||
|
downloadQueueLock.RLock()
|
||||||
|
defer downloadQueueLock.RUnlock()
|
||||||
|
|
||||||
|
downloadingLock.RLock()
|
||||||
|
downloading := isDownloading
|
||||||
|
downloadingLock.RUnlock()
|
||||||
|
|
||||||
|
speedLock.RLock()
|
||||||
|
speed := currentSpeed
|
||||||
|
speedLock.RUnlock()
|
||||||
|
|
||||||
|
totalDownloadedLock.RLock()
|
||||||
|
total := totalDownloaded
|
||||||
|
totalDownloadedLock.RUnlock()
|
||||||
|
|
||||||
|
sessionStartLock.RLock()
|
||||||
|
sessionStart := sessionStartTime
|
||||||
|
sessionStartLock.RUnlock()
|
||||||
|
|
||||||
|
var queued, completed, failed, skipped int
|
||||||
|
for _, item := range downloadQueue {
|
||||||
|
switch item.Status {
|
||||||
|
case StatusQueued:
|
||||||
|
queued++
|
||||||
|
case StatusCompleted:
|
||||||
|
completed++
|
||||||
|
case StatusFailed:
|
||||||
|
failed++
|
||||||
|
case StatusSkipped:
|
||||||
|
skipped++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
queueCopy := make([]DownloadItem, len(downloadQueue))
|
||||||
|
copy(queueCopy, downloadQueue)
|
||||||
|
|
||||||
|
return DownloadQueueInfo{
|
||||||
|
IsDownloading: downloading,
|
||||||
|
Queue: queueCopy,
|
||||||
|
CurrentSpeed: speed,
|
||||||
|
TotalDownloaded: total,
|
||||||
|
SessionStartTime: sessionStart,
|
||||||
|
QueuedCount: queued,
|
||||||
|
CompletedCount: completed,
|
||||||
|
FailedCount: failed,
|
||||||
|
SkippedCount: skipped,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ClearDownloadQueue() {
|
||||||
|
downloadQueueLock.Lock()
|
||||||
|
defer downloadQueueLock.Unlock()
|
||||||
|
|
||||||
|
newQueue := make([]DownloadItem, 0)
|
||||||
|
for _, item := range downloadQueue {
|
||||||
|
if item.Status == StatusQueued || item.Status == StatusDownloading {
|
||||||
|
newQueue = append(newQueue, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
downloadQueue = newQueue
|
||||||
|
}
|
||||||
|
|
||||||
|
func ClearAllDownloads() {
|
||||||
|
downloadQueueLock.Lock()
|
||||||
|
downloadQueue = []DownloadItem{}
|
||||||
|
downloadQueueLock.Unlock()
|
||||||
|
|
||||||
|
totalDownloadedLock.Lock()
|
||||||
|
totalDownloaded = 0
|
||||||
|
totalDownloadedLock.Unlock()
|
||||||
|
|
||||||
|
sessionStartLock.Lock()
|
||||||
|
sessionStartTime = 0
|
||||||
|
sessionStartLock.Unlock()
|
||||||
|
|
||||||
|
currentItemLock.Lock()
|
||||||
|
currentItemID = ""
|
||||||
|
currentItemLock.Unlock()
|
||||||
|
|
||||||
|
SetDownloadProgress(0)
|
||||||
|
SetDownloadSpeed(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func CancelAllQueuedItems() {
|
||||||
|
downloadQueueLock.Lock()
|
||||||
|
defer downloadQueueLock.Unlock()
|
||||||
|
|
||||||
|
for i := range downloadQueue {
|
||||||
|
if downloadQueue[i].Status == StatusQueued {
|
||||||
|
downloadQueue[i].Status = StatusSkipped
|
||||||
|
downloadQueue[i].EndTime = time.Now().Unix()
|
||||||
|
downloadQueue[i].ErrorMessage = "Cancelled"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResetSessionIfComplete() {
|
||||||
|
downloadQueueLock.RLock()
|
||||||
|
hasActiveOrQueued := false
|
||||||
|
for _, item := range downloadQueue {
|
||||||
|
if item.Status == StatusQueued || item.Status == StatusDownloading {
|
||||||
|
hasActiveOrQueued = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
downloadQueueLock.RUnlock()
|
||||||
|
|
||||||
|
if !hasActiveOrQueued {
|
||||||
|
sessionStartLock.Lock()
|
||||||
|
sessionStartTime = 0
|
||||||
|
sessionStartLock.Unlock()
|
||||||
|
|
||||||
|
totalDownloadedLock.Lock()
|
||||||
|
totalDownloaded = 0
|
||||||
|
totalDownloadedLock.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+119
-83
@@ -8,6 +8,8 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -76,7 +78,7 @@ func NewQobuzDownloader() *QobuzDownloader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (q *QobuzDownloader) SearchByISRC(isrc string) (*QobuzTrack, error) {
|
func (q *QobuzDownloader) SearchByISRC(isrc string) (*QobuzTrack, error) {
|
||||||
// Decode base64 API URL
|
|
||||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
|
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
|
||||||
url := fmt.Sprintf("%s%s&limit=1&app_id=%s", string(apiBase), isrc, q.appID)
|
url := fmt.Sprintf("%s%s&limit=1&app_id=%s", string(apiBase), isrc, q.appID)
|
||||||
|
|
||||||
@@ -91,8 +93,23 @@ func (q *QobuzDownloader) SearchByISRC(isrc string) (*QobuzTrack, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var searchResp QobuzSearchResponse
|
var searchResp QobuzSearchResponse
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
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, &searchResp); 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(searchResp.Tracks.Items) == 0 {
|
if len(searchResp.Tracks.Items) == 0 {
|
||||||
@@ -103,17 +120,19 @@ func (q *QobuzDownloader) SearchByISRC(isrc string) (*QobuzTrack, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string, error) {
|
func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string, error) {
|
||||||
// Map quality to Qobuz quality code
|
|
||||||
// Qobuz uses: 5 (MP3 320), 6 (FLAC 16-bit), 7 (FLAC 24-bit), 27 (Hi-Res)
|
|
||||||
qualityCode := "27" // Default to Hi-Res
|
|
||||||
|
|
||||||
fmt.Printf("Getting download URL for track ID: %d\n", trackID)
|
qualityCode := quality
|
||||||
|
if qualityCode == "" {
|
||||||
|
qualityCode = "6"
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
// Decode base64 API URLs
|
|
||||||
primaryBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9kYWIueWVldC5zdS9hcGkvc3RyZWFtP3RyYWNrSWQ9")
|
primaryBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9kYWIueWVldC5zdS9hcGkvc3RyZWFtP3RyYWNrSWQ9")
|
||||||
|
|
||||||
// Try primary API first
|
|
||||||
primaryURL := fmt.Sprintf("%s%d&quality=%s", string(primaryBase), trackID, qualityCode)
|
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)
|
resp, err := q.client.Get(primaryURL)
|
||||||
if err == nil && resp.StatusCode == 200 {
|
if err == nil && resp.StatusCode == 200 {
|
||||||
@@ -132,7 +151,6 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string,
|
|||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to secondary API
|
|
||||||
fmt.Println("Primary API failed, trying fallback...")
|
fmt.Println("Primary API failed, trying fallback...")
|
||||||
fallbackBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9kYWJtdXNpYy54eXovYXBpL3N0cmVhbT90cmFja0lkPQ==")
|
fallbackBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9kYWJtdXNpYy54eXovYXBpL3N0cmVhbT90cmFja0lkPQ==")
|
||||||
fallbackURL := fmt.Sprintf("%s%d&quality=%s", string(fallbackBase), trackID, qualityCode)
|
fallbackURL := fmt.Sprintf("%s%d&quality=%s", string(fallbackBase), trackID, qualityCode)
|
||||||
@@ -149,12 +167,25 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string,
|
|||||||
return "", fmt.Errorf("API returned status %d", resp.StatusCode)
|
return "", fmt.Errorf("API returned status %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to read response body: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(body) == 0 {
|
||||||
|
return "", fmt.Errorf("API returned empty response")
|
||||||
|
}
|
||||||
|
|
||||||
fmt.Printf("Fallback API response: %s\n", string(body))
|
fmt.Printf("Fallback API response: %s\n", string(body))
|
||||||
|
|
||||||
var streamResp QobuzStreamResponse
|
var streamResp QobuzStreamResponse
|
||||||
if err := json.Unmarshal(body, &streamResp); err != nil {
|
if err := json.Unmarshal(body, &streamResp); err != nil {
|
||||||
return "", fmt.Errorf("failed to decode response: %w", err)
|
|
||||||
|
bodyStr := string(body)
|
||||||
|
if len(bodyStr) > 200 {
|
||||||
|
bodyStr = bodyStr[:200] + "..."
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("failed to decode response: %w (response: %s)", err, bodyStr)
|
||||||
}
|
}
|
||||||
|
|
||||||
if streamResp.URL == "" {
|
if streamResp.URL == "" {
|
||||||
@@ -167,7 +198,12 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string,
|
|||||||
|
|
||||||
func (q *QobuzDownloader) DownloadFile(url, filepath string) error {
|
func (q *QobuzDownloader) DownloadFile(url, filepath string) error {
|
||||||
fmt.Println("Starting file download...")
|
fmt.Println("Starting file download...")
|
||||||
resp, err := q.client.Get(url)
|
|
||||||
|
downloadClient := &http.Client{
|
||||||
|
Timeout: 5 * time.Minute,
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := downloadClient.Get(url)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to download file: %w", err)
|
return fmt.Errorf("failed to download file: %w", err)
|
||||||
}
|
}
|
||||||
@@ -185,14 +221,13 @@ func (q *QobuzDownloader) DownloadFile(url, filepath string) error {
|
|||||||
defer out.Close()
|
defer out.Close()
|
||||||
|
|
||||||
fmt.Println("Downloading...")
|
fmt.Println("Downloading...")
|
||||||
// Use progress writer to track download
|
|
||||||
pw := NewProgressWriter(out)
|
pw := NewProgressWriter(out)
|
||||||
_, err = io.Copy(pw, resp.Body)
|
_, err = io.Copy(pw, resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to write file: %w", err)
|
return fmt.Errorf("failed to write file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Print final size
|
|
||||||
fmt.Printf("\rDownloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024))
|
fmt.Printf("\rDownloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -222,70 +257,78 @@ func (q *QobuzDownloader) DownloadCoverArt(coverURL, filepath string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildQobuzFilename(title, artist string, trackNumber int, format string, includeTrackNumber bool, position int, useAlbumTrackNumber bool) string {
|
func buildQobuzFilename(title, artist, album, albumArtist, releaseDate string, trackNumber, discNumber int, format string, includeTrackNumber bool, position int, useAlbumTrackNumber bool) string {
|
||||||
var filename string
|
var filename string
|
||||||
|
|
||||||
// Build base filename based on format
|
numberToUse := position
|
||||||
switch format {
|
if useAlbumTrackNumber && trackNumber > 0 {
|
||||||
case "artist-title":
|
numberToUse = trackNumber
|
||||||
filename = fmt.Sprintf("%s - %s", artist, title)
|
|
||||||
case "title":
|
|
||||||
filename = title
|
|
||||||
default: // "title-artist"
|
|
||||||
filename = fmt.Sprintf("%s - %s", title, artist)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add track number prefix if enabled
|
year := ""
|
||||||
if includeTrackNumber && position > 0 {
|
if len(releaseDate) >= 4 {
|
||||||
// Use album track number if in album folder structure, otherwise use playlist position
|
year = releaseDate[:4]
|
||||||
numberToUse := position
|
}
|
||||||
if useAlbumTrackNumber && trackNumber > 0 {
|
|
||||||
numberToUse = trackNumber
|
if strings.Contains(format, "{") {
|
||||||
|
filename = format
|
||||||
|
filename = strings.ReplaceAll(filename, "{title}", title)
|
||||||
|
filename = strings.ReplaceAll(filename, "{artist}", artist)
|
||||||
|
filename = strings.ReplaceAll(filename, "{album}", album)
|
||||||
|
filename = strings.ReplaceAll(filename, "{album_artist}", albumArtist)
|
||||||
|
filename = strings.ReplaceAll(filename, "{year}", year)
|
||||||
|
|
||||||
|
if discNumber > 0 {
|
||||||
|
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
|
||||||
|
} else {
|
||||||
|
filename = strings.ReplaceAll(filename, "{disc}", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
if numberToUse > 0 {
|
||||||
|
filename = strings.ReplaceAll(filename, "{track}", fmt.Sprintf("%02d", numberToUse))
|
||||||
|
} else {
|
||||||
|
|
||||||
|
filename = regexp.MustCompile(`\{track\}\.\s*`).ReplaceAllString(filename, "")
|
||||||
|
filename = regexp.MustCompile(`\{track\}\s*-\s*`).ReplaceAllString(filename, "")
|
||||||
|
filename = regexp.MustCompile(`\{track\}\s*`).ReplaceAllString(filename, "")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
|
||||||
|
switch format {
|
||||||
|
case "artist-title":
|
||||||
|
filename = fmt.Sprintf("%s - %s", artist, title)
|
||||||
|
case "title":
|
||||||
|
filename = title
|
||||||
|
default:
|
||||||
|
filename = fmt.Sprintf("%s - %s", title, artist)
|
||||||
|
}
|
||||||
|
|
||||||
|
if includeTrackNumber && position > 0 {
|
||||||
|
filename = fmt.Sprintf("%02d. %s", numberToUse, filename)
|
||||||
}
|
}
|
||||||
filename = fmt.Sprintf("%02d. %s", numberToUse, filename)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return filename + ".flac"
|
return filename + ".flac"
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *QobuzDownloader) DownloadByISRC(isrc, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName string, useAlbumTrackNumber bool) (string, error) {
|
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", isrc)
|
fmt.Printf("Fetching track info for ISRC: %s\n", deezerISRC)
|
||||||
|
|
||||||
// Create output directory if it doesn't exist
|
|
||||||
if outputDir != "." {
|
if outputDir != "." {
|
||||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||||
return "", fmt.Errorf("failed to create output directory: %w", err)
|
return "", fmt.Errorf("failed to create output directory: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
track, err := q.SearchByISRC(isrc)
|
track, err := q.SearchByISRC(deezerISRC)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use Spotify metadata if provided, otherwise fallback to Qobuz metadata
|
|
||||||
artists := spotifyArtistName
|
artists := spotifyArtistName
|
||||||
trackTitle := spotifyTrackName
|
trackTitle := spotifyTrackName
|
||||||
albumTitle := spotifyAlbumName
|
albumTitle := spotifyAlbumName
|
||||||
|
|
||||||
if artists == "" {
|
|
||||||
artists = track.Performer.Name
|
|
||||||
if track.Album.Artist.Name != "" {
|
|
||||||
artists = track.Album.Artist.Name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if trackTitle == "" {
|
|
||||||
trackTitle = track.Title
|
|
||||||
if track.Version != "" && track.Version != "null" {
|
|
||||||
trackTitle = fmt.Sprintf("%s (%s)", track.Title, track.Version)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if albumTitle == "" {
|
|
||||||
albumTitle = track.Album.Title
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("Found track: %s - %s\n", artists, trackTitle)
|
fmt.Printf("Found track: %s - %s\n", artists, trackTitle)
|
||||||
fmt.Printf("Album: %s\n", albumTitle)
|
fmt.Printf("Album: %s\n", albumTitle)
|
||||||
|
|
||||||
@@ -305,7 +348,6 @@ func (q *QobuzDownloader) DownloadByISRC(isrc, outputDir, quality, filenameForma
|
|||||||
return "", fmt.Errorf("received empty download URL")
|
return "", fmt.Errorf("received empty download URL")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show partial URL for security
|
|
||||||
urlPreview := downloadURL
|
urlPreview := downloadURL
|
||||||
if len(downloadURL) > 60 {
|
if len(downloadURL) > 60 {
|
||||||
urlPreview = downloadURL[:60] + "..."
|
urlPreview = downloadURL[:60] + "..."
|
||||||
@@ -314,15 +356,10 @@ func (q *QobuzDownloader) DownloadByISRC(isrc, outputDir, quality, filenameForma
|
|||||||
|
|
||||||
safeArtist := sanitizeFilename(artists)
|
safeArtist := sanitizeFilename(artists)
|
||||||
safeTitle := sanitizeFilename(trackTitle)
|
safeTitle := sanitizeFilename(trackTitle)
|
||||||
|
safeAlbum := sanitizeFilename(albumTitle)
|
||||||
|
safeAlbumArtist := sanitizeFilename(spotifyAlbumArtist)
|
||||||
|
|
||||||
// Check if file with same ISRC already exists
|
filename := buildQobuzFilename(safeTitle, safeArtist, safeAlbum, safeAlbumArtist, spotifyReleaseDate, spotifyTrackNumber, spotifyDiscNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
|
||||||
if existingFile, exists := CheckISRCExists(outputDir, track.ISRC); exists {
|
|
||||||
fmt.Printf("File with ISRC %s already exists: %s\n", track.ISRC, existingFile)
|
|
||||||
return "EXISTS:" + existingFile, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build filename based on format settings
|
|
||||||
filename := buildQobuzFilename(safeTitle, safeArtist, track.TrackNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
|
|
||||||
filepath := filepath.Join(outputDir, filename)
|
filepath := filepath.Join(outputDir, filename)
|
||||||
|
|
||||||
if fileInfo, err := os.Stat(filepath); err == nil && fileInfo.Size() > 0 {
|
if fileInfo, err := os.Stat(filepath); err == nil && fileInfo.Size() > 0 {
|
||||||
@@ -338,41 +375,40 @@ func (q *QobuzDownloader) DownloadByISRC(isrc, outputDir, quality, filenameForma
|
|||||||
fmt.Printf("Downloaded: %s\n", filepath)
|
fmt.Printf("Downloaded: %s\n", filepath)
|
||||||
|
|
||||||
coverPath := ""
|
coverPath := ""
|
||||||
if track.Album.Image.Large != "" {
|
|
||||||
|
if spotifyCoverURL != "" {
|
||||||
coverPath = filepath + ".cover.jpg"
|
coverPath = filepath + ".cover.jpg"
|
||||||
fmt.Println("Downloading cover art...")
|
coverClient := NewCoverClient()
|
||||||
if err := q.DownloadCoverArt(track.Album.Image.Large, coverPath); err != nil {
|
if err := coverClient.DownloadCoverToPath(spotifyCoverURL, coverPath, embedMaxQualityCover); err != nil {
|
||||||
fmt.Printf("Warning: Failed to download cover art: %v\n", err)
|
fmt.Printf("Warning: Failed to download Spotify cover: %v\n", err)
|
||||||
|
coverPath = ""
|
||||||
} else {
|
} else {
|
||||||
defer os.Remove(coverPath)
|
defer os.Remove(coverPath)
|
||||||
|
fmt.Println("Spotify cover downloaded")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("Embedding metadata and cover art...")
|
fmt.Println("Embedding metadata and cover art...")
|
||||||
|
|
||||||
releaseYear := ""
|
trackNumberToEmbed := spotifyTrackNumber
|
||||||
if len(track.ReleaseDateOriginal) >= 4 {
|
if trackNumberToEmbed == 0 {
|
||||||
releaseYear = track.ReleaseDateOriginal[:4]
|
trackNumberToEmbed = 1
|
||||||
}
|
|
||||||
|
|
||||||
// Use album track number if in album folder structure, otherwise use playlist position
|
|
||||||
trackNumberToEmbed := 0
|
|
||||||
if position > 0 {
|
|
||||||
if useAlbumTrackNumber && track.TrackNumber > 0 {
|
|
||||||
trackNumberToEmbed = track.TrackNumber
|
|
||||||
} else {
|
|
||||||
trackNumberToEmbed = position
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
metadata := Metadata{
|
metadata := Metadata{
|
||||||
Title: trackTitle,
|
Title: trackTitle,
|
||||||
Artist: artists,
|
Artist: artists,
|
||||||
Album: albumTitle,
|
Album: albumTitle,
|
||||||
Date: releaseYear,
|
AlbumArtist: spotifyAlbumArtist,
|
||||||
|
Date: spotifyReleaseDate,
|
||||||
TrackNumber: trackNumberToEmbed,
|
TrackNumber: trackNumberToEmbed,
|
||||||
DiscNumber: track.MediaNumber,
|
TotalTracks: spotifyTotalTracks,
|
||||||
ISRC: track.ISRC,
|
DiscNumber: spotifyDiscNumber,
|
||||||
|
TotalDiscs: spotifyTotalDiscs,
|
||||||
|
URL: spotifyURL,
|
||||||
|
Copyright: spotifyCopyright,
|
||||||
|
Publisher: spotifyPublisher,
|
||||||
|
Description: "https://github.com/afkarxyz/SpotiFLAC",
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := EmbedMetadata(filepath, metadata, coverPath); err != nil {
|
if err := EmbedMetadata(filepath, metadata, coverPath); err != nil {
|
||||||
|
|||||||
+21
-35
@@ -5,7 +5,6 @@ import (
|
|||||||
"unicode"
|
"unicode"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Hiragana to Romaji mapping
|
|
||||||
var hiraganaToRomaji = map[rune]string{
|
var hiraganaToRomaji = map[rune]string{
|
||||||
'あ': "a", 'い': "i", 'う': "u", 'え': "e", 'お': "o",
|
'あ': "a", 'い': "i", 'う': "u", 'え': "e", 'お': "o",
|
||||||
'か': "ka", 'き': "ki", 'く': "ku", 'け': "ke", 'こ': "ko",
|
'か': "ka", 'き': "ki", 'く': "ku", 'け': "ke", 'こ': "ko",
|
||||||
@@ -17,20 +16,19 @@ var hiraganaToRomaji = map[rune]string{
|
|||||||
'や': "ya", 'ゆ': "yu", 'よ': "yo",
|
'や': "ya", 'ゆ': "yu", 'よ': "yo",
|
||||||
'ら': "ra", 'り': "ri", 'る': "ru", 'れ': "re", 'ろ': "ro",
|
'ら': "ra", 'り': "ri", 'る': "ru", 'れ': "re", 'ろ': "ro",
|
||||||
'わ': "wa", 'を': "wo", 'ん': "n",
|
'わ': "wa", 'を': "wo", 'ん': "n",
|
||||||
// Dakuten (voiced)
|
|
||||||
'が': "ga", 'ぎ': "gi", 'ぐ': "gu", 'げ': "ge", 'ご': "go",
|
'が': "ga", 'ぎ': "gi", 'ぐ': "gu", 'げ': "ge", 'ご': "go",
|
||||||
'ざ': "za", 'じ': "ji", 'ず': "zu", 'ぜ': "ze", 'ぞ': "zo",
|
'ざ': "za", 'じ': "ji", 'ず': "zu", 'ぜ': "ze", 'ぞ': "zo",
|
||||||
'だ': "da", 'ぢ': "ji", 'づ': "zu", 'で': "de", 'ど': "do",
|
'だ': "da", 'ぢ': "ji", 'づ': "zu", 'で': "de", 'ど': "do",
|
||||||
'ば': "ba", 'び': "bi", 'ぶ': "bu", 'べ': "be", 'ぼ': "bo",
|
'ば': "ba", 'び': "bi", 'ぶ': "bu", 'べ': "be", 'ぼ': "bo",
|
||||||
// Handakuten (semi-voiced)
|
|
||||||
'ぱ': "pa", 'ぴ': "pi", 'ぷ': "pu", 'ぺ': "pe", 'ぽ': "po",
|
'ぱ': "pa", 'ぴ': "pi", 'ぷ': "pu", 'ぺ': "pe", 'ぽ': "po",
|
||||||
// Small characters
|
|
||||||
'ゃ': "ya", 'ゅ': "yu", 'ょ': "yo",
|
'ゃ': "ya", 'ゅ': "yu", 'ょ': "yo",
|
||||||
'っ': "", // Double consonant marker
|
'っ': "",
|
||||||
'ぁ': "a", 'ぃ': "i", 'ぅ': "u", 'ぇ': "e", 'ぉ': "o",
|
'ぁ': "a", 'ぃ': "i", 'ぅ': "u", 'ぇ': "e", 'ぉ': "o",
|
||||||
}
|
}
|
||||||
|
|
||||||
// Katakana to Romaji mapping
|
|
||||||
var katakanaToRomaji = map[rune]string{
|
var katakanaToRomaji = map[rune]string{
|
||||||
'ア': "a", 'イ': "i", 'ウ': "u", 'エ': "e", 'オ': "o",
|
'ア': "a", 'イ': "i", 'ウ': "u", 'エ': "e", 'オ': "o",
|
||||||
'カ': "ka", 'キ': "ki", 'ク': "ku", 'ケ': "ke", 'コ': "ko",
|
'カ': "ka", 'キ': "ki", 'ク': "ku", 'ケ': "ke", 'コ': "ko",
|
||||||
@@ -42,23 +40,22 @@ var katakanaToRomaji = map[rune]string{
|
|||||||
'ヤ': "ya", 'ユ': "yu", 'ヨ': "yo",
|
'ヤ': "ya", 'ユ': "yu", 'ヨ': "yo",
|
||||||
'ラ': "ra", 'リ': "ri", 'ル': "ru", 'レ': "re", 'ロ': "ro",
|
'ラ': "ra", 'リ': "ri", 'ル': "ru", 'レ': "re", 'ロ': "ro",
|
||||||
'ワ': "wa", 'ヲ': "wo", 'ン': "n",
|
'ワ': "wa", 'ヲ': "wo", 'ン': "n",
|
||||||
// Dakuten (voiced)
|
|
||||||
'ガ': "ga", 'ギ': "gi", 'グ': "gu", 'ゲ': "ge", 'ゴ': "go",
|
'ガ': "ga", 'ギ': "gi", 'グ': "gu", 'ゲ': "ge", 'ゴ': "go",
|
||||||
'ザ': "za", 'ジ': "ji", 'ズ': "zu", 'ゼ': "ze", 'ゾ': "zo",
|
'ザ': "za", 'ジ': "ji", 'ズ': "zu", 'ゼ': "ze", 'ゾ': "zo",
|
||||||
'ダ': "da", 'ヂ': "ji", 'ヅ': "zu", 'デ': "de", 'ド': "do",
|
'ダ': "da", 'ヂ': "ji", 'ヅ': "zu", 'デ': "de", 'ド': "do",
|
||||||
'バ': "ba", 'ビ': "bi", 'ブ': "bu", 'ベ': "be", 'ボ': "bo",
|
'バ': "ba", 'ビ': "bi", 'ブ': "bu", 'ベ': "be", 'ボ': "bo",
|
||||||
// Handakuten (semi-voiced)
|
|
||||||
'パ': "pa", 'ピ': "pi", 'プ': "pu", 'ペ': "pe", 'ポ': "po",
|
'パ': "pa", 'ピ': "pi", 'プ': "pu", 'ペ': "pe", 'ポ': "po",
|
||||||
// Small characters
|
|
||||||
'ャ': "ya", 'ュ': "yu", 'ョ': "yo",
|
'ャ': "ya", 'ュ': "yu", 'ョ': "yo",
|
||||||
'ッ': "", // Double consonant marker
|
'ッ': "",
|
||||||
'ァ': "a", 'ィ': "i", 'ゥ': "u", 'ェ': "e", 'ォ': "o",
|
'ァ': "a", 'ィ': "i", 'ゥ': "u", 'ェ': "e", 'ォ': "o",
|
||||||
// Extended katakana
|
|
||||||
'ー': "", // Long vowel mark
|
'ー': "",
|
||||||
'ヴ': "vu",
|
'ヴ': "vu",
|
||||||
}
|
}
|
||||||
|
|
||||||
// Combination mappings for きゃ, しゃ, etc.
|
|
||||||
var combinationHiragana = map[string]string{
|
var combinationHiragana = map[string]string{
|
||||||
"きゃ": "kya", "きゅ": "kyu", "きょ": "kyo",
|
"きゃ": "kya", "きゅ": "kyu", "きょ": "kyo",
|
||||||
"しゃ": "sha", "しゅ": "shu", "しょ": "sho",
|
"しゃ": "sha", "しゅ": "shu", "しょ": "sho",
|
||||||
@@ -85,13 +82,12 @@ var combinationKatakana = map[string]string{
|
|||||||
"ジャ": "ja", "ジュ": "ju", "ジョ": "jo",
|
"ジャ": "ja", "ジュ": "ju", "ジョ": "jo",
|
||||||
"ビャ": "bya", "ビュ": "byu", "ビョ": "byo",
|
"ビャ": "bya", "ビュ": "byu", "ビョ": "byo",
|
||||||
"ピャ": "pya", "ピュ": "pyu", "ピョ": "pyo",
|
"ピャ": "pya", "ピュ": "pyu", "ピョ": "pyo",
|
||||||
// Extended combinations
|
|
||||||
"ティ": "ti", "ディ": "di", "トゥ": "tu", "ドゥ": "du",
|
"ティ": "ti", "ディ": "di", "トゥ": "tu", "ドゥ": "du",
|
||||||
"ファ": "fa", "フィ": "fi", "フェ": "fe", "フォ": "fo",
|
"ファ": "fa", "フィ": "fi", "フェ": "fe", "フォ": "fo",
|
||||||
"ウィ": "wi", "ウェ": "we", "ウォ": "wo",
|
"ウィ": "wi", "ウェ": "we", "ウォ": "wo",
|
||||||
}
|
}
|
||||||
|
|
||||||
// ContainsJapanese checks if a string contains Japanese characters
|
|
||||||
func ContainsJapanese(s string) bool {
|
func ContainsJapanese(s string) bool {
|
||||||
for _, r := range s {
|
for _, r := range s {
|
||||||
if isHiragana(r) || isKatakana(r) || isKanji(r) {
|
if isHiragana(r) || isKatakana(r) || isKanji(r) {
|
||||||
@@ -110,12 +106,10 @@ func isKatakana(r rune) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func isKanji(r rune) bool {
|
func isKanji(r rune) bool {
|
||||||
return (r >= 0x4E00 && r <= 0x9FFF) || // CJK Unified Ideographs
|
return (r >= 0x4E00 && r <= 0x9FFF) ||
|
||||||
(r >= 0x3400 && r <= 0x4DBF) // CJK Unified Ideographs Extension A
|
(r >= 0x3400 && r <= 0x4DBF)
|
||||||
}
|
}
|
||||||
|
|
||||||
// JapaneseToRomaji converts Japanese text (hiragana/katakana) to romaji
|
|
||||||
// Note: Kanji cannot be converted without a dictionary, so they are kept as-is
|
|
||||||
func JapaneseToRomaji(text string) string {
|
func JapaneseToRomaji(text string) string {
|
||||||
if !ContainsJapanese(text) {
|
if !ContainsJapanese(text) {
|
||||||
return text
|
return text
|
||||||
@@ -126,7 +120,7 @@ func JapaneseToRomaji(text string) string {
|
|||||||
i := 0
|
i := 0
|
||||||
|
|
||||||
for i < len(runes) {
|
for i < len(runes) {
|
||||||
// Check for っ/ッ (double consonant)
|
|
||||||
if i < len(runes)-1 && (runes[i] == 'っ' || runes[i] == 'ッ') {
|
if i < len(runes)-1 && (runes[i] == 'っ' || runes[i] == 'ッ') {
|
||||||
nextRomaji := ""
|
nextRomaji := ""
|
||||||
if romaji, ok := hiraganaToRomaji[runes[i+1]]; ok {
|
if romaji, ok := hiraganaToRomaji[runes[i+1]]; ok {
|
||||||
@@ -135,13 +129,12 @@ func JapaneseToRomaji(text string) string {
|
|||||||
nextRomaji = romaji
|
nextRomaji = romaji
|
||||||
}
|
}
|
||||||
if len(nextRomaji) > 0 {
|
if len(nextRomaji) > 0 {
|
||||||
result.WriteByte(nextRomaji[0]) // Double the first consonant
|
result.WriteByte(nextRomaji[0])
|
||||||
}
|
}
|
||||||
i++
|
i++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for two-character combinations
|
|
||||||
if i < len(runes)-1 {
|
if i < len(runes)-1 {
|
||||||
combo := string(runes[i : i+2])
|
combo := string(runes[i : i+2])
|
||||||
if romaji, ok := combinationHiragana[combo]; ok {
|
if romaji, ok := combinationHiragana[combo]; ok {
|
||||||
@@ -156,17 +149,16 @@ func JapaneseToRomaji(text string) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Single character conversion
|
|
||||||
r := runes[i]
|
r := runes[i]
|
||||||
if romaji, ok := hiraganaToRomaji[r]; ok {
|
if romaji, ok := hiraganaToRomaji[r]; ok {
|
||||||
result.WriteString(romaji)
|
result.WriteString(romaji)
|
||||||
} else if romaji, ok := katakanaToRomaji[r]; ok {
|
} else if romaji, ok := katakanaToRomaji[r]; ok {
|
||||||
result.WriteString(romaji)
|
result.WriteString(romaji)
|
||||||
} else if isKanji(r) {
|
} else if isKanji(r) {
|
||||||
// Keep kanji as-is (would need dictionary for proper conversion)
|
|
||||||
result.WriteRune(r)
|
result.WriteRune(r)
|
||||||
} else {
|
} else {
|
||||||
// Keep other characters (punctuation, spaces, etc.)
|
|
||||||
result.WriteRune(r)
|
result.WriteRune(r)
|
||||||
}
|
}
|
||||||
i++
|
i++
|
||||||
@@ -175,21 +167,17 @@ func JapaneseToRomaji(text string) string {
|
|||||||
return result.String()
|
return result.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildSearchQuery creates a search query from track name and artist
|
|
||||||
// Converts Japanese to romaji if present
|
|
||||||
func BuildSearchQuery(trackName, artistName string) string {
|
func BuildSearchQuery(trackName, artistName string) string {
|
||||||
// Convert Japanese to romaji
|
|
||||||
trackRomaji := JapaneseToRomaji(trackName)
|
trackRomaji := JapaneseToRomaji(trackName)
|
||||||
artistRomaji := JapaneseToRomaji(artistName)
|
artistRomaji := JapaneseToRomaji(artistName)
|
||||||
|
|
||||||
// Clean up the query - remove special characters that might interfere with search
|
|
||||||
trackClean := cleanSearchQuery(trackRomaji)
|
trackClean := cleanSearchQuery(trackRomaji)
|
||||||
artistClean := cleanSearchQuery(artistRomaji)
|
artistClean := cleanSearchQuery(artistRomaji)
|
||||||
|
|
||||||
return strings.TrimSpace(artistClean + " " + trackClean)
|
return strings.TrimSpace(artistClean + " " + trackClean)
|
||||||
}
|
}
|
||||||
|
|
||||||
// cleanSearchQuery removes special characters that might interfere with search
|
|
||||||
func cleanSearchQuery(s string) string {
|
func cleanSearchQuery(s string) string {
|
||||||
var result strings.Builder
|
var result strings.Builder
|
||||||
for _, r := range s {
|
for _, r := range s {
|
||||||
@@ -202,21 +190,19 @@ func cleanSearchQuery(s string) string {
|
|||||||
return strings.TrimSpace(result.String())
|
return strings.TrimSpace(result.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
// cleanToASCII removes all non-ASCII characters and keeps only letters, numbers, spaces
|
|
||||||
// This is useful for creating search queries that work better with Tidal's search
|
|
||||||
func cleanToASCII(s string) string {
|
func cleanToASCII(s string) string {
|
||||||
var result strings.Builder
|
var result strings.Builder
|
||||||
for _, r := range s {
|
for _, r := range s {
|
||||||
// Keep only ASCII letters, numbers, spaces, and basic punctuation
|
|
||||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
|
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
|
||||||
(r >= '0' && r <= '9') || r == ' ' || r == '-' || r == '\'' {
|
(r >= '0' && r <= '9') || r == ' ' || r == '-' || r == '\'' {
|
||||||
result.WriteRune(r)
|
result.WriteRune(r)
|
||||||
} else if r == ',' || r == '.' {
|
} else if r == ',' || r == '.' {
|
||||||
// Convert punctuation to space
|
|
||||||
result.WriteRune(' ')
|
result.WriteRune(' ')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Clean up multiple spaces
|
|
||||||
cleaned := strings.Join(strings.Fields(result.String()), " ")
|
cleaned := strings.Join(strings.Fields(result.String()), " ")
|
||||||
return strings.TrimSpace(cleaned)
|
return strings.TrimSpace(cleaned)
|
||||||
}
|
}
|
||||||
|
|||||||
+320
-20
@@ -4,8 +4,10 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -18,10 +20,19 @@ type SongLinkClient struct {
|
|||||||
|
|
||||||
type SongLinkURLs struct {
|
type SongLinkURLs struct {
|
||||||
TidalURL string `json:"tidal_url"`
|
TidalURL string `json:"tidal_url"`
|
||||||
DeezerURL string `json:"deezer_url"`
|
|
||||||
AmazonURL string `json:"amazon_url"`
|
AmazonURL string `json:"amazon_url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TrackAvailability struct {
|
||||||
|
SpotifyID string `json:"spotify_id"`
|
||||||
|
Tidal bool `json:"tidal"`
|
||||||
|
Amazon bool `json:"amazon"`
|
||||||
|
Qobuz bool `json:"qobuz"`
|
||||||
|
TidalURL string `json:"tidal_url,omitempty"`
|
||||||
|
AmazonURL string `json:"amazon_url,omitempty"`
|
||||||
|
QobuzURL string `json:"qobuz_url,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
func NewSongLinkClient() *SongLinkClient {
|
func NewSongLinkClient() *SongLinkClient {
|
||||||
return &SongLinkClient{
|
return &SongLinkClient{
|
||||||
client: &http.Client{
|
client: &http.Client{
|
||||||
@@ -32,14 +43,13 @@ func NewSongLinkClient() *SongLinkClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string) (*SongLinkURLs, error) {
|
func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string) (*SongLinkURLs, error) {
|
||||||
// Rate limiting: max 10 requests per minute (song.link API limit)
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
if now.Sub(s.apiCallResetTime) >= time.Minute {
|
if now.Sub(s.apiCallResetTime) >= time.Minute {
|
||||||
s.apiCallCount = 0
|
s.apiCallCount = 0
|
||||||
s.apiCallResetTime = now
|
s.apiCallResetTime = now
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we've hit the limit, wait until the next minute
|
|
||||||
if s.apiCallCount >= 9 {
|
if s.apiCallCount >= 9 {
|
||||||
waitTime := time.Minute - now.Sub(s.apiCallResetTime)
|
waitTime := time.Minute - now.Sub(s.apiCallResetTime)
|
||||||
if waitTime > 0 {
|
if waitTime > 0 {
|
||||||
@@ -50,7 +60,6 @@ func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string) (*SongLink
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add delay between requests (7 seconds to be safe)
|
|
||||||
if !s.lastAPICallTime.IsZero() {
|
if !s.lastAPICallTime.IsZero() {
|
||||||
timeSinceLastCall := now.Sub(s.lastAPICallTime)
|
timeSinceLastCall := now.Sub(s.lastAPICallTime)
|
||||||
minDelay := 7 * time.Second
|
minDelay := 7 * time.Second
|
||||||
@@ -61,7 +70,6 @@ func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string) (*SongLink
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decode base64 API URL
|
|
||||||
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
|
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
|
||||||
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID)
|
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID)
|
||||||
|
|
||||||
@@ -75,7 +83,6 @@ func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string) (*SongLink
|
|||||||
|
|
||||||
fmt.Println("Getting streaming URLs from song.link...")
|
fmt.Println("Getting streaming URLs from song.link...")
|
||||||
|
|
||||||
// Retry logic for rate limit errors
|
|
||||||
maxRetries := 3
|
maxRetries := 3
|
||||||
var resp *http.Response
|
var resp *http.Response
|
||||||
for i := 0; i < maxRetries; i++ {
|
for i := 0; i < maxRetries; i++ {
|
||||||
@@ -84,7 +91,6 @@ func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string) (*SongLink
|
|||||||
return nil, fmt.Errorf("failed to get URLs: %w", err)
|
return nil, fmt.Errorf("failed to get URLs: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update rate limit tracking
|
|
||||||
s.lastAPICallTime = time.Now()
|
s.lastAPICallTime = time.Now()
|
||||||
s.apiCallCount++
|
s.apiCallCount++
|
||||||
|
|
||||||
@@ -113,38 +119,332 @@ func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string) (*SongLink
|
|||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
} `json:"linksByPlatform"`
|
} `json:"linksByPlatform"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&songLinkResp); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
urls := &SongLinkURLs{}
|
urls := &SongLinkURLs{}
|
||||||
|
|
||||||
// Extract Tidal URL
|
|
||||||
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
||||||
urls.TidalURL = tidalLink.URL
|
urls.TidalURL = tidalLink.URL
|
||||||
fmt.Printf("✓ Tidal URL found\n")
|
fmt.Printf("✓ Tidal URL found\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract Deezer URL
|
|
||||||
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
|
||||||
urls.DeezerURL = deezerLink.URL
|
|
||||||
fmt.Printf("✓ Deezer URL found\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract Amazon URL
|
|
||||||
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
||||||
amazonURL := amazonLink.URL
|
amazonURL := amazonLink.URL
|
||||||
// Convert album URL to track URL if needed
|
|
||||||
if len(amazonURL) > 0 {
|
if len(amazonURL) > 0 {
|
||||||
urls.AmazonURL = amazonURL
|
urls.AmazonURL = amazonURL
|
||||||
fmt.Printf("✓ Amazon URL found\n")
|
fmt.Printf("✓ Amazon URL found\n")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if at least one URL was found
|
if urls.TidalURL == "" && urls.AmazonURL == "" {
|
||||||
if urls.TidalURL == "" && urls.DeezerURL == "" && urls.AmazonURL == "" {
|
|
||||||
return nil, fmt.Errorf("no streaming URLs found")
|
return nil, fmt.Errorf("no streaming URLs found")
|
||||||
}
|
}
|
||||||
|
|
||||||
return urls, nil
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
availability := &TrackAvailability{
|
||||||
|
SpotifyID: spotifyTrackID,
|
||||||
|
}
|
||||||
|
|
||||||
|
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
||||||
|
availability.Tidal = true
|
||||||
|
availability.TidalURL = tidalLink.URL
|
||||||
|
}
|
||||||
|
|
||||||
|
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
||||||
|
availability.Amazon = true
|
||||||
|
availability.AmazonURL = amazonLink.URL
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return availability, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
resp, err := client.Get(searchURL)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var searchResp struct {
|
||||||
|
Tracks struct {
|
||||||
|
Total int `json:"total"`
|
||||||
|
} `json:"tracks"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return searchResp.Tracks.Total > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SongLinkClient) GetDeezerURLFromSpotify(spotifyTrackID string) (string, 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 "", fmt.Errorf("failed to create request: %w", 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
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
apiURL := fmt.Sprintf("https://api.deezer.com/track/%s", trackID)
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
resp, err := client.Get(apiURL)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to call Deezer API: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return "", fmt.Errorf("Deezer API returned status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var deezerTrack struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
ISRC string `json:"isrc"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&deezerTrack); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to decode Deezer API response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if deezerTrack.ISRC == "" {
|
||||||
|
return "", fmt.Errorf("ISRC not found in Deezer API response for track %s", trackID)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Found ISRC from Deezer: %s (track: %s)\n", deezerTrack.ISRC, deezerTrack.Title)
|
||||||
|
return deezerTrack.ISRC, nil
|
||||||
|
}
|
||||||
|
|||||||
+3
-17
@@ -8,7 +8,6 @@ import (
|
|||||||
"github.com/mewkiz/flac"
|
"github.com/mewkiz/flac"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SpectrumData contains frequency spectrum information
|
|
||||||
type SpectrumData struct {
|
type SpectrumData struct {
|
||||||
TimeSlices []TimeSlice `json:"time_slices"`
|
TimeSlices []TimeSlice `json:"time_slices"`
|
||||||
SampleRate int `json:"sample_rate"`
|
SampleRate int `json:"sample_rate"`
|
||||||
@@ -17,15 +16,13 @@ type SpectrumData struct {
|
|||||||
MaxFreq float64 `json:"max_freq"`
|
MaxFreq float64 `json:"max_freq"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TimeSlice represents spectrum data at a point in time
|
|
||||||
type TimeSlice struct {
|
type TimeSlice struct {
|
||||||
Time float64 `json:"time"`
|
Time float64 `json:"time"`
|
||||||
Magnitudes []float64 `json:"magnitudes"`
|
Magnitudes []float64 `json:"magnitudes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AnalyzeSpectrum decodes FLAC file and performs FFT analysis
|
|
||||||
func AnalyzeSpectrum(filepath string) (*SpectrumData, error) {
|
func AnalyzeSpectrum(filepath string) (*SpectrumData, error) {
|
||||||
// Open FLAC file
|
|
||||||
stream, err := flac.ParseFile(filepath)
|
stream, err := flac.ParseFile(filepath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to parse FLAC: %w", err)
|
return nil, fmt.Errorf("failed to parse FLAC: %w", err)
|
||||||
@@ -36,7 +33,6 @@ func AnalyzeSpectrum(filepath string) (*SpectrumData, error) {
|
|||||||
sampleRate := int(info.SampleRate)
|
sampleRate := int(info.SampleRate)
|
||||||
channels := int(info.NChannels)
|
channels := int(info.NChannels)
|
||||||
|
|
||||||
// Read audio samples
|
|
||||||
samples, err := readSamples(stream, channels)
|
samples, err := readSamples(stream, channels)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to read samples: %w", err)
|
return nil, fmt.Errorf("failed to read samples: %w", err)
|
||||||
@@ -46,28 +42,23 @@ func AnalyzeSpectrum(filepath string) (*SpectrumData, error) {
|
|||||||
return nil, fmt.Errorf("no audio samples found")
|
return nil, fmt.Errorf("no audio samples found")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate spectrum
|
|
||||||
return calculateSpectrum(samples, sampleRate), nil
|
return calculateSpectrum(samples, sampleRate), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// readSamples reads and decodes audio samples from FLAC stream
|
|
||||||
func readSamples(stream *flac.Stream, channels int) ([]float64, error) {
|
func readSamples(stream *flac.Stream, channels int) ([]float64, error) {
|
||||||
var allSamples []float64
|
var allSamples []float64
|
||||||
maxSamples := 10 * 1024 * 1024 // Limit to ~10 million samples to avoid memory issues
|
maxSamples := 10 * 1024 * 1024
|
||||||
|
|
||||||
// Decode frames
|
|
||||||
for {
|
for {
|
||||||
frame, err := stream.ParseNext()
|
frame, err := stream.ParseNext()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// End of stream
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert samples to float64 and mix channels to mono
|
|
||||||
for i := 0; i < frame.Subframes[0].NSamples; i++ {
|
for i := 0; i < frame.Subframes[0].NSamples; i++ {
|
||||||
var sample float64
|
var sample float64
|
||||||
|
|
||||||
// Mix all channels to mono by averaging
|
|
||||||
for ch := 0; ch < channels; ch++ {
|
for ch := 0; ch < channels; ch++ {
|
||||||
sample += float64(frame.Subframes[ch].Samples[i])
|
sample += float64(frame.Subframes[ch].Samples[i])
|
||||||
}
|
}
|
||||||
@@ -75,7 +66,6 @@ func readSamples(stream *flac.Stream, channels int) ([]float64, error) {
|
|||||||
|
|
||||||
allSamples = append(allSamples, sample)
|
allSamples = append(allSamples, sample)
|
||||||
|
|
||||||
// Limit sample count
|
|
||||||
if len(allSamples) >= maxSamples {
|
if len(allSamples) >= maxSamples {
|
||||||
return allSamples, nil
|
return allSamples, nil
|
||||||
}
|
}
|
||||||
@@ -85,7 +75,6 @@ func readSamples(stream *flac.Stream, channels int) ([]float64, error) {
|
|||||||
return allSamples, nil
|
return allSamples, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// calculateSpectrum performs FFT analysis on audio samples
|
|
||||||
func calculateSpectrum(samples []float64, sampleRate int) *SpectrumData {
|
func calculateSpectrum(samples []float64, sampleRate int) *SpectrumData {
|
||||||
fftSize := 8192
|
fftSize := 8192
|
||||||
numTimeSlices := 300
|
numTimeSlices := 300
|
||||||
@@ -140,7 +129,6 @@ func calculateSpectrum(samples []float64, sampleRate int) *SpectrumData {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// applyHannWindow applies Hann window to reduce spectral leakage
|
|
||||||
func applyHannWindow(samples []float64) []float64 {
|
func applyHannWindow(samples []float64) []float64 {
|
||||||
n := len(samples)
|
n := len(samples)
|
||||||
windowed := make([]float64, n)
|
windowed := make([]float64, n)
|
||||||
@@ -153,7 +141,6 @@ func applyHannWindow(samples []float64) []float64 {
|
|||||||
return windowed
|
return windowed
|
||||||
}
|
}
|
||||||
|
|
||||||
// fft performs Fast Fourier Transform using Cooley-Tukey algorithm
|
|
||||||
func fft(samples []float64) []complex128 {
|
func fft(samples []float64) []complex128 {
|
||||||
n := len(samples)
|
n := len(samples)
|
||||||
|
|
||||||
@@ -165,7 +152,6 @@ func fft(samples []float64) []complex128 {
|
|||||||
return fftRecursive(x)
|
return fftRecursive(x)
|
||||||
}
|
}
|
||||||
|
|
||||||
// fftRecursive performs recursive FFT
|
|
||||||
func fftRecursive(x []complex128) []complex128 {
|
func fftRecursive(x []complex128) []complex128 {
|
||||||
n := len(x)
|
n := len(x)
|
||||||
|
|
||||||
|
|||||||
+594
-829
File diff suppressed because it is too large
Load Diff
+438
-600
File diff suppressed because it is too large
Load Diff
@@ -18,5 +18,7 @@
|
|||||||
"lib": "@/lib",
|
"lib": "@/lib",
|
||||||
"hooks": "@/hooks"
|
"hooks": "@/hooks"
|
||||||
},
|
},
|
||||||
"registries": {}
|
"registries": {
|
||||||
|
"@lucide-animated": "https://lucide-animated.com/r/{name}.json"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<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.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Google+Sans+Code:ital,wght@0,300..800;1,300..800&family=Google+Sans+Flex:opsz,wght@6..144,1..1000&display=swap" rel="stylesheet">
|
<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>
|
<title>SpotiFLAC</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
+18
-16
@@ -17,36 +17,38 @@
|
|||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
"@radix-ui/react-progress": "^1.1.8",
|
"@radix-ui/react-progress": "^1.1.8",
|
||||||
"@radix-ui/react-radio-group": "^1.3.8",
|
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
|
"@radix-ui/react-toggle": "^1.1.10",
|
||||||
|
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@tailwindcss/vite": "^4.1.17",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.554.0",
|
"lucide-react": "^0.562.0",
|
||||||
|
"motion": "^12.24.12",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.3",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.3",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwindcss": "^4.1.17"
|
"tailwindcss": "^4.1.18"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.2",
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^25.0.3",
|
||||||
"@types/react": "^19.2.6",
|
"@types/react": "^19.2.7",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^5.1.1",
|
"@vitejs/plugin-react": "^5.1.2",
|
||||||
"eslint": "^9.39.1",
|
"eslint": "^9.39.2",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.4.24",
|
"eslint-plugin-react-refresh": "^0.4.26",
|
||||||
"globals": "^16.5.0",
|
"globals": "^17.0.0",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"typescript-eslint": "^8.47.0",
|
"typescript-eslint": "^8.52.0",
|
||||||
"vite": "^7.2.4"
|
"vite": "^7.3.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
2d92c35b92c8ea713ea561773c5b7b7b
|
be90455e8d3a26cf5c12d4fa0779bc1a
|
||||||
Generated
+928
-945
File diff suppressed because it is too large
Load Diff
+436
-530
File diff suppressed because it is too large
Load Diff
@@ -1,204 +1,141 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Download, FolderOpen } from "lucide-react";
|
import { Download, FolderOpen, ImageDown, FileText } from "lucide-react";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { SearchAndSort } from "./SearchAndSort";
|
import { SearchAndSort } from "./SearchAndSort";
|
||||||
import { TrackList } from "./TrackList";
|
import { TrackList } from "./TrackList";
|
||||||
import { DownloadProgress } from "./DownloadProgress";
|
import { DownloadProgress } from "./DownloadProgress";
|
||||||
import type { TrackMetadata } from "@/types/api";
|
import type { TrackMetadata, TrackAvailability } from "@/types/api";
|
||||||
|
|
||||||
interface AlbumInfoProps {
|
interface AlbumInfoProps {
|
||||||
albumInfo: {
|
albumInfo: {
|
||||||
name: string;
|
name: string;
|
||||||
artists: string;
|
artists: string;
|
||||||
images: string;
|
images: string;
|
||||||
release_date: string;
|
release_date: string;
|
||||||
total_tracks: number;
|
total_tracks: number;
|
||||||
artist_id?: string;
|
artist_id?: string;
|
||||||
artist_url?: string;
|
artist_url?: string;
|
||||||
};
|
};
|
||||||
trackList: TrackMetadata[];
|
trackList: TrackMetadata[];
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
sortBy: string;
|
sortBy: string;
|
||||||
selectedTracks: string[];
|
selectedTracks: string[];
|
||||||
downloadedTracks: Set<string>;
|
downloadedTracks: Set<string>;
|
||||||
failedTracks: Set<string>;
|
failedTracks: Set<string>;
|
||||||
skippedTracks: Set<string>;
|
skippedTracks: Set<string>;
|
||||||
downloadingTrack: string | null;
|
downloadingTrack: string | null;
|
||||||
isDownloading: boolean;
|
isDownloading: boolean;
|
||||||
bulkDownloadType: "all" | "selected" | null;
|
bulkDownloadType: "all" | "selected" | null;
|
||||||
downloadProgress: number;
|
downloadProgress: number;
|
||||||
currentDownloadInfo: { name: string; artists: string } | null;
|
currentDownloadInfo: {
|
||||||
currentPage: number;
|
name: string;
|
||||||
itemsPerPage: number;
|
artists: string;
|
||||||
// Lyrics props
|
} | null;
|
||||||
downloadedLyrics?: Set<string>;
|
currentPage: number;
|
||||||
failedLyrics?: Set<string>;
|
itemsPerPage: number;
|
||||||
skippedLyrics?: Set<string>;
|
downloadedLyrics?: Set<string>;
|
||||||
downloadingLyricsTrack?: string | null;
|
failedLyrics?: Set<string>;
|
||||||
onSearchChange: (value: string) => void;
|
skippedLyrics?: Set<string>;
|
||||||
onSortChange: (value: string) => void;
|
downloadingLyricsTrack?: string | null;
|
||||||
onToggleTrack: (isrc: string) => void;
|
checkingAvailabilityTrack?: string | null;
|
||||||
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
|
availabilityMap?: Map<string, TrackAvailability>;
|
||||||
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string) => void;
|
downloadedCovers?: Set<string>;
|
||||||
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean) => void;
|
failedCovers?: Set<string>;
|
||||||
onDownloadAll: () => void;
|
skippedCovers?: Set<string>;
|
||||||
onDownloadSelected: () => void;
|
downloadingCoverTrack?: string | null;
|
||||||
onStopDownload: () => void;
|
isBulkDownloadingCovers?: boolean;
|
||||||
onOpenFolder: () => void;
|
isBulkDownloadingLyrics?: boolean;
|
||||||
onPageChange: (page: number) => void;
|
onSearchChange: (value: string) => void;
|
||||||
onArtistClick?: (artist: { id: string; name: string; external_urls: string }) => void;
|
onSortChange: (value: string) => void;
|
||||||
onTrackClick?: (track: TrackMetadata) => void;
|
onToggleTrack: (isrc: 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;
|
||||||
|
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;
|
||||||
|
onDownloadAllLyrics?: () => void;
|
||||||
|
onDownloadAllCovers?: () => void;
|
||||||
|
onDownloadAll: () => void;
|
||||||
|
onDownloadSelected: () => void;
|
||||||
|
onStopDownload: () => void;
|
||||||
|
onOpenFolder: () => void;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
onArtistClick?: (artist: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
external_urls: string;
|
||||||
|
}) => void;
|
||||||
|
onTrackClick?: (track: TrackMetadata) => 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({
|
return (<div className="space-y-6">
|
||||||
albumInfo,
|
|
||||||
trackList,
|
|
||||||
searchQuery,
|
|
||||||
sortBy,
|
|
||||||
selectedTracks,
|
|
||||||
downloadedTracks,
|
|
||||||
failedTracks,
|
|
||||||
skippedTracks,
|
|
||||||
downloadingTrack,
|
|
||||||
isDownloading,
|
|
||||||
bulkDownloadType,
|
|
||||||
downloadProgress,
|
|
||||||
currentDownloadInfo,
|
|
||||||
currentPage,
|
|
||||||
itemsPerPage,
|
|
||||||
downloadedLyrics,
|
|
||||||
failedLyrics,
|
|
||||||
skippedLyrics,
|
|
||||||
downloadingLyricsTrack,
|
|
||||||
onSearchChange,
|
|
||||||
onSortChange,
|
|
||||||
onToggleTrack,
|
|
||||||
onToggleSelectAll,
|
|
||||||
onDownloadTrack,
|
|
||||||
onDownloadLyrics,
|
|
||||||
onDownloadAll,
|
|
||||||
onDownloadSelected,
|
|
||||||
onStopDownload,
|
|
||||||
onOpenFolder,
|
|
||||||
onPageChange,
|
|
||||||
onArtistClick,
|
|
||||||
onTrackClick,
|
|
||||||
}: AlbumInfoProps) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="px-6">
|
<CardContent className="px-6">
|
||||||
<div className="flex gap-6 items-start">
|
<div className="flex gap-6 items-start">
|
||||||
{albumInfo.images && (
|
{albumInfo.images && (<img src={albumInfo.images} alt={albumInfo.name} className="w-48 h-48 rounded-md shadow-lg object-cover"/>)}
|
||||||
<img
|
|
||||||
src={albumInfo.images}
|
|
||||||
alt={albumInfo.name}
|
|
||||||
className="w-48 h-48 rounded-md shadow-lg object-cover"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div className="flex-1 space-y-4">
|
<div className="flex-1 space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-sm font-medium">Album</p>
|
<p className="text-sm font-medium">Album</p>
|
||||||
<h2 className="text-4xl font-bold">{albumInfo.name}</h2>
|
<h2 className="text-4xl font-bold">{albumInfo.name}</h2>
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
{onArtistClick && albumInfo.artist_id && albumInfo.artist_url ? (
|
{onArtistClick && albumInfo.artist_id && albumInfo.artist_url ? (<span className="font-medium cursor-pointer hover:underline" onClick={() => onArtistClick({
|
||||||
<span
|
id: albumInfo.artist_id!,
|
||||||
className="font-medium cursor-pointer hover:underline"
|
name: albumInfo.artists,
|
||||||
onClick={() =>
|
external_urls: albumInfo.artist_url!,
|
||||||
onArtistClick({
|
})}>
|
||||||
id: albumInfo.artist_id!,
|
|
||||||
name: albumInfo.artists,
|
|
||||||
external_urls: albumInfo.artist_url!,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{albumInfo.artists}
|
{albumInfo.artists}
|
||||||
</span>
|
</span>) : (<span className="font-medium">{albumInfo.artists}</span>)}
|
||||||
) : (
|
|
||||||
<span className="font-medium">{albumInfo.artists}</span>
|
|
||||||
)}
|
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
<span>{albumInfo.release_date}</span>
|
<span>{albumInfo.release_date}</span>
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
<span>{albumInfo.total_tracks} songs</span>
|
<span>
|
||||||
|
{albumInfo.total_tracks} {albumInfo.total_tracks === 1 ? "song" : "songs"}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 flex-wrap">
|
<div className="flex gap-2 flex-wrap">
|
||||||
<Button onClick={onDownloadAll} disabled={isDownloading}>
|
<Button onClick={onDownloadAll} disabled={isDownloading}>
|
||||||
{isDownloading && bulkDownloadType === "all" ? (
|
{isDownloading && bulkDownloadType === "all" ? (<Spinner />) : (<Download className="h-4 w-4"/>)}
|
||||||
<Spinner />
|
|
||||||
) : (
|
|
||||||
<Download className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
Download All
|
Download All
|
||||||
</Button>
|
</Button>
|
||||||
{selectedTracks.length > 0 && (
|
{selectedTracks.length > 0 && (<Button onClick={onDownloadSelected} variant="secondary" disabled={isDownloading}>
|
||||||
<Button
|
{isDownloading && bulkDownloadType === "selected" ? (<Spinner />) : (<Download className="h-4 w-4"/>)}
|
||||||
onClick={onDownloadSelected}
|
|
||||||
variant="secondary"
|
|
||||||
disabled={isDownloading}
|
|
||||||
>
|
|
||||||
{isDownloading && bulkDownloadType === "selected" ? (
|
|
||||||
<Spinner />
|
|
||||||
) : (
|
|
||||||
<Download className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
Download Selected ({selectedTracks.length})
|
Download Selected ({selectedTracks.length})
|
||||||
</Button>
|
</Button>)}
|
||||||
)}
|
{onDownloadAllLyrics && (<Tooltip>
|
||||||
{downloadedTracks.size > 0 && (
|
<TooltipTrigger asChild>
|
||||||
<Button onClick={onOpenFolder} variant="outline">
|
<Button onClick={onDownloadAllLyrics} variant="outline" disabled={isBulkDownloadingLyrics}>
|
||||||
<FolderOpen className="h-4 w-4" />
|
{isBulkDownloadingLyrics ? <Spinner /> : <FileText className="h-4 w-4"/>}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Download All Lyrics</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>)}
|
||||||
|
{onDownloadAllCovers && (<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button onClick={onDownloadAllCovers} variant="outline" disabled={isBulkDownloadingCovers}>
|
||||||
|
{isBulkDownloadingCovers ? <Spinner /> : <ImageDown className="h-4 w-4"/>}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Download All Covers</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>)}
|
||||||
|
{downloadedTracks.size > 0 && (<Button onClick={onOpenFolder} variant="outline">
|
||||||
|
<FolderOpen className="h-4 w-4"/>
|
||||||
Open Folder
|
Open Folder
|
||||||
</Button>
|
</Button>)}
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{isDownloading && (
|
{isDownloading && (<DownloadProgress progress={downloadProgress} currentTrack={currentDownloadInfo} onStop={onStopDownload}/>)}
|
||||||
<DownloadProgress
|
|
||||||
progress={downloadProgress}
|
|
||||||
currentTrack={currentDownloadInfo}
|
|
||||||
onStop={onStopDownload}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<SearchAndSort
|
<SearchAndSort searchQuery={searchQuery} sortBy={sortBy} onSearchChange={onSearchChange} onSortChange={onSortChange}/>
|
||||||
searchQuery={searchQuery}
|
<TrackList tracks={trackList} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={downloadedTracks} failedTracks={failedTracks} skippedTracks={skippedTracks} downloadingTrack={downloadingTrack} isDownloading={isDownloading} currentPage={currentPage} itemsPerPage={itemsPerPage} showCheckboxes={true} hideAlbumColumn={true} folderName={albumInfo.name} downloadedLyrics={downloadedLyrics} failedLyrics={failedLyrics} skippedLyrics={skippedLyrics} downloadingLyricsTrack={downloadingLyricsTrack} checkingAvailabilityTrack={checkingAvailabilityTrack} availabilityMap={availabilityMap} onToggleTrack={onToggleTrack} onToggleSelectAll={onToggleSelectAll} onDownloadTrack={onDownloadTrack} onDownloadLyrics={onDownloadLyrics} onDownloadCover={onDownloadCover} downloadedCovers={downloadedCovers} failedCovers={failedCovers} skippedCovers={skippedCovers} downloadingCoverTrack={downloadingCoverTrack} onCheckAvailability={onCheckAvailability} onPageChange={onPageChange} onArtistClick={onArtistClick} onTrackClick={onTrackClick}/>
|
||||||
sortBy={sortBy}
|
|
||||||
onSearchChange={onSearchChange}
|
|
||||||
onSortChange={onSortChange}
|
|
||||||
/>
|
|
||||||
<TrackList
|
|
||||||
tracks={trackList}
|
|
||||||
searchQuery={searchQuery}
|
|
||||||
sortBy={sortBy}
|
|
||||||
selectedTracks={selectedTracks}
|
|
||||||
downloadedTracks={downloadedTracks}
|
|
||||||
failedTracks={failedTracks}
|
|
||||||
skippedTracks={skippedTracks}
|
|
||||||
downloadingTrack={downloadingTrack}
|
|
||||||
isDownloading={isDownloading}
|
|
||||||
currentPage={currentPage}
|
|
||||||
itemsPerPage={itemsPerPage}
|
|
||||||
showCheckboxes={true}
|
|
||||||
hideAlbumColumn={true}
|
|
||||||
folderName={albumInfo.name}
|
|
||||||
downloadedLyrics={downloadedLyrics}
|
|
||||||
failedLyrics={failedLyrics}
|
|
||||||
skippedLyrics={skippedLyrics}
|
|
||||||
downloadingLyricsTrack={downloadingLyricsTrack}
|
|
||||||
onToggleTrack={onToggleTrack}
|
|
||||||
onToggleSelectAll={onToggleSelectAll}
|
|
||||||
onDownloadTrack={onDownloadTrack}
|
|
||||||
onDownloadLyrics={onDownloadLyrics}
|
|
||||||
onPageChange={onPageChange}
|
|
||||||
onTrackClick={onTrackClick}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,246 +1,451 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Download, FolderOpen } from "lucide-react";
|
import { Download, FolderOpen, ImageDown, FileText, BadgeCheck } from "lucide-react";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { SearchAndSort } from "./SearchAndSort";
|
import { SearchAndSort } from "./SearchAndSort";
|
||||||
import { TrackList } from "./TrackList";
|
import { TrackList } from "./TrackList";
|
||||||
import { DownloadProgress } from "./DownloadProgress";
|
import { DownloadProgress } from "./DownloadProgress";
|
||||||
import type { TrackMetadata } from "@/types/api";
|
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";
|
||||||
interface ArtistInfoProps {
|
interface ArtistInfoProps {
|
||||||
artistInfo: {
|
artistInfo: {
|
||||||
name: string;
|
name: string;
|
||||||
images: string;
|
images: string;
|
||||||
followers: number;
|
header?: string;
|
||||||
genres: string[];
|
gallery?: string[];
|
||||||
};
|
followers: number;
|
||||||
albumList: Array<{
|
genres: string[];
|
||||||
id: string;
|
biography?: string;
|
||||||
name: string;
|
verified?: boolean;
|
||||||
images: string;
|
listeners?: number;
|
||||||
release_date: string;
|
rank?: number;
|
||||||
album_type: string;
|
};
|
||||||
external_urls: string;
|
albumList: Array<{
|
||||||
}>;
|
id: string;
|
||||||
trackList: TrackMetadata[];
|
name: string;
|
||||||
searchQuery: string;
|
images: string;
|
||||||
sortBy: string;
|
release_date: string;
|
||||||
selectedTracks: string[];
|
album_type: string;
|
||||||
downloadedTracks: Set<string>;
|
external_urls: string;
|
||||||
failedTracks: Set<string>;
|
}>;
|
||||||
skippedTracks: Set<string>;
|
trackList: TrackMetadata[];
|
||||||
downloadingTrack: string | null;
|
searchQuery: string;
|
||||||
isDownloading: boolean;
|
sortBy: string;
|
||||||
bulkDownloadType: "all" | "selected" | null;
|
selectedTracks: string[];
|
||||||
downloadProgress: number;
|
downloadedTracks: Set<string>;
|
||||||
currentDownloadInfo: { name: string; artists: string } | null;
|
failedTracks: Set<string>;
|
||||||
currentPage: number;
|
skippedTracks: Set<string>;
|
||||||
itemsPerPage: number;
|
downloadingTrack: string | null;
|
||||||
// Lyrics props
|
isDownloading: boolean;
|
||||||
downloadedLyrics?: Set<string>;
|
bulkDownloadType: "all" | "selected" | null;
|
||||||
failedLyrics?: Set<string>;
|
downloadProgress: number;
|
||||||
skippedLyrics?: Set<string>;
|
currentDownloadInfo: {
|
||||||
downloadingLyricsTrack?: string | null;
|
name: string;
|
||||||
onSearchChange: (value: string) => void;
|
artists: string;
|
||||||
onSortChange: (value: string) => void;
|
} | null;
|
||||||
onToggleTrack: (isrc: string) => void;
|
currentPage: number;
|
||||||
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
|
itemsPerPage: number;
|
||||||
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string) => void;
|
downloadedLyrics?: Set<string>;
|
||||||
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean) => void;
|
failedLyrics?: Set<string>;
|
||||||
onDownloadAll: () => void;
|
skippedLyrics?: Set<string>;
|
||||||
onDownloadSelected: () => void;
|
downloadingLyricsTrack?: string | null;
|
||||||
onStopDownload: () => void;
|
checkingAvailabilityTrack?: string | null;
|
||||||
onOpenFolder: () => void;
|
availabilityMap?: Map<string, TrackAvailability>;
|
||||||
onAlbumClick: (album: { id: string; name: string; external_urls: string }) => void;
|
downloadedCovers?: Set<string>;
|
||||||
onArtistClick: (artist: { id: string; name: string; external_urls: string }) => void;
|
failedCovers?: Set<string>;
|
||||||
onPageChange: (page: number) => void;
|
skippedCovers?: Set<string>;
|
||||||
onTrackClick?: (track: TrackMetadata) => void;
|
downloadingCoverTrack?: string | null;
|
||||||
|
isBulkDownloadingCovers?: boolean;
|
||||||
|
isBulkDownloadingLyrics?: boolean;
|
||||||
|
onSearchChange: (value: string) => void;
|
||||||
|
onSortChange: (value: string) => void;
|
||||||
|
onToggleTrack: (isrc: 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;
|
||||||
|
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;
|
||||||
|
onDownloadAllLyrics?: () => void;
|
||||||
|
onDownloadAllCovers?: () => void;
|
||||||
|
onDownloadAll: () => void;
|
||||||
|
onDownloadSelected: () => void;
|
||||||
|
onStopDownload: () => void;
|
||||||
|
onOpenFolder: () => void;
|
||||||
|
onAlbumClick: (album: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
external_urls: string;
|
||||||
|
}) => void;
|
||||||
|
onArtistClick: (artist: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
external_urls: string;
|
||||||
|
}) => void;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
onTrackClick?: (track: TrackMetadata) => 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({
|
const [downloadingHeader, setDownloadingHeader] = useState(false);
|
||||||
artistInfo,
|
const [downloadingAvatar, setDownloadingAvatar] = useState(false);
|
||||||
albumList,
|
const [downloadingGalleryIndex, setDownloadingGalleryIndex] = useState<number | null>(null);
|
||||||
trackList,
|
const [downloadingAllGallery, setDownloadingAllGallery] = useState(false);
|
||||||
searchQuery,
|
const handleDownloadHeader = async () => {
|
||||||
sortBy,
|
if (!artistInfo.header)
|
||||||
selectedTracks,
|
return;
|
||||||
downloadedTracks,
|
setDownloadingHeader(true);
|
||||||
failedTracks,
|
try {
|
||||||
skippedTracks,
|
const settings = getSettings();
|
||||||
downloadingTrack,
|
const response = await downloadHeader({
|
||||||
isDownloading,
|
header_url: artistInfo.header,
|
||||||
bulkDownloadType,
|
artist_name: artistInfo.name,
|
||||||
downloadProgress,
|
output_dir: settings.downloadPath,
|
||||||
currentDownloadInfo,
|
});
|
||||||
currentPage,
|
if (response.success) {
|
||||||
itemsPerPage,
|
if (response.already_exists) {
|
||||||
downloadedLyrics,
|
toast.info("Header already exists");
|
||||||
failedLyrics,
|
}
|
||||||
skippedLyrics,
|
else {
|
||||||
downloadingLyricsTrack,
|
toast.success("Header downloaded successfully");
|
||||||
onSearchChange,
|
}
|
||||||
onSortChange,
|
}
|
||||||
onToggleTrack,
|
else {
|
||||||
onToggleSelectAll,
|
toast.error(response.error || "Failed to download header");
|
||||||
onDownloadTrack,
|
}
|
||||||
onDownloadLyrics,
|
}
|
||||||
onDownloadAll,
|
catch (error) {
|
||||||
onDownloadSelected,
|
toast.error(`Error downloading header: ${error}`);
|
||||||
onStopDownload,
|
}
|
||||||
onOpenFolder,
|
finally {
|
||||||
onAlbumClick,
|
setDownloadingHeader(false);
|
||||||
onArtistClick,
|
}
|
||||||
onPageChange,
|
};
|
||||||
onTrackClick,
|
const handleDownloadAvatar = async () => {
|
||||||
}: ArtistInfoProps) {
|
if (!artistInfo.images)
|
||||||
return (
|
return;
|
||||||
<div className="space-y-6">
|
setDownloadingAvatar(true);
|
||||||
<Card>
|
try {
|
||||||
<CardContent className="px-6">
|
const settings = getSettings();
|
||||||
<div className="flex gap-6 items-start">
|
const response = await downloadAvatar({
|
||||||
{artistInfo.images && (
|
avatar_url: artistInfo.images,
|
||||||
<img
|
artist_name: artistInfo.name,
|
||||||
src={artistInfo.images}
|
output_dir: settings.downloadPath,
|
||||||
alt={artistInfo.name}
|
});
|
||||||
className="w-48 h-48 rounded-full shadow-lg object-cover"
|
if (response.success) {
|
||||||
/>
|
if (response.already_exists) {
|
||||||
)}
|
toast.info("Avatar already exists");
|
||||||
<div className="flex-1 space-y-2">
|
}
|
||||||
<p className="text-sm font-medium">Artist</p>
|
else {
|
||||||
<h2 className="text-4xl font-bold">{artistInfo.name}</h2>
|
toast.success("Avatar downloaded successfully");
|
||||||
<div className="flex items-center gap-2 text-sm flex-wrap">
|
}
|
||||||
<span>{artistInfo.followers.toLocaleString()} followers</span>
|
}
|
||||||
<span>•</span>
|
else {
|
||||||
<span>{albumList.length} albums</span>
|
toast.error(response.error || "Failed to download avatar");
|
||||||
<span>•</span>
|
}
|
||||||
<span>{trackList.length} tracks</span>
|
}
|
||||||
{artistInfo.genres.length > 0 && (
|
catch (error) {
|
||||||
<>
|
toast.error(`Error downloading avatar: ${error}`);
|
||||||
<span>•</span>
|
}
|
||||||
<span>{artistInfo.genres.join(", ")}</span>
|
finally {
|
||||||
</>
|
setDownloadingAvatar(false);
|
||||||
)}
|
}
|
||||||
|
};
|
||||||
|
const handleDownloadGalleryImage = async (imageUrl: string, index: number) => {
|
||||||
|
setDownloadingGalleryIndex(index);
|
||||||
|
try {
|
||||||
|
const settings = getSettings();
|
||||||
|
const response = await downloadGalleryImage({
|
||||||
|
image_url: imageUrl,
|
||||||
|
artist_name: artistInfo.name,
|
||||||
|
image_index: index,
|
||||||
|
output_dir: settings.downloadPath,
|
||||||
|
});
|
||||||
|
if (response.success) {
|
||||||
|
if (response.already_exists) {
|
||||||
|
toast.info(`Gallery image ${index + 1} already exists`);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
toast.success(`Gallery image ${index + 1} downloaded successfully`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
toast.error(response.error || `Failed to download gallery image ${index + 1}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
toast.error(`Error downloading gallery image ${index + 1}: ${error}`);
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
setDownloadingGalleryIndex(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleDownloadAllGallery = async () => {
|
||||||
|
if (!artistInfo.gallery || artistInfo.gallery.length === 0)
|
||||||
|
return;
|
||||||
|
setDownloadingAllGallery(true);
|
||||||
|
try {
|
||||||
|
const settings = getSettings();
|
||||||
|
let successCount = 0;
|
||||||
|
let existsCount = 0;
|
||||||
|
let failCount = 0;
|
||||||
|
for (let index = 0; index < artistInfo.gallery.length; index++) {
|
||||||
|
const imageUrl = artistInfo.gallery[index];
|
||||||
|
try {
|
||||||
|
const response = await downloadGalleryImage({
|
||||||
|
image_url: imageUrl,
|
||||||
|
artist_name: artistInfo.name,
|
||||||
|
image_index: index,
|
||||||
|
output_dir: settings.downloadPath,
|
||||||
|
});
|
||||||
|
if (response.success) {
|
||||||
|
if (response.already_exists) {
|
||||||
|
existsCount++;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
successCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
failCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
failCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (failCount === 0) {
|
||||||
|
if (existsCount > 0 && successCount > 0) {
|
||||||
|
toast.success(`${successCount} images downloaded, ${existsCount} already existed`);
|
||||||
|
}
|
||||||
|
else if (existsCount > 0) {
|
||||||
|
toast.info(`All ${existsCount} images already exist`);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
toast.success(`All ${successCount} gallery images downloaded successfully`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
toast.error(`${failCount} images failed to download`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
toast.error(`Error downloading gallery images: ${error}`);
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
setDownloadingAllGallery(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (<div className="space-y-6">
|
||||||
|
<Card className="overflow-hidden p-0">
|
||||||
|
{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">
|
||||||
|
<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">
|
||||||
|
{downloadingHeader ? (<Spinner className="h-4 w-4"/>) : (<ImageDown className="h-4 w-4"/>)}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Download Header</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<div className="relative px-6 pt-6 pb-20">
|
||||||
|
<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"/>
|
||||||
|
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/50 transition-colors rounded-full flex items-center justify-center">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button onClick={handleDownloadAvatar} size="sm" variant="secondary" disabled={downloadingAvatar} className="opacity-0 group-hover:opacity-100 transition-opacity bg-white/10 hover:bg-white/20 text-white border-white/20">
|
||||||
|
{downloadingAvatar ? (<Spinner className="h-4 w-4"/>) : (<ImageDown className="h-4 w-4"/>)}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Download Avatar</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>)}
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<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"/>)}
|
||||||
|
</div>
|
||||||
|
{artistInfo.biography && (<p className="text-sm text-white/90">{artistInfo.biography}</p>)}
|
||||||
|
<div className="flex items-center gap-2 text-sm flex-wrap text-white/90">
|
||||||
|
<span>{artistInfo.followers.toLocaleString()} followers</span>
|
||||||
|
{artistInfo.listeners && (<>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{artistInfo.listeners.toLocaleString()} listeners</span>
|
||||||
|
</>)}
|
||||||
|
{artistInfo.rank && (<>
|
||||||
|
<span>•</span>
|
||||||
|
<span>#{artistInfo.rank} rank</span>
|
||||||
|
</>)}
|
||||||
|
<span>•</span>
|
||||||
|
<span>{albumList.length} {albumList.length === 1 ? "album" : "albums"}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{trackList.length} {trackList.length === 1 ? "track" : "tracks"}</span>
|
||||||
|
{artistInfo.genres.length > 0 && (<>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{artistInfo.genres.join(", ")}</span>
|
||||||
|
</>)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>) : (<CardContent className="px-6 py-6">
|
||||||
</CardContent>
|
<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"/>
|
||||||
|
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/50 transition-colors rounded-full flex items-center justify-center">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button onClick={handleDownloadAvatar} size="sm" variant="secondary" disabled={downloadingAvatar} className="opacity-0 group-hover:opacity-100 transition-opacity bg-white/10 hover:bg-white/20 text-white border-white/20">
|
||||||
|
{downloadingAvatar ? (<Spinner className="h-4 w-4"/>) : (<ImageDown className="h-4 w-4"/>)}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Download Avatar</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>)}
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<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"/>)}
|
||||||
|
</div>
|
||||||
|
{artistInfo.biography && (<p className="text-sm text-muted-foreground">{artistInfo.biography}</p>)}
|
||||||
|
<div className="flex items-center gap-2 text-sm flex-wrap">
|
||||||
|
<span>{artistInfo.followers.toLocaleString()} followers</span>
|
||||||
|
{artistInfo.listeners && (<>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{artistInfo.listeners.toLocaleString()} listeners</span>
|
||||||
|
</>)}
|
||||||
|
{artistInfo.rank && (<>
|
||||||
|
<span>•</span>
|
||||||
|
<span>#{artistInfo.rank} rank</span>
|
||||||
|
</>)}
|
||||||
|
<span>•</span>
|
||||||
|
<span>{albumList.length} albums</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{trackList.length} tracks</span>
|
||||||
|
{artistInfo.genres.length > 0 && (<>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{artistInfo.genres.join(", ")}</span>
|
||||||
|
</>)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{albumList.length > 0 && (
|
{artistInfo.gallery && artistInfo.gallery.length > 0 && (<div className="space-y-4">
|
||||||
<div className="space-y-4">
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-2xl font-bold">Gallery ({artistInfo.gallery.length})</h3>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button onClick={handleDownloadAllGallery} size="sm" variant="outline" disabled={downloadingAllGallery}>
|
||||||
|
{downloadingAllGallery ? <Spinner className="h-4 w-4"/> : <ImageDown className="h-4 w-4"/>}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Download All Gallery</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</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">
|
||||||
|
<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">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button onClick={() => handleDownloadGalleryImage(imageUrl, index)} size="sm" variant="secondary" disabled={downloadingGalleryIndex === index} className="opacity-0 group-hover:opacity-100 transition-opacity bg-white/10 hover:bg-white/20 text-white border-white/20">
|
||||||
|
{downloadingGalleryIndex === index ? (<Spinner className="h-4 w-4"/>) : (<ImageDown className="h-4 w-4"/>)}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Download Image {index + 1}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>))}
|
||||||
|
</div>
|
||||||
|
</div>)}
|
||||||
|
|
||||||
|
{albumList.length > 0 && (<div className="space-y-4">
|
||||||
<h3 className="text-2xl font-bold">Discography</h3>
|
<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">
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||||
{albumList.map((album) => (
|
{albumList.map((album) => (<div key={album.id} className="group cursor-pointer" onClick={() => onAlbumClick({
|
||||||
<div
|
|
||||||
key={album.id}
|
|
||||||
className="group cursor-pointer"
|
|
||||||
onClick={() =>
|
|
||||||
onAlbumClick({
|
|
||||||
id: album.id,
|
id: album.id,
|
||||||
name: album.name,
|
name: album.name,
|
||||||
external_urls: album.external_urls,
|
external_urls: album.external_urls,
|
||||||
})
|
})}>
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="relative mb-4">
|
<div className="relative mb-4">
|
||||||
{album.images && (
|
{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"/>)}
|
||||||
<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>
|
</div>
|
||||||
<h4 className="font-semibold truncate">{album.name}</h4>
|
<h4 className="font-semibold truncate">{album.name}</h4>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{album.release_date?.split("-")[0]} • {album.album_type}
|
{album.release_date?.split("-")[0]}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>))}
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>)}
|
||||||
)}
|
|
||||||
|
|
||||||
{trackList.length > 0 && (
|
{trackList.length > 0 && (<div className="space-y-4">
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||||
<h3 className="text-2xl font-bold">Popular Tracks</h3>
|
<h3 className="text-2xl font-bold">All Tracks</h3>
|
||||||
<div className="flex gap-2 flex-wrap">
|
<div className="flex gap-2 flex-wrap">
|
||||||
<Button onClick={onDownloadAll} size="sm" disabled={isDownloading}>
|
<Button onClick={onDownloadAll} size="sm" disabled={isDownloading}>
|
||||||
{isDownloading && bulkDownloadType === "all" ? (
|
{isDownloading && bulkDownloadType === "all" ? (<Spinner />) : (<Download className="h-4 w-4"/>)}
|
||||||
<Spinner />
|
|
||||||
) : (
|
|
||||||
<Download className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
Download All
|
Download All
|
||||||
</Button>
|
</Button>
|
||||||
{selectedTracks.length > 0 && (
|
{selectedTracks.length > 0 && (<Button onClick={onDownloadSelected} size="sm" variant="secondary" disabled={isDownloading}>
|
||||||
<Button
|
{isDownloading && bulkDownloadType === "selected" ? (<Spinner />) : (<Download className="h-4 w-4"/>)}
|
||||||
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})
|
||||||
</Button>
|
</Button>)}
|
||||||
)}
|
{onDownloadAllLyrics && (<Tooltip>
|
||||||
{downloadedTracks.size > 0 && (
|
<TooltipTrigger asChild>
|
||||||
<Button onClick={onOpenFolder} size="sm" variant="outline">
|
<Button onClick={onDownloadAllLyrics} size="sm" variant="outline" disabled={isBulkDownloadingLyrics}>
|
||||||
<FolderOpen className="h-4 w-4" />
|
{isBulkDownloadingLyrics ? <Spinner /> : <FileText className="h-4 w-4"/>}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Download All Lyrics</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>)}
|
||||||
|
{onDownloadAllCovers && (<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button onClick={onDownloadAllCovers} size="sm" variant="outline" disabled={isBulkDownloadingCovers}>
|
||||||
|
{isBulkDownloadingCovers ? <Spinner /> : <ImageDown className="h-4 w-4"/>}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Download All Covers</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>)}
|
||||||
|
{downloadedTracks.size > 0 && (<Button onClick={onOpenFolder} size="sm" variant="outline">
|
||||||
|
<FolderOpen className="h-4 w-4"/>
|
||||||
Open Folder
|
Open Folder
|
||||||
</Button>
|
</Button>)}
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{isDownloading && (
|
{isDownloading && (<DownloadProgress progress={downloadProgress} currentTrack={currentDownloadInfo} onStop={onStopDownload}/>)}
|
||||||
<DownloadProgress
|
<SearchAndSort searchQuery={searchQuery} sortBy={sortBy} onSearchChange={onSearchChange} onSortChange={onSortChange}/>
|
||||||
progress={downloadProgress}
|
<TrackList tracks={trackList} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={downloadedTracks} failedTracks={failedTracks} skippedTracks={skippedTracks} downloadingTrack={downloadingTrack} isDownloading={isDownloading} currentPage={currentPage} itemsPerPage={itemsPerPage} showCheckboxes={true} hideAlbumColumn={false} folderName={artistInfo.name} isArtistDiscography={true} downloadedLyrics={downloadedLyrics} failedLyrics={failedLyrics} skippedLyrics={skippedLyrics} downloadingLyricsTrack={downloadingLyricsTrack} checkingAvailabilityTrack={checkingAvailabilityTrack} availabilityMap={availabilityMap} onToggleTrack={onToggleTrack} onToggleSelectAll={onToggleSelectAll} onDownloadTrack={onDownloadTrack} onDownloadLyrics={onDownloadLyrics} onDownloadCover={onDownloadCover} downloadedCovers={downloadedCovers} failedCovers={failedCovers} skippedCovers={skippedCovers} downloadingCoverTrack={downloadingCoverTrack} onCheckAvailability={onCheckAvailability} onPageChange={onPageChange} onAlbumClick={onAlbumClick} onArtistClick={onArtistClick} onTrackClick={onTrackClick}/>
|
||||||
currentTrack={currentDownloadInfo}
|
</div>)}
|
||||||
onStop={onStopDownload}
|
</div>);
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<SearchAndSort
|
|
||||||
searchQuery={searchQuery}
|
|
||||||
sortBy={sortBy}
|
|
||||||
onSearchChange={onSearchChange}
|
|
||||||
onSortChange={onSortChange}
|
|
||||||
/>
|
|
||||||
<TrackList
|
|
||||||
tracks={trackList}
|
|
||||||
searchQuery={searchQuery}
|
|
||||||
sortBy={sortBy}
|
|
||||||
selectedTracks={selectedTracks}
|
|
||||||
downloadedTracks={downloadedTracks}
|
|
||||||
failedTracks={failedTracks}
|
|
||||||
skippedTracks={skippedTracks}
|
|
||||||
downloadingTrack={downloadingTrack}
|
|
||||||
isDownloading={isDownloading}
|
|
||||||
currentPage={currentPage}
|
|
||||||
itemsPerPage={itemsPerPage}
|
|
||||||
showCheckboxes={true}
|
|
||||||
hideAlbumColumn={false}
|
|
||||||
folderName={artistInfo.name}
|
|
||||||
isArtistDiscography={true}
|
|
||||||
downloadedLyrics={downloadedLyrics}
|
|
||||||
failedLyrics={failedLyrics}
|
|
||||||
skippedLyrics={skippedLyrics}
|
|
||||||
downloadingLyricsTrack={downloadingLyricsTrack}
|
|
||||||
onToggleTrack={onToggleTrack}
|
|
||||||
onToggleSelectAll={onToggleSelectAll}
|
|
||||||
onDownloadTrack={onDownloadTrack}
|
|
||||||
onDownloadLyrics={onDownloadLyrics}
|
|
||||||
onPageChange={onPageChange}
|
|
||||||
onAlbumClick={onAlbumClick}
|
|
||||||
onArtistClick={onArtistClick}
|
|
||||||
onTrackClick={onTrackClick}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,194 +1,125 @@
|
|||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import { Activity, Waves, Radio, TrendingUp, FileAudio, Clock, Gauge, HardDrive } from "lucide-react";
|
||||||
Activity,
|
|
||||||
Waves,
|
|
||||||
Radio,
|
|
||||||
TrendingUp,
|
|
||||||
FileAudio,
|
|
||||||
Clock,
|
|
||||||
Gauge,
|
|
||||||
HardDrive
|
|
||||||
} from "lucide-react";
|
|
||||||
import type { AnalysisResult } from "@/types/api";
|
import type { AnalysisResult } from "@/types/api";
|
||||||
|
|
||||||
interface AudioAnalysisProps {
|
interface AudioAnalysisProps {
|
||||||
result: AnalysisResult | null;
|
result: AnalysisResult | null;
|
||||||
analyzing: boolean;
|
analyzing: boolean;
|
||||||
onAnalyze?: () => void;
|
onAnalyze?: () => void;
|
||||||
showAnalyzeButton?: boolean;
|
showAnalyzeButton?: boolean;
|
||||||
|
filePath?: string;
|
||||||
}
|
}
|
||||||
|
export function AudioAnalysis({ result, analyzing, onAnalyze, showAnalyzeButton = true, filePath }: AudioAnalysisProps) {
|
||||||
export function AudioAnalysis({
|
if (analyzing) {
|
||||||
result,
|
return (<Card>
|
||||||
analyzing,
|
|
||||||
onAnalyze,
|
|
||||||
showAnalyzeButton = true
|
|
||||||
}: AudioAnalysisProps) {
|
|
||||||
if (analyzing) {
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="px-6">
|
<CardContent className="px-6">
|
||||||
<div className="flex items-center justify-center py-8 gap-3">
|
<div className="flex items-center justify-center py-8 gap-3">
|
||||||
<Spinner />
|
<Spinner />
|
||||||
<span className="text-muted-foreground">Analyzing audio quality...</span>
|
<span className="text-muted-foreground">Analyzing audio quality...</span>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>);
|
||||||
);
|
}
|
||||||
}
|
if (!result && showAnalyzeButton) {
|
||||||
|
return (<Card>
|
||||||
if (!result && showAnalyzeButton) {
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="px-6">
|
<CardContent className="px-6">
|
||||||
<div className="flex flex-col items-center justify-center py-8 gap-4">
|
<div className="flex flex-col items-center justify-center py-8 gap-4">
|
||||||
<Activity className="h-12 w-12 text-muted-foreground/50" />
|
<Activity className="h-12 w-12 text-primary"/>
|
||||||
<div className="text-center space-y-2">
|
<div className="text-center space-y-2">
|
||||||
<p className="font-medium">Audio Quality Analysis</p>
|
<p className="font-medium">Audio Quality Analysis</p>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Verify the true lossless quality of downloaded files
|
Verify the true lossless quality of downloaded files
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{onAnalyze && (
|
{onAnalyze && (<Button onClick={onAnalyze}>
|
||||||
<Button onClick={onAnalyze}>
|
<Activity className="h-4 w-4"/>
|
||||||
<Activity className="h-4 w-4" />
|
|
||||||
Analyze Audio
|
Analyze Audio
|
||||||
</Button>
|
</Button>)}
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>);
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!result) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatDuration = (seconds: number) => {
|
|
||||||
const mins = Math.floor(seconds / 60);
|
|
||||||
const secs = Math.floor(seconds % 60);
|
|
||||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatNumber = (num: number) => {
|
|
||||||
return num.toFixed(2);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Calculate Nyquist frequency (half of sample rate)
|
|
||||||
const nyquistFreq = result.sample_rate / 2;
|
|
||||||
|
|
||||||
// Calculate approximate data size (uncompressed PCM)
|
|
||||||
// Formula: sample_rate * channels * (bits_per_sample / 8) * duration
|
|
||||||
const dataSizeBytes = result.sample_rate * result.channels * (result.bits_per_sample / 8) * result.duration;
|
|
||||||
const dataSizeMB = dataSizeBytes / (1024 * 1024);
|
|
||||||
|
|
||||||
const formatDataSize = (mb: number) => {
|
|
||||||
if (mb >= 1024) {
|
|
||||||
return `${(mb / 1024).toFixed(2)} GB`;
|
|
||||||
}
|
}
|
||||||
return `${mb.toFixed(2)} MB`;
|
if (!result) {
|
||||||
};
|
return null;
|
||||||
|
}
|
||||||
return (
|
const formatDuration = (seconds: number) => {
|
||||||
<Card>
|
const mins = Math.floor(seconds / 60);
|
||||||
|
const secs = Math.floor(seconds % 60);
|
||||||
|
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
const formatNumber = (num: number) => {
|
||||||
|
return num.toFixed(2);
|
||||||
|
};
|
||||||
|
const 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];
|
||||||
|
};
|
||||||
|
const nyquistFreq = result.sample_rate / 2;
|
||||||
|
return (<Card className="gap-2">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="space-y-1">
|
{filePath && (<p className="text-sm font-mono break-all">{filePath}</p>)}
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<Activity className="h-5 w-5" />
|
|
||||||
Audio Quality Analysis
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Technical analysis of audio file properties
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="px-6 space-y-6">
|
<CardContent className="space-y-2">
|
||||||
|
|
||||||
{/* Technical Specifications */}
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="flex items-center gap-1">
|
||||||
<div className="space-y-1">
|
<Radio className="h-3 w-3 text-muted-foreground"/>
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
<span className="text-muted-foreground">Sample Rate:</span>
|
||||||
<Radio className="h-3 w-3" />
|
<span className="font-semibold">{(result.sample_rate / 1000).toFixed(1)} kHz</span>
|
||||||
Sample Rate
|
|
||||||
</div>
|
|
||||||
<p className="font-semibold">{(result.sample_rate / 1000).toFixed(1)} kHz</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
<div className="space-y-1">
|
<FileAudio className="h-3 w-3 text-muted-foreground"/>
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
<span className="text-muted-foreground">Bit Depth:</span>
|
||||||
<FileAudio className="h-3 w-3" />
|
<span className="font-semibold">{result.bit_depth}</span>
|
||||||
Bit Depth
|
|
||||||
</div>
|
|
||||||
<p className="font-semibold">{result.bit_depth}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
<div className="space-y-1">
|
<Waves className="h-3 w-3 text-muted-foreground"/>
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
<span className="text-muted-foreground">Channels:</span>
|
||||||
<Waves className="h-3 w-3" />
|
<span className="font-semibold">{result.channels === 2 ? "Stereo" : result.channels === 1 ? "Mono" : `${result.channels}`}</span>
|
||||||
Channels
|
|
||||||
</div>
|
|
||||||
<p className="font-semibold">{result.channels === 2 ? "Stereo" : result.channels === 1 ? "Mono" : `${result.channels} channels`}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
<div className="space-y-1">
|
<Clock className="h-3 w-3 text-muted-foreground"/>
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
<span className="text-muted-foreground">Duration:</span>
|
||||||
<Clock className="h-3 w-3" />
|
<span className="font-semibold">{formatDuration(result.duration)}</span>
|
||||||
Duration
|
|
||||||
</div>
|
|
||||||
<p className="font-semibold">{formatDuration(result.duration)}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
<div className="space-y-1">
|
<Gauge className="h-3 w-3 text-muted-foreground"/>
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
<span className="text-muted-foreground">Nyquist:</span>
|
||||||
<Gauge className="h-3 w-3" />
|
<span className="font-semibold">{(nyquistFreq / 1000).toFixed(1)} kHz</span>
|
||||||
Nyquist Frequency
|
|
||||||
</div>
|
|
||||||
<p className="font-semibold">{(nyquistFreq / 1000).toFixed(1)} kHz</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
||||||
<HardDrive className="h-3 w-3" />
|
|
||||||
Data Size
|
|
||||||
</div>
|
|
||||||
<p className="font-semibold">{formatDataSize(dataSizeMB)}</p>
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Dynamic Range Analysis */}
|
|
||||||
<div className="border rounded-lg p-4 space-y-3 bg-muted/30">
|
<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-2 text-sm font-medium">
|
<div className="flex items-center gap-1">
|
||||||
<TrendingUp className="h-4 w-4" />
|
<TrendingUp className="h-3 w-3 text-muted-foreground"/>
|
||||||
Dynamic Range Analysis
|
<span className="text-muted-foreground">Dynamic Range:</span>
|
||||||
|
<span className="font-semibold">{formatNumber(result.dynamic_range)} dB</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
<div className="grid grid-cols-3 gap-3 text-sm">
|
<span className="text-muted-foreground">Peak:</span>
|
||||||
<div>
|
<span className="font-semibold">{formatNumber(result.peak_amplitude)} dB</span>
|
||||||
<p className="text-xs text-muted-foreground">Dynamic Range</p>
|
</div>
|
||||||
<p className="font-semibold">{formatNumber(result.dynamic_range)} dB</p>
|
<div className="flex items-center gap-1">
|
||||||
</div>
|
<span className="text-muted-foreground">RMS:</span>
|
||||||
<div>
|
<span className="font-semibold">{formatNumber(result.rms_level)} dB</span>
|
||||||
<p className="text-xs text-muted-foreground">Peak Level</p>
|
</div>
|
||||||
<p className="font-semibold">{formatNumber(result.peak_amplitude)} dB</p>
|
<div className="flex items-center gap-1 ml-auto">
|
||||||
</div>
|
<span className="text-muted-foreground">Samples:</span>
|
||||||
<div>
|
<span className="font-semibold">{result.total_samples.toLocaleString()}</span>
|
||||||
<p className="text-xs text-muted-foreground">RMS Level</p>
|
|
||||||
<p className="font-semibold">{formatNumber(result.rms_level)} dB</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Technical Info Footer */}
|
|
||||||
<div className="pt-2 border-t">
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Total Samples: {result.total_samples.toLocaleString()}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,143 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from "@/components/ui/tooltip";
|
|
||||||
import { Activity, Upload, X } from "lucide-react";
|
|
||||||
import { AudioAnalysis } from "@/components/AudioAnalysis";
|
|
||||||
import { SpectrumVisualization } from "@/components/SpectrumVisualization";
|
|
||||||
import { useAudioAnalysis } from "@/hooks/useAudioAnalysis";
|
|
||||||
import { SelectFile } from "../../wailsjs/go/main/App";
|
|
||||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
|
||||||
|
|
||||||
export function AudioAnalysisDialog() {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const { analyzing, result, analyzeFile, clearResult } = useAudioAnalysis();
|
|
||||||
const [selectedFilePath, setSelectedFilePath] = useState<string>("");
|
|
||||||
|
|
||||||
const handleSelectFile = async () => {
|
|
||||||
try {
|
|
||||||
const filePath = await SelectFile();
|
|
||||||
if (filePath) {
|
|
||||||
setSelectedFilePath(filePath);
|
|
||||||
await analyzeFile(filePath);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
toast.error("File Selection Failed", {
|
|
||||||
description: err instanceof Error ? err.message : "Failed to select file",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
setOpen(false);
|
|
||||||
setTimeout(() => {
|
|
||||||
clearResult();
|
|
||||||
setSelectedFilePath("");
|
|
||||||
}, 200);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={(isOpen) => {
|
|
||||||
if (!isOpen) {
|
|
||||||
handleClose();
|
|
||||||
} else {
|
|
||||||
setOpen(true);
|
|
||||||
}
|
|
||||||
}}>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button variant="outline" size="icon">
|
|
||||||
<Activity className="h-5 w-5" />
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="left">
|
|
||||||
<p>Audio Quality Analyzer</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<DialogContent className="sm:max-w-[900px] max-h-[90vh] overflow-y-auto flex flex-col p-6 [&>button]:hidden custom-scrollbar" aria-describedby={undefined}>
|
|
||||||
<div className="absolute right-4 top-4">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-6 w-6 opacity-70 hover:opacity-100"
|
|
||||||
onClick={handleClose}
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogTitle className="text-sm font-medium">Audio Quality Analyzer</DialogTitle>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* File Selection */}
|
|
||||||
{!result && !analyzing && (
|
|
||||||
<div className="flex flex-col items-center justify-center py-12 border-2 border-dashed rounded-lg">
|
|
||||||
<Activity className="h-16 w-16 text-muted-foreground/50 mb-4" />
|
|
||||||
<h3 className="text-lg font-medium mb-2">Analyze FLAC Audio Quality</h3>
|
|
||||||
<p className="text-sm text-muted-foreground mb-6 text-center max-w-md">
|
|
||||||
Upload a FLAC file to verify true lossless quality, view detailed technical specifications, and see the frequency spectrum
|
|
||||||
</p>
|
|
||||||
<Button onClick={handleSelectFile} size="lg">
|
|
||||||
<Upload className="h-5 w-5" />
|
|
||||||
Select FLAC File
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Analysis Results */}
|
|
||||||
{result && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* File Info */}
|
|
||||||
<div className="p-3 bg-muted/30 rounded-lg">
|
|
||||||
<p className="text-xs text-muted-foreground">Analyzing file:</p>
|
|
||||||
<p className="text-sm font-mono truncate">{selectedFilePath}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Spectrum Visualization */}
|
|
||||||
<SpectrumVisualization
|
|
||||||
sampleRate={result.sample_rate}
|
|
||||||
bitsPerSample={result.bits_per_sample}
|
|
||||||
duration={result.duration}
|
|
||||||
spectrumData={result.spectrum}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Detailed Analysis */}
|
|
||||||
<AudioAnalysis
|
|
||||||
result={result}
|
|
||||||
analyzing={analyzing}
|
|
||||||
showAnalyzeButton={false}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="flex gap-2 justify-end pt-2">
|
|
||||||
<Button onClick={handleSelectFile} variant="outline">
|
|
||||||
<Upload className="h-4 w-4" />
|
|
||||||
Analyze Another File
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Loading State */}
|
|
||||||
{analyzing && !result && (
|
|
||||||
<div className="flex flex-col items-center justify-center py-12">
|
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { useState, useCallback, useEffect } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Upload, ArrowLeft, Trash2 } from "lucide-react";
|
||||||
|
import { AudioAnalysis } from "@/components/AudioAnalysis";
|
||||||
|
import { SpectrumVisualization } from "@/components/SpectrumVisualization";
|
||||||
|
import { useAudioAnalysis } from "@/hooks/useAudioAnalysis";
|
||||||
|
import { SelectFile } from "../../wailsjs/go/main/App";
|
||||||
|
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||||
|
import { OnFileDrop, OnFileDropOff } from "../../wailsjs/runtime/runtime";
|
||||||
|
interface AudioAnalysisPageProps {
|
||||||
|
onBack?: () => void;
|
||||||
|
}
|
||||||
|
export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
|
||||||
|
const { analyzing, result, analyzeFile, clearResult, selectedFilePath, spectrumLoading } = useAudioAnalysis();
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const handleSelectFile = async () => {
|
||||||
|
try {
|
||||||
|
const filePath = await SelectFile();
|
||||||
|
if (filePath) {
|
||||||
|
await analyzeFile(filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
toast.error("File Selection Failed", {
|
||||||
|
description: err instanceof Error ? err.message : "Failed to select file",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleFileDrop = useCallback(async (_x: number, _y: number, paths: string[]) => {
|
||||||
|
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",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await analyzeFile(filePath);
|
||||||
|
}, [analyzeFile]);
|
||||||
|
useEffect(() => {
|
||||||
|
OnFileDrop((x, y, paths) => {
|
||||||
|
handleFileDrop(x, y, paths);
|
||||||
|
}, true);
|
||||||
|
return () => {
|
||||||
|
OnFileDropOff();
|
||||||
|
};
|
||||||
|
}, [handleFileDrop]);
|
||||||
|
const handleAnalyzeAnother = () => {
|
||||||
|
clearResult();
|
||||||
|
};
|
||||||
|
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>
|
||||||
|
|
||||||
|
|
||||||
|
{!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();
|
||||||
|
setIsDragging(true);
|
||||||
|
}} onDragLeave={(e) => {
|
||||||
|
e.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>)}
|
||||||
|
|
||||||
|
|
||||||
|
{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>)}
|
||||||
|
|
||||||
|
|
||||||
|
{result && (<div className="space-y-4">
|
||||||
|
|
||||||
|
<AudioAnalysis result={result} analyzing={analyzing} showAnalyzeButton={false} filePath={selectedFilePath}/>
|
||||||
|
|
||||||
|
|
||||||
|
{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>);
|
||||||
|
}
|
||||||
@@ -0,0 +1,512 @@
|
|||||||
|
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 { Spinner } from "@/components/ui/spinner";
|
||||||
|
import { IsFFmpegInstalled, DownloadFFmpeg, ConvertAudio, SelectAudioFiles, } 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;
|
||||||
|
format: string;
|
||||||
|
size: number;
|
||||||
|
status: "pending" | "converting" | "success" | "error";
|
||||||
|
error?: string;
|
||||||
|
outputPath?: string;
|
||||||
|
}
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
const BITRATE_OPTIONS = [
|
||||||
|
{ value: "320k", label: "320k" },
|
||||||
|
{ value: "256k", label: "256k" },
|
||||||
|
{ value: "192k", label: "192k" },
|
||||||
|
{ value: "128k", label: "128k" },
|
||||||
|
];
|
||||||
|
const M4A_CODEC_OPTIONS = [
|
||||||
|
{ value: "aac", label: "AAC" },
|
||||||
|
{ value: "alac", label: "ALAC" },
|
||||||
|
];
|
||||||
|
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);
|
||||||
|
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 [outputFormat, setOutputFormat] = useState<"mp3" | "m4a">(() => {
|
||||||
|
try {
|
||||||
|
const saved = sessionStorage.getItem(STORAGE_KEY);
|
||||||
|
if (saved) {
|
||||||
|
const parsed = JSON.parse(saved);
|
||||||
|
if (parsed.outputFormat === "mp3" || parsed.outputFormat === "m4a") {
|
||||||
|
return parsed.outputFormat;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
}
|
||||||
|
return "mp3";
|
||||||
|
});
|
||||||
|
const [bitrate, setBitrate] = useState(() => {
|
||||||
|
try {
|
||||||
|
const saved = sessionStorage.getItem(STORAGE_KEY);
|
||||||
|
if (saved) {
|
||||||
|
const parsed = JSON.parse(saved);
|
||||||
|
if (parsed.bitrate) {
|
||||||
|
return parsed.bitrate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
}
|
||||||
|
return "320k";
|
||||||
|
});
|
||||||
|
const [m4aCodec, setM4aCodec] = useState<"aac" | "alac">(() => {
|
||||||
|
try {
|
||||||
|
const saved = sessionStorage.getItem(STORAGE_KEY);
|
||||||
|
if (saved) {
|
||||||
|
const parsed = JSON.parse(saved);
|
||||||
|
if (parsed.m4aCodec === "aac" || parsed.m4aCodec === "alac") {
|
||||||
|
return parsed.m4aCodec;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
}
|
||||||
|
return "aac";
|
||||||
|
});
|
||||||
|
const [converting, setConverting] = useState(false);
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
|
const saveState = useCallback((stateToSave: {
|
||||||
|
files: AudioFile[];
|
||||||
|
outputFormat: "mp3" | "m4a";
|
||||||
|
bitrate: string;
|
||||||
|
m4aCodec: "aac" | "alac";
|
||||||
|
}) => {
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(stateToSave));
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.error("Failed to save state:", err);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
useEffect(() => {
|
||||||
|
checkFfmpegInstallation();
|
||||||
|
}, []);
|
||||||
|
useEffect(() => {
|
||||||
|
saveState({ files, outputFormat, bitrate, m4aCodec });
|
||||||
|
}, [files, outputFormat, bitrate, m4aCodec, saveState]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (files.length === 0)
|
||||||
|
return;
|
||||||
|
const allMP3 = files.every((f) => f.format === "mp3");
|
||||||
|
if (allMP3 && outputFormat !== "m4a") {
|
||||||
|
setOutputFormat("m4a");
|
||||||
|
}
|
||||||
|
const hasFlac = files.some((f) => f.format === "flac");
|
||||||
|
if (!hasFlac && m4aCodec === "alac") {
|
||||||
|
setM4aCodec("aac");
|
||||||
|
}
|
||||||
|
}, [files, outputFormat, m4aCodec]);
|
||||||
|
const isFormatDisabled = files.length > 0 && files.every((f) => f.format === "mp3");
|
||||||
|
const hasFlacFiles = files.some((f) => f.format === "flac");
|
||||||
|
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 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();
|
||||||
|
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 addFiles = useCallback(async (paths: string[]) => {
|
||||||
|
const validExtensions = [".mp3", ".flac"];
|
||||||
|
const m4aFiles = paths.filter((path) => {
|
||||||
|
const ext = path.toLowerCase().slice(path.lastIndexOf("."));
|
||||||
|
return ext === ".m4a";
|
||||||
|
});
|
||||||
|
if (m4aFiles.length > 0) {
|
||||||
|
toast.error("M4A files not supported", {
|
||||||
|
description: "Only FLAC and MP3 files are supported as input. Please convert M4A files first.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
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) : {};
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
if (newFiles.length > 0) {
|
||||||
|
if (paths.length > newFiles.length) {
|
||||||
|
const skipped = paths.length - newFiles.length;
|
||||||
|
toast.info("Some files skipped", {
|
||||||
|
description: `${skipped} file(s) were skipped (unsupported format or already added)`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return [...prev, ...newFiles];
|
||||||
|
}
|
||||||
|
if (paths.length > 0 && m4aFiles.length === 0) {
|
||||||
|
toast.info("No new files added", {
|
||||||
|
description: "All files were already added or have unsupported format",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
const handleFileDrop = useCallback(async (_x: number, _y: number, paths: string[]) => {
|
||||||
|
setIsDragging(false);
|
||||||
|
if (paths.length === 0)
|
||||||
|
return;
|
||||||
|
addFiles(paths);
|
||||||
|
}, [addFiles]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (ffmpegInstalled === true) {
|
||||||
|
OnFileDrop((x, y, paths) => {
|
||||||
|
handleFileDrop(x, y, paths);
|
||||||
|
}, true);
|
||||||
|
return () => {
|
||||||
|
OnFileDropOff();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [handleFileDrop, ffmpegInstalled]);
|
||||||
|
const removeFile = (path: string) => {
|
||||||
|
setFiles((prev) => prev.filter((f) => f.path !== path));
|
||||||
|
};
|
||||||
|
const clearFiles = () => {
|
||||||
|
setFiles([]);
|
||||||
|
};
|
||||||
|
const handleConvert = async () => {
|
||||||
|
if (files.length === 0) {
|
||||||
|
toast.error("No files selected", {
|
||||||
|
description: "Please add audio files to convert",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setConverting(true);
|
||||||
|
try {
|
||||||
|
const inputPaths = files.map((f) => f.path);
|
||||||
|
setFiles((prev) => prev.map((f) => {
|
||||||
|
if (inputPaths.includes(f.path)) {
|
||||||
|
return { ...f, status: "converting" as const, error: undefined };
|
||||||
|
}
|
||||||
|
return f;
|
||||||
|
}));
|
||||||
|
const results = await ConvertAudio({
|
||||||
|
input_files: inputPaths,
|
||||||
|
output_format: outputFormat,
|
||||||
|
bitrate: bitrate,
|
||||||
|
codec: outputFormat === "m4a" ? m4aCodec : "",
|
||||||
|
});
|
||||||
|
setFiles((prev) => prev.map((f) => {
|
||||||
|
const result = results.find((r) => r.input_file === f.path);
|
||||||
|
if (result) {
|
||||||
|
return {
|
||||||
|
...f,
|
||||||
|
status: result.success ? "success" : "error",
|
||||||
|
error: result.error,
|
||||||
|
outputPath: result.output_file,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return f;
|
||||||
|
}));
|
||||||
|
const successCount = results.filter((r) => r.success).length;
|
||||||
|
const failCount = results.filter((r) => !r.success).length;
|
||||||
|
if (successCount > 0) {
|
||||||
|
toast.success("Conversion Complete", {
|
||||||
|
description: `Successfully converted ${successCount} file(s)${failCount > 0 ? `, ${failCount} failed` : ""}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else if (failCount > 0) {
|
||||||
|
toast.error("Conversion Failed", {
|
||||||
|
description: `All ${failCount} file(s) failed to convert`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
toast.error("Conversion Error", {
|
||||||
|
description: err instanceof Error ? err.message : "Unknown error",
|
||||||
|
});
|
||||||
|
setFiles((prev) => prev.map((f) => ({ ...f, status: "error" as const, error: "Conversion failed" })));
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
setConverting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const getStatusIcon = (status: AudioFile["status"]) => {
|
||||||
|
switch (status) {
|
||||||
|
case "converting":
|
||||||
|
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 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>
|
||||||
|
|
||||||
|
<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>)}
|
||||||
|
</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
|
||||||
|
? "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>
|
||||||
|
<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) => {
|
||||||
|
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) => {
|
||||||
|
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) => {
|
||||||
|
if (value)
|
||||||
|
setBitrate(value);
|
||||||
|
}}>
|
||||||
|
{BITRATE_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} converted
|
||||||
|
</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>);
|
||||||
|
}
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
import { useState, useEffect, useRef } from "react";
|
|
||||||
import { Bug, Trash2, X, Copy, Check } from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { logger, type LogEntry } from "@/lib/logger";
|
|
||||||
|
|
||||||
const levelColors: Record<string, string> = {
|
|
||||||
info: "text-blue-500",
|
|
||||||
success: "text-green-500",
|
|
||||||
warning: "text-yellow-500",
|
|
||||||
error: "text-red-500",
|
|
||||||
debug: "text-gray-500",
|
|
||||||
};
|
|
||||||
|
|
||||||
function formatTime(date: Date): string {
|
|
||||||
return date.toLocaleTimeString("en-US", {
|
|
||||||
hour12: false,
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
second: "2-digit",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DebugLogger() {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
|
||||||
const [copied, setCopied] = useState(false);
|
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const unsubscribe = logger.subscribe(() => {
|
|
||||||
setLogs(logger.getLogs());
|
|
||||||
});
|
|
||||||
setLogs(logger.getLogs());
|
|
||||||
return () => {
|
|
||||||
unsubscribe();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (scrollRef.current) {
|
|
||||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
|
||||||
}
|
|
||||||
}, [logs]);
|
|
||||||
|
|
||||||
const handleClear = () => {
|
|
||||||
logger.clear();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCopy = async () => {
|
|
||||||
const logText = logs
|
|
||||||
.map((log) => `[${formatTime(log.timestamp)}] [${log.level}] ${log.message}`)
|
|
||||||
.join("\n");
|
|
||||||
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(logText);
|
|
||||||
setCopied(true);
|
|
||||||
setTimeout(() => setCopied(false), 500);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to copy logs:", err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-6 w-6 opacity-50 hover:opacity-100"
|
|
||||||
>
|
|
||||||
<Bug className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent className="sm:max-w-[600px] max-h-[80vh] p-6 [&>button]:hidden">
|
|
||||||
<DialogTitle className="text-sm font-medium">Debug Logs</DialogTitle>
|
|
||||||
<div className="absolute right-4 top-4 flex items-center gap-1">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-6 w-6 opacity-70 hover:opacity-100"
|
|
||||||
onClick={handleCopy}
|
|
||||||
disabled={logs.length === 0}
|
|
||||||
>
|
|
||||||
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-6 w-6 opacity-70 hover:opacity-100"
|
|
||||||
onClick={handleClear}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-6 w-6 opacity-70 hover:opacity-100"
|
|
||||||
onClick={() => setOpen(false)}
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
ref={scrollRef}
|
|
||||||
className="h-[400px] overflow-y-auto bg-muted/50 rounded-md p-3 font-mono text-xs"
|
|
||||||
>
|
|
||||||
{logs.length === 0 ? (
|
|
||||||
<p className="text-muted-foreground lowercase">no logs yet...</p>
|
|
||||||
) : (
|
|
||||||
logs.map((log, i) => (
|
|
||||||
<div key={i} className="flex gap-2 py-0.5">
|
|
||||||
<span className="text-muted-foreground shrink-0">
|
|
||||||
[{formatTime(log.timestamp)}]
|
|
||||||
</span>
|
|
||||||
<span className={`shrink-0 w-16 ${levelColors[log.level]}`}>
|
|
||||||
[{log.level}]
|
|
||||||
</span>
|
|
||||||
<span className="break-all">{log.message}</span>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
import { Trash2, Copy, Check } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { logger, type LogEntry } from "@/lib/logger";
|
||||||
|
const levelColors: Record<string, string> = {
|
||||||
|
info: "text-blue-500",
|
||||||
|
success: "text-green-500",
|
||||||
|
warning: "text-yellow-500",
|
||||||
|
error: "text-red-500",
|
||||||
|
debug: "text-gray-500",
|
||||||
|
};
|
||||||
|
function formatTime(date: Date): string {
|
||||||
|
return date.toLocaleTimeString("en-US", {
|
||||||
|
hour12: false,
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
export function DebugLoggerPage() {
|
||||||
|
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = logger.subscribe(() => {
|
||||||
|
setLogs(logger.getLogs());
|
||||||
|
});
|
||||||
|
setLogs(logger.getLogs());
|
||||||
|
return () => {
|
||||||
|
unsubscribe();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
useEffect(() => {
|
||||||
|
if (scrollRef.current) {
|
||||||
|
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||||
|
}
|
||||||
|
}, [logs]);
|
||||||
|
const handleClear = () => {
|
||||||
|
logger.clear();
|
||||||
|
};
|
||||||
|
const handleCopy = async () => {
|
||||||
|
const logText = logs
|
||||||
|
.map((log) => `[${formatTime(log.timestamp)}] [${log.level}] ${log.message}`)
|
||||||
|
.join("\n");
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(logText);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 500);
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.error("Failed to copy logs:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
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={handleCopy} disabled={logs.length === 0}>
|
||||||
|
{copied ? <Check className="h-4 w-4"/> : <Copy className="h-4 w-4"/>}
|
||||||
|
Copy
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" className="gap-1.5" onClick={handleClear} disabled={logs.length === 0}>
|
||||||
|
<Trash2 className="h-4 w-4"/>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ref={scrollRef} className="h-[calc(100vh-220px)] overflow-y-auto bg-muted/50 rounded-lg p-4 font-mono text-xs">
|
||||||
|
{logs.length === 0 ? (<p className="text-muted-foreground lowercase">no logs yet...</p>) : (logs.map((log, i) => (<div key={i} className="flex gap-2 py-0.5">
|
||||||
|
<span className="text-muted-foreground shrink-0">
|
||||||
|
[{formatTime(log.timestamp)}]
|
||||||
|
</span>
|
||||||
|
<span className={`shrink-0 w-16 ${levelColors[log.level]}`}>
|
||||||
|
[{log.level}]
|
||||||
|
</span>
|
||||||
|
<span className="break-all">{log.message}</span>
|
||||||
|
</div>)))}
|
||||||
|
</div>
|
||||||
|
</div>);
|
||||||
|
}
|
||||||
@@ -1,29 +1,29 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import { StopCircle } from "lucide-react";
|
import { StopCircle } from "lucide-react";
|
||||||
|
|
||||||
interface DownloadProgressProps {
|
interface DownloadProgressProps {
|
||||||
progress: number;
|
progress: number;
|
||||||
currentTrack: { name: string; artists: string } | null;
|
currentTrack: {
|
||||||
onStop: () => void;
|
name: string;
|
||||||
|
artists: string;
|
||||||
|
} | null;
|
||||||
|
onStop: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DownloadProgress({ progress, currentTrack, onStop }: DownloadProgressProps) {
|
export function DownloadProgress({ progress, currentTrack, onStop }: DownloadProgressProps) {
|
||||||
return (
|
const clampedProgress = Math.min(100, Math.max(0, progress));
|
||||||
<div className="w-full space-y-2 mt-4">
|
return (<div className="w-full space-y-2 mt-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Progress value={progress} className="h-2 flex-1" />
|
<Progress value={clampedProgress} className="h-2 flex-1"/>
|
||||||
<Button variant="destructive" size="sm" onClick={onStop} className="gap-1.5">
|
<Button variant="destructive" size="sm" onClick={onStop} className="gap-1.5">
|
||||||
<StopCircle className="h-4 w-4" />
|
<StopCircle className="h-4 w-4"/>
|
||||||
Stop
|
Stop
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{progress}% -{" "}
|
{clampedProgress}% -{" "}
|
||||||
{currentTrack
|
{currentTrack
|
||||||
? `${currentTrack.name} - ${currentTrack.artists}`
|
? `${currentTrack.name} - ${currentTrack.artists}`
|
||||||
: "Preparing download..."}
|
: "Preparing download..."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +1,31 @@
|
|||||||
import { useDownloadProgress } from "@/hooks/useDownloadProgress";
|
import { useDownloadProgress } from "@/hooks/useDownloadProgress";
|
||||||
import { Download } from "lucide-react";
|
import { useDownloadQueueData } from "@/hooks/useDownloadQueueData";
|
||||||
|
import { Download, ChevronRight } from "lucide-react";
|
||||||
export function DownloadProgressToast() {
|
import { Button } from "@/components/ui/button";
|
||||||
const progress = useDownloadProgress();
|
interface DownloadProgressToastProps {
|
||||||
|
onClick: () => void;
|
||||||
if (!progress.is_downloading) {
|
}
|
||||||
return null;
|
export function DownloadProgressToast({ onClick }: DownloadProgressToastProps) {
|
||||||
}
|
const progress = useDownloadProgress();
|
||||||
|
const queueInfo = useDownloadQueueData();
|
||||||
return (
|
const hasActiveDownloads = queueInfo.queue.some(item => item.status === "queued" || item.status === "downloading");
|
||||||
<div className="fixed bottom-4 left-4 z-50 animate-in slide-in-from-bottom-5 data-[state=closed]:animate-out data-[state=closed]:slide-out-to-bottom-5">
|
if (!hasActiveDownloads) {
|
||||||
<div className="bg-background border rounded-lg shadow-lg p-3">
|
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}>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Download className="h-4 w-4 text-primary animate-bounce" />
|
<Download className={`h-4 w-4 text-primary ${progress.is_downloading ? 'animate-bounce' : ''}`}/>
|
||||||
<div className="flex flex-col min-w-[80px]">
|
<div className="flex flex-col min-w-[80px]">
|
||||||
<p className="text-sm font-medium font-mono tabular-nums">
|
<p className="text-sm font-medium font-mono tabular-nums">
|
||||||
{progress.mb_downloaded.toFixed(2)} MB
|
{progress.mb_downloaded.toFixed(2)} MB
|
||||||
</p>
|
</p>
|
||||||
{progress.speed_mbps > 0 && (
|
{progress.speed_mbps > 0 && (<p className="text-xs text-muted-foreground font-mono tabular-nums">
|
||||||
<p className="text-xs text-muted-foreground font-mono tabular-nums">
|
|
||||||
{progress.speed_mbps.toFixed(2)} MB/s
|
{progress.speed_mbps.toFixed(2)} MB/s
|
||||||
</p>
|
</p>)}
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
<ChevronRight className="h-4 w-4 text-muted-foreground ml-1"/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Button>
|
||||||
</div>
|
</div>);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,231 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { X, Download, CheckCircle2, XCircle, Clock, FileCheck, Trash2, HardDrive, Zap, Timer } 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 { backend } from "../../wailsjs/go/models";
|
||||||
|
interface DownloadQueueProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
export function DownloadQueue({ isOpen, onClose }: DownloadQueueProps) {
|
||||||
|
const [queueInfo, setQueueInfo] = useState<backend.DownloadQueueInfo>(new backend.DownloadQueueInfo({
|
||||||
|
is_downloading: false,
|
||||||
|
queue: [],
|
||||||
|
current_speed: 0,
|
||||||
|
total_downloaded: 0,
|
||||||
|
session_start_time: 0,
|
||||||
|
queued_count: 0,
|
||||||
|
completed_count: 0,
|
||||||
|
failed_count: 0,
|
||||||
|
skipped_count: 0,
|
||||||
|
}));
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen)
|
||||||
|
return;
|
||||||
|
const fetchQueue = async () => {
|
||||||
|
try {
|
||||||
|
const info = await GetDownloadQueue();
|
||||||
|
setQueueInfo(info);
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error("Failed to get download queue:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchQueue();
|
||||||
|
const interval = setInterval(fetchQueue, 500);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [isOpen]);
|
||||||
|
const handleClearHistory = async () => {
|
||||||
|
try {
|
||||||
|
await ClearCompletedDownloads();
|
||||||
|
const info = await GetDownloadQueue();
|
||||||
|
setQueueInfo(info);
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error("Failed to clear history:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const getStatusIcon = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case "downloading":
|
||||||
|
return <Download className="h-4 w-4 text-blue-500 animate-bounce"/>;
|
||||||
|
case "completed":
|
||||||
|
return <CheckCircle2 className="h-4 w-4 text-green-500"/>;
|
||||||
|
case "failed":
|
||||||
|
return <XCircle className="h-4 w-4 text-red-500"/>;
|
||||||
|
case "skipped":
|
||||||
|
return <FileCheck className="h-4 w-4 text-yellow-500"/>;
|
||||||
|
case "queued":
|
||||||
|
return <Clock className="h-4 w-4 text-muted-foreground"/>;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const getStatusBadge = (status: string) => {
|
||||||
|
const variants: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
|
||||||
|
downloading: "default",
|
||||||
|
completed: "outline",
|
||||||
|
failed: "destructive",
|
||||||
|
skipped: "secondary",
|
||||||
|
queued: "outline",
|
||||||
|
};
|
||||||
|
return (<Badge variant={variants[status] || "outline"} className="text-xs">
|
||||||
|
{status}
|
||||||
|
</Badge>);
|
||||||
|
};
|
||||||
|
const formatDuration = (startTimestamp: number) => {
|
||||||
|
if (startTimestamp === 0)
|
||||||
|
return "—";
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const durationSeconds = now - startTimestamp;
|
||||||
|
const hours = Math.floor(durationSeconds / 3600);
|
||||||
|
const minutes = Math.floor((durationSeconds % 3600) / 60);
|
||||||
|
const seconds = durationSeconds % 60;
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${hours}h ${minutes}m ${seconds}s`;
|
||||||
|
}
|
||||||
|
else if (minutes > 0) {
|
||||||
|
return `${minutes}m ${seconds}s`;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return `${seconds}s`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
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>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</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>
|
||||||
|
|
||||||
|
|
||||||
|
{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
|
||||||
|
? `${item.speed.toFixed(2)} MB/s`
|
||||||
|
: queueInfo.current_speed > 0
|
||||||
|
? `${queueInfo.current_speed.toFixed(2)} MB/s`
|
||||||
|
: "—"}
|
||||||
|
</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 === "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>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>);
|
||||||
|
}
|
||||||
@@ -1,91 +1,96 @@
|
|||||||
import { X } from "lucide-react";
|
import { X, Music2, Disc3, ListMusic, UserRound } from "lucide-react";
|
||||||
|
|
||||||
export interface HistoryItem {
|
export interface HistoryItem {
|
||||||
id: string;
|
id: string;
|
||||||
url: string;
|
url: string;
|
||||||
type: "track" | "album" | "playlist" | "artist";
|
type: "track" | "album" | "playlist" | "artist";
|
||||||
name: string;
|
name: string;
|
||||||
artist: string;
|
artist: string;
|
||||||
image: string;
|
image: string;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FetchHistoryProps {
|
interface FetchHistoryProps {
|
||||||
history: HistoryItem[];
|
history: HistoryItem[];
|
||||||
onSelect: (item: HistoryItem) => void;
|
onSelect: (item: HistoryItem) => void;
|
||||||
onRemove: (id: string) => void;
|
onRemove: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FetchHistory({ history, onSelect, onRemove }: FetchHistoryProps) {
|
export function FetchHistory({ history, onSelect, onRemove }: FetchHistoryProps) {
|
||||||
if (history.length === 0) return null;
|
if (history.length === 0)
|
||||||
|
return null;
|
||||||
const getTypeLabel = (type: string) => {
|
const getTypeLabel = (type: string) => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case "track":
|
case "track":
|
||||||
return "Track";
|
return "Track";
|
||||||
case "album":
|
case "album":
|
||||||
return "Album";
|
return "Album";
|
||||||
case "playlist":
|
case "playlist":
|
||||||
return "Playlist";
|
return "Playlist";
|
||||||
case "artist":
|
case "artist":
|
||||||
return "Artist";
|
return "Artist";
|
||||||
default:
|
default:
|
||||||
return type;
|
return type;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const getTypeIcon = (type: string) => {
|
||||||
return (
|
switch (type) {
|
||||||
<div className="space-y-2">
|
case "track":
|
||||||
<span className="text-sm text-muted-foreground">Recent Fetches</span>
|
return Music2;
|
||||||
|
case "album":
|
||||||
|
return Disc3;
|
||||||
|
case "playlist":
|
||||||
|
return ListMusic;
|
||||||
|
case "artist":
|
||||||
|
return UserRound;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const getTypeBadgeClass = (type: string) => {
|
||||||
|
switch (type) {
|
||||||
|
case "track":
|
||||||
|
return "bg-blue-500/10 text-blue-600 dark:bg-blue-500/20 dark:text-blue-400";
|
||||||
|
case "album":
|
||||||
|
return "bg-green-500/10 text-green-600 dark:bg-green-500/20 dark:text-green-400";
|
||||||
|
case "playlist":
|
||||||
|
return "bg-purple-500/10 text-purple-600 dark:bg-purple-500/20 dark:text-purple-400";
|
||||||
|
case "artist":
|
||||||
|
return "bg-orange-500/10 text-orange-600 dark:bg-orange-500/20 dark:text-orange-400";
|
||||||
|
default:
|
||||||
|
return "bg-muted text-muted-foreground";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (<div className="space-y-2">
|
||||||
|
<span className="text-sm text-muted-foreground">{history.length === 1 ? "Recent Fetch" : "Recent Fetches"}</span>
|
||||||
<div className="flex gap-2 overflow-x-auto pb-2 pt-2">
|
<div className="flex gap-2 overflow-x-auto pb-2 pt-2">
|
||||||
{history.map((item) => (
|
{history.map((item) => (<div key={item.id} className="relative shrink-0 w-[130px] group cursor-pointer rounded-lg border bg-card hover:bg-accent transition-colors overflow-visible" onClick={() => onSelect(item)}>
|
||||||
<div
|
<button type="button" className="absolute -top-1.5 -right-1.5 z-10 w-5 h-5 rounded-full bg-red-500 hover:bg-red-600 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-all cursor-pointer shadow-sm" onClick={(e) => {
|
||||||
key={item.id}
|
|
||||||
className="relative shrink-0 w-[130px] group cursor-pointer rounded-lg border bg-card hover:bg-accent transition-colors overflow-visible"
|
|
||||||
onClick={() => onSelect(item)}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="absolute -top-1.5 -right-1.5 z-10 w-5 h-5 rounded-full bg-red-500 hover:bg-red-600 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-all cursor-pointer shadow-sm"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onRemove(item.id);
|
onRemove(item.id);
|
||||||
}}
|
}}>
|
||||||
>
|
<X className="h-3 w-3 text-red-900" strokeWidth={3}/>
|
||||||
<X className="h-3 w-3 text-red-900" strokeWidth={3} />
|
|
||||||
</button>
|
</button>
|
||||||
<div className="p-2">
|
<div className="p-2">
|
||||||
<div className="aspect-square w-full rounded-md overflow-hidden mb-2 bg-muted">
|
<div className="aspect-square w-full rounded-md overflow-hidden mb-2 bg-muted">
|
||||||
{item.image ? (
|
{item.image ? (<img src={item.image} alt={item.name} className="w-full h-full object-cover"/>) : (<div className="w-full h-full flex items-center justify-center text-muted-foreground text-xs">
|
||||||
<img
|
|
||||||
src={item.image}
|
|
||||||
alt={item.name}
|
|
||||||
className="w-full h-full object-cover"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="w-full h-full flex items-center justify-center text-muted-foreground text-xs">
|
|
||||||
No Image
|
No Image
|
||||||
</div>
|
</div>)}
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<p className="text-xs font-medium truncate" title={item.name}>
|
<p className="text-xs font-medium truncate" title={item.name}>
|
||||||
{item.name}
|
{item.name}
|
||||||
</p>
|
</p>
|
||||||
<p
|
<p className="text-xs text-muted-foreground truncate" title={item.artist}>
|
||||||
className="text-xs text-muted-foreground truncate"
|
|
||||||
title={item.artist}
|
|
||||||
>
|
|
||||||
{item.artist}
|
{item.artist}
|
||||||
</p>
|
</p>
|
||||||
<span className="inline-block text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
|
{(() => {
|
||||||
{getTypeLabel(item.type)}
|
const IconComponent = getTypeIcon(item.type);
|
||||||
</span>
|
return (<span className={`inline-flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded ${getTypeBadgeClass(item.type)}`}>
|
||||||
|
{IconComponent ? <IconComponent className="h-2.5 w-2.5"/> : null}
|
||||||
|
{getTypeLabel(item.type)}
|
||||||
|
</span>);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>))}
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,793 @@
|
|||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { InputWithContext } from "@/components/ui/input-with-context";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select";
|
||||||
|
import { FolderOpen, RefreshCw, FileMusic, ChevronRight, ChevronDown, Pencil, Eye, Folder, Info, RotateCcw, FileText, Image, Copy, Check, } from "lucide-react";
|
||||||
|
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { SelectFolder } from "../../wailsjs/go/main/App";
|
||||||
|
import { backend } from "../../wailsjs/go/models";
|
||||||
|
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||||
|
import { getSettings } from "@/lib/settings";
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
|
||||||
|
const ListDirectoryFiles = (path: string): Promise<backend.FileInfo[]> => (window as any)['go']['main']['App']['ListDirectoryFiles'](path);
|
||||||
|
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);
|
||||||
|
interface FileNode {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
is_dir: boolean;
|
||||||
|
size: number;
|
||||||
|
children?: FileNode[];
|
||||||
|
expanded?: boolean;
|
||||||
|
}
|
||||||
|
interface FileMetadata {
|
||||||
|
title: string;
|
||||||
|
artist: string;
|
||||||
|
album: string;
|
||||||
|
album_artist: string;
|
||||||
|
track_number: number;
|
||||||
|
disc_number: number;
|
||||||
|
year: string;
|
||||||
|
}
|
||||||
|
type TabType = "track" | "lyric" | "cover";
|
||||||
|
const FORMAT_PRESETS: Record<string, {
|
||||||
|
label: string;
|
||||||
|
template: string;
|
||||||
|
}> = {
|
||||||
|
"title": { label: "Title", template: "{title}" },
|
||||||
|
"title-artist": { label: "Title - Artist", template: "{title} - {artist}" },
|
||||||
|
"artist-title": { label: "Artist - Title", template: "{artist} - {title}" },
|
||||||
|
"track-title": { label: "Track. Title", template: "{track}. {title}" },
|
||||||
|
"track-title-artist": { label: "Track. Title - Artist", template: "{track}. {title} - {artist}" },
|
||||||
|
"track-artist-title": { label: "Track. Artist - Title", template: "{track}. {artist} - {title}" },
|
||||||
|
"title-album-artist": { label: "Title - Album Artist", template: "{title} - {album_artist}" },
|
||||||
|
"track-title-album-artist": { label: "Track. Title - Album Artist", template: "{track}. {title} - {album_artist}" },
|
||||||
|
"artist-album-title": { label: "Artist - Album - Title", template: "{artist} - {album} - {title}" },
|
||||||
|
"track-dash-title": { label: "Track - Title", template: "{track} - {title}" },
|
||||||
|
"disc-track-title": { label: "Disc-Track. Title", template: "{disc}-{track}. {title}" },
|
||||||
|
"disc-track-title-artist": { label: "Disc-Track. Title - Artist", template: "{disc}-{track}. {title} - {artist}" },
|
||||||
|
"custom": { label: "Custom...", template: "{title} - {artist}" },
|
||||||
|
};
|
||||||
|
const STORAGE_KEY = "spotiflac_file_manager_state";
|
||||||
|
const DEFAULT_PRESET = "title-artist";
|
||||||
|
const DEFAULT_CUSTOM_FORMAT = "{title} - {artist}";
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
export function FileManagerPage() {
|
||||||
|
const [rootPath, setRootPath] = useState(() => {
|
||||||
|
const settings = getSettings();
|
||||||
|
return settings.downloadPath || "";
|
||||||
|
});
|
||||||
|
const [allFiles, setAllFiles] = useState<FileNode[]>([]);
|
||||||
|
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [activeTab, setActiveTab] = useState<TabType>("track");
|
||||||
|
const [formatPreset, setFormatPreset] = useState<string>(() => {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (saved) {
|
||||||
|
const parsed = JSON.parse(saved);
|
||||||
|
if (parsed.formatPreset && FORMAT_PRESETS[parsed.formatPreset]) {
|
||||||
|
return parsed.formatPreset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
return DEFAULT_PRESET;
|
||||||
|
});
|
||||||
|
const [customFormat, setCustomFormat] = useState(() => {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (saved) {
|
||||||
|
const parsed = JSON.parse(saved);
|
||||||
|
if (parsed.customFormat)
|
||||||
|
return parsed.customFormat;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
return DEFAULT_CUSTOM_FORMAT;
|
||||||
|
});
|
||||||
|
const renameFormat = formatPreset === "custom" ? (customFormat || FORMAT_PRESETS["custom"].template) : FORMAT_PRESETS[formatPreset].template;
|
||||||
|
const [showPreview, setShowPreview] = useState(false);
|
||||||
|
const [previewData, setPreviewData] = useState<backend.RenamePreview[]>([]);
|
||||||
|
const [renaming, setRenaming] = useState(false);
|
||||||
|
const [previewOnly, setPreviewOnly] = useState(false);
|
||||||
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
|
const [showResetConfirm, setShowResetConfirm] = useState(false);
|
||||||
|
const [showMetadata, setShowMetadata] = useState(false);
|
||||||
|
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("");
|
||||||
|
const [lyricsTab, setLyricsTab] = useState<"synced" | "plain">("synced");
|
||||||
|
const [copySuccess, setCopySuccess] = useState(false);
|
||||||
|
const [showCoverPreview, setShowCoverPreview] = useState(false);
|
||||||
|
const [coverFile, setCoverFile] = useState("");
|
||||||
|
const [coverData, setCoverData] = useState("");
|
||||||
|
const [showManualRename, setShowManualRename] = useState(false);
|
||||||
|
const [manualRenameFile, setManualRenameFile] = useState("");
|
||||||
|
const [manualRenameName, setManualRenameName] = useState("");
|
||||||
|
const [manualRenaming, setManualRenaming] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify({ formatPreset, customFormat }));
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}, [formatPreset, customFormat]);
|
||||||
|
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 filterFilesByType = (nodes: FileNode[], type: TabType): FileNode[] => {
|
||||||
|
return nodes
|
||||||
|
.map((node) => {
|
||||||
|
if (node.is_dir && node.children) {
|
||||||
|
const filteredChildren = filterFilesByType(node.children, type);
|
||||||
|
if (filteredChildren.length > 0) {
|
||||||
|
return { ...node, children: filteredChildren };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const ext = node.name.toLowerCase();
|
||||||
|
if (type === "track" && (ext.endsWith(".flac") || ext.endsWith(".mp3") || ext.endsWith(".m4a")))
|
||||||
|
return node;
|
||||||
|
if (type === "lyric" && ext.endsWith(".lrc"))
|
||||||
|
return node;
|
||||||
|
if (type === "cover" && (ext.endsWith(".jpg") || ext.endsWith(".jpeg") || ext.endsWith(".png")))
|
||||||
|
return node;
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
.filter((node): node is FileNode => node !== null);
|
||||||
|
};
|
||||||
|
const loadFiles = useCallback(async () => {
|
||||||
|
if (!rootPath)
|
||||||
|
return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await ListDirectoryFiles(rootPath);
|
||||||
|
if (!result || !Array.isArray(result)) {
|
||||||
|
setAllFiles([]);
|
||||||
|
setSelectedFiles(new Set());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAllFiles(result as FileNode[]);
|
||||||
|
setSelectedFiles(new Set());
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
const errorMsg = err instanceof Error ? err.message : String(err || "");
|
||||||
|
if (!errorMsg.toLowerCase().includes("empty") && !errorMsg.toLowerCase().includes("no file")) {
|
||||||
|
toast.error("Failed to load files", { description: errorMsg || "Unknown error" });
|
||||||
|
}
|
||||||
|
setAllFiles([]);
|
||||||
|
setSelectedFiles(new Set());
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [rootPath]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (rootPath)
|
||||||
|
loadFiles();
|
||||||
|
}, [rootPath, loadFiles]);
|
||||||
|
const filteredFiles = filterFilesByType(allFiles, activeTab);
|
||||||
|
const getAllFilesFlat = (nodes: FileNode[]): FileNode[] => {
|
||||||
|
const result: FileNode[] = [];
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (!node.is_dir)
|
||||||
|
result.push(node);
|
||||||
|
if (node.children)
|
||||||
|
result.push(...getAllFilesFlat(node.children));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
const allAudioFiles = getAllFilesFlat(filterFilesByType(allFiles, "track"));
|
||||||
|
const allLyricFiles = getAllFilesFlat(filterFilesByType(allFiles, "lyric"));
|
||||||
|
const allCoverFiles = getAllFilesFlat(filterFilesByType(allFiles, "cover"));
|
||||||
|
const handleSelectFolder = async () => {
|
||||||
|
try {
|
||||||
|
const path = await SelectFolder(rootPath);
|
||||||
|
if (path)
|
||||||
|
setRootPath(path);
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
toast.error("Failed to select folder", { description: err instanceof Error ? err.message : "Unknown error" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const toggleExpand = (path: string) => {
|
||||||
|
setAllFiles((prev) => toggleNodeExpand(prev, path));
|
||||||
|
};
|
||||||
|
const toggleNodeExpand = (nodes: FileNode[], path: string): FileNode[] => {
|
||||||
|
return nodes.map((node) => {
|
||||||
|
if (node.path === path)
|
||||||
|
return { ...node, expanded: !node.expanded };
|
||||||
|
if (node.children)
|
||||||
|
return { ...node, children: toggleNodeExpand(node.children, path) };
|
||||||
|
return node;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const toggleSelect = (path: string) => {
|
||||||
|
setSelectedFiles((prev) => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
if (newSet.has(path))
|
||||||
|
newSet.delete(path);
|
||||||
|
else
|
||||||
|
newSet.add(path);
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const toggleFolderSelect = (node: FileNode) => {
|
||||||
|
const folderFiles = getAllFilesFlat([node]);
|
||||||
|
const allSelected = folderFiles.every((f) => selectedFiles.has(f.path));
|
||||||
|
setSelectedFiles((prev) => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
if (allSelected)
|
||||||
|
folderFiles.forEach((f) => newSet.delete(f.path));
|
||||||
|
else
|
||||||
|
folderFiles.forEach((f) => newSet.add(f.path));
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const isFolderSelected = (node: FileNode): boolean | "indeterminate" => {
|
||||||
|
const folderFiles = getAllFilesFlat([node]);
|
||||||
|
if (folderFiles.length === 0)
|
||||||
|
return false;
|
||||||
|
const selectedCount = folderFiles.filter((f) => selectedFiles.has(f.path)).length;
|
||||||
|
if (selectedCount === 0)
|
||||||
|
return false;
|
||||||
|
if (selectedCount === folderFiles.length)
|
||||||
|
return true;
|
||||||
|
return "indeterminate";
|
||||||
|
};
|
||||||
|
const selectAll = () => setSelectedFiles(new Set(allAudioFiles.map((f) => f.path)));
|
||||||
|
const deselectAll = () => setSelectedFiles(new Set());
|
||||||
|
const resetToDefault = () => { setFormatPreset(DEFAULT_PRESET); setCustomFormat(DEFAULT_CUSTOM_FORMAT); setShowResetConfirm(false); };
|
||||||
|
const handlePreview = async (isPreviewOnly: boolean) => {
|
||||||
|
if (selectedFiles.size === 0) {
|
||||||
|
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);
|
||||||
|
setPreviewOnly(isPreviewOnly);
|
||||||
|
setShowPreview(true);
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
toast.error("Failed to generate preview", { description: err instanceof Error ? err.message : "Unknown error" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
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 {
|
||||||
|
const metadata = await ReadFileMetadata(filePath);
|
||||||
|
setMetadataInfo(metadata as FileMetadata);
|
||||||
|
setShowMetadata(true);
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
toast.error("Failed to read metadata", { description: err instanceof Error ? err.message : "Unknown error" });
|
||||||
|
setMetadataInfo(null);
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
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);
|
||||||
|
setLyricsTab("synced");
|
||||||
|
try {
|
||||||
|
const content = await ReadTextFile(filePath);
|
||||||
|
setLyricsContent(content);
|
||||||
|
setShowLyricsPreview(true);
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
toast.error("Failed to read lyrics file", { description: err instanceof Error ? err.message : "Unknown error" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleShowCover = async (filePath: string, e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setCoverFile(filePath);
|
||||||
|
try {
|
||||||
|
const data = await ReadImageAsBase64(filePath);
|
||||||
|
setCoverData(data);
|
||||||
|
setShowCoverPreview(true);
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
toast.error("Failed to load image", { description: err instanceof Error ? err.message : "Unknown error" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const getPlainLyrics = (content: string) => {
|
||||||
|
return content.split('\n').map(line => line.replace(/^\[[\d:.]+\]\s*/, '')).filter(line => !line.startsWith('[') || line.includes(']')).map(line => line.startsWith('[') ? '' : line).join('\n').trim();
|
||||||
|
};
|
||||||
|
const formatTimestamp = (timestamp: string): string => {
|
||||||
|
const match = timestamp.match(/\[(\d+):(\d+)(?:\.(\d+))?\]/);
|
||||||
|
if (!match)
|
||||||
|
return timestamp;
|
||||||
|
const minutes = parseInt(match[1], 10);
|
||||||
|
const seconds = match[2];
|
||||||
|
return `${minutes}:${seconds}`;
|
||||||
|
};
|
||||||
|
const renderSyncedLyrics = (content: string) => {
|
||||||
|
if (!content)
|
||||||
|
return <div className="text-sm text-muted-foreground">No lyrics content</div>;
|
||||||
|
const lines = content.split('\n');
|
||||||
|
return lines.map((line, index) => {
|
||||||
|
if (line.match(/^\[(ti|ar|al|by|length|offset):/i))
|
||||||
|
return null;
|
||||||
|
const match = line.match(/^(\[[\d:.]+\])(.*)$/);
|
||||||
|
if (match) {
|
||||||
|
const timestamp = match[1];
|
||||||
|
const text = match[2].trim();
|
||||||
|
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>);
|
||||||
|
}
|
||||||
|
if (!line.trim())
|
||||||
|
return null;
|
||||||
|
return (<div key={index} className="py-1">
|
||||||
|
<span className="text-sm">{line}</span>
|
||||||
|
</div>);
|
||||||
|
}).filter(item => item !== null);
|
||||||
|
};
|
||||||
|
const handleCopyLyrics = async () => {
|
||||||
|
try {
|
||||||
|
const textToCopy = lyricsTab === "synced" ? lyricsContent : getPlainLyrics(lyricsContent);
|
||||||
|
await navigator.clipboard.writeText(textToCopy);
|
||||||
|
setCopySuccess(true);
|
||||||
|
setTimeout(() => setCopySuccess(false), 500);
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
toast.error("Failed to copy lyrics");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleManualRename = (filePath: string, e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const fileName = filePath.split(/[/\\]/).pop() || "";
|
||||||
|
const nameWithoutExt = fileName.replace(/\.[^.]+$/, "");
|
||||||
|
setManualRenameFile(filePath);
|
||||||
|
setManualRenameName(nameWithoutExt);
|
||||||
|
setShowManualRename(true);
|
||||||
|
};
|
||||||
|
const handleConfirmManualRename = async () => {
|
||||||
|
if (!manualRenameFile || !manualRenameName.trim())
|
||||||
|
return;
|
||||||
|
setManualRenaming(true);
|
||||||
|
try {
|
||||||
|
await RenameFileTo(manualRenameFile, manualRenameName.trim());
|
||||||
|
toast.success("File renamed successfully");
|
||||||
|
setShowManualRename(false);
|
||||||
|
loadFiles();
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
toast.error("Failed to rename file", { description: err instanceof Error ? err.message : "Unknown error" });
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
setManualRenaming(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleRename = async () => {
|
||||||
|
if (selectedFiles.size === 0)
|
||||||
|
return;
|
||||||
|
setRenaming(true);
|
||||||
|
try {
|
||||||
|
const result = await RenameFilesByMetadata(Array.from(selectedFiles), renameFormat);
|
||||||
|
const successCount = result.filter((r: backend.RenameResult) => r.success).length;
|
||||||
|
const failCount = result.filter((r: backend.RenameResult) => !r.success).length;
|
||||||
|
if (successCount > 0)
|
||||||
|
toast.success("Rename Complete", { description: `${successCount} file(s) renamed${failCount > 0 ? `, ${failCount} failed` : ""}` });
|
||||||
|
else
|
||||||
|
toast.error("Rename Failed", { description: `All ${failCount} file(s) failed to rename` });
|
||||||
|
setShowPreview(false);
|
||||||
|
setSelectedFiles(new Set());
|
||||||
|
loadFiles();
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
toast.error("Rename Failed", { description: err instanceof Error ? err.message : "Unknown error" });
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
setRenaming(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
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) => {
|
||||||
|
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>));
|
||||||
|
};
|
||||||
|
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>));
|
||||||
|
};
|
||||||
|
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>));
|
||||||
|
};
|
||||||
|
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>
|
||||||
|
|
||||||
|
|
||||||
|
<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}"}</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={`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>
|
||||||
|
|
||||||
|
|
||||||
|
<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={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"}
|
||||||
|
</Button>
|
||||||
|
</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={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>
|
||||||
|
</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>);
|
||||||
|
}
|
||||||
@@ -1,81 +1,42 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Settings } from "@/components/Settings";
|
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
|
||||||
import { AudioAnalysisDialog } from "@/components/AudioAnalysisDialog";
|
import { openExternal } from "@/lib/utils";
|
||||||
import {
|
import { formatRelativeTime } from "@/lib/relative-time";
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from "@/components/ui/tooltip";
|
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
version: string;
|
version: string;
|
||||||
hasUpdate: boolean;
|
hasUpdate: boolean;
|
||||||
|
releaseDate?: string | null;
|
||||||
}
|
}
|
||||||
|
export function Header({ version, hasUpdate, releaseDate }: HeaderProps) {
|
||||||
export function Header({ version, hasUpdate }: HeaderProps) {
|
return (<div className="relative">
|
||||||
return (
|
|
||||||
<div className="relative">
|
|
||||||
<div className="text-center space-y-2">
|
<div className="text-center space-y-2">
|
||||||
<div className="flex items-center justify-center gap-3">
|
<div className="flex items-center justify-center gap-3">
|
||||||
<img
|
<img src="/icon.svg" alt="SpotiFLAC" className="w-12 h-12 cursor-pointer" onClick={() => window.location.reload()}/>
|
||||||
src="/icon.svg"
|
<h1 className="text-4xl font-bold cursor-pointer" onClick={() => window.location.reload()}>
|
||||||
alt="SpotiFLAC"
|
|
||||||
className="w-12 h-12 cursor-pointer"
|
|
||||||
onClick={() => window.location.reload()}
|
|
||||||
/>
|
|
||||||
<h1
|
|
||||||
className="text-4xl font-bold cursor-pointer"
|
|
||||||
onClick={() => window.location.reload()}
|
|
||||||
>
|
|
||||||
SpotiFLAC
|
SpotiFLAC
|
||||||
</h1>
|
</h1>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Badge variant="default" asChild>
|
<Tooltip>
|
||||||
<a
|
<TooltipTrigger asChild>
|
||||||
href="https://github.com/afkarxyz/SpotiFLAC/releases"
|
<Badge variant="default" asChild>
|
||||||
target="_blank"
|
<button type="button" onClick={() => openExternal("https://github.com/afkarxyz/SpotiFLAC/releases")} className="cursor-pointer hover:opacity-80 transition-opacity">
|
||||||
rel="noopener noreferrer"
|
v{version}
|
||||||
className="cursor-pointer hover:opacity-80 transition-opacity"
|
</button>
|
||||||
>
|
</Badge>
|
||||||
v{version}
|
</TooltipTrigger>
|
||||||
</a>
|
{hasUpdate && releaseDate && (<TooltipContent>
|
||||||
</Badge>
|
<p>{formatRelativeTime(releaseDate)}</p>
|
||||||
{hasUpdate && (
|
</TooltipContent>)}
|
||||||
<span className="absolute -top-1 -right-1 flex h-3 w-3">
|
</Tooltip>
|
||||||
|
{hasUpdate && (<span className="absolute -top-1 -right-1 flex h-3 w-3">
|
||||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
|
||||||
<span className="relative inline-flex rounded-full h-3 w-3 bg-green-500"></span>
|
<span className="relative inline-flex rounded-full h-3 w-3 bg-green-500"></span>
|
||||||
</span>
|
</span>)}
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Get Spotify tracks in true FLAC from Tidal, Deezer, Qobuz & Amazon Music — no account required.
|
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account required.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute right-0 top-0 flex gap-2">
|
</div>);
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button variant="outline" size="icon" asChild>
|
|
||||||
<a
|
|
||||||
href="https://github.com/afkarxyz/SpotiFLAC/issues"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
aria-label="GitHub Issues"
|
|
||||||
>
|
|
||||||
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="currentColor">
|
|
||||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="left">
|
|
||||||
<p>Report bug or request feature</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<AudioAnalysisDialog />
|
|
||||||
<Settings />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
export const TidalIcon = ({ className = "w-4 h-4" }: {
|
||||||
|
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" }: {
|
||||||
|
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>);
|
||||||
@@ -1,197 +1,150 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Download, FolderOpen } from "lucide-react";
|
import { Download, FolderOpen, ImageDown, FileText } from "lucide-react";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { SearchAndSort } from "./SearchAndSort";
|
import { SearchAndSort } from "./SearchAndSort";
|
||||||
import { TrackList } from "./TrackList";
|
import { TrackList } from "./TrackList";
|
||||||
import { DownloadProgress } from "./DownloadProgress";
|
import { DownloadProgress } from "./DownloadProgress";
|
||||||
import type { TrackMetadata } from "@/types/api";
|
import type { TrackMetadata, TrackAvailability } from "@/types/api";
|
||||||
|
|
||||||
interface PlaylistInfoProps {
|
interface PlaylistInfoProps {
|
||||||
playlistInfo: {
|
playlistInfo: {
|
||||||
owner: {
|
owner: {
|
||||||
name: string;
|
name: string;
|
||||||
display_name: string;
|
display_name: string;
|
||||||
images: string;
|
images: string;
|
||||||
|
};
|
||||||
|
tracks: {
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
followers: {
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
cover?: string;
|
||||||
|
description?: string;
|
||||||
};
|
};
|
||||||
tracks: {
|
trackList: TrackMetadata[];
|
||||||
total: number;
|
searchQuery: string;
|
||||||
};
|
sortBy: string;
|
||||||
followers: {
|
selectedTracks: string[];
|
||||||
total: number;
|
downloadedTracks: Set<string>;
|
||||||
};
|
failedTracks: Set<string>;
|
||||||
};
|
skippedTracks: Set<string>;
|
||||||
trackList: TrackMetadata[];
|
downloadingTrack: string | null;
|
||||||
searchQuery: string;
|
isDownloading: boolean;
|
||||||
sortBy: string;
|
bulkDownloadType: "all" | "selected" | null;
|
||||||
selectedTracks: string[];
|
downloadProgress: number;
|
||||||
downloadedTracks: Set<string>;
|
currentDownloadInfo: {
|
||||||
failedTracks: Set<string>;
|
name: string;
|
||||||
skippedTracks: Set<string>;
|
artists: string;
|
||||||
downloadingTrack: string | null;
|
} | null;
|
||||||
isDownloading: boolean;
|
currentPage: number;
|
||||||
bulkDownloadType: "all" | "selected" | null;
|
itemsPerPage: number;
|
||||||
downloadProgress: number;
|
downloadedLyrics?: Set<string>;
|
||||||
currentDownloadInfo: { name: string; artists: string } | null;
|
failedLyrics?: Set<string>;
|
||||||
currentPage: number;
|
skippedLyrics?: Set<string>;
|
||||||
itemsPerPage: number;
|
downloadingLyricsTrack?: string | null;
|
||||||
// Lyrics props
|
checkingAvailabilityTrack?: string | null;
|
||||||
downloadedLyrics?: Set<string>;
|
availabilityMap?: Map<string, TrackAvailability>;
|
||||||
failedLyrics?: Set<string>;
|
downloadedCovers?: Set<string>;
|
||||||
skippedLyrics?: Set<string>;
|
failedCovers?: Set<string>;
|
||||||
downloadingLyricsTrack?: string | null;
|
skippedCovers?: Set<string>;
|
||||||
onSearchChange: (value: string) => void;
|
downloadingCoverTrack?: string | null;
|
||||||
onSortChange: (value: string) => void;
|
isBulkDownloadingCovers?: boolean;
|
||||||
onToggleTrack: (isrc: string) => void;
|
isBulkDownloadingLyrics?: boolean;
|
||||||
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
|
onSearchChange: (value: string) => void;
|
||||||
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string) => void;
|
onSortChange: (value: string) => void;
|
||||||
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean) => void;
|
onToggleTrack: (isrc: string) => void;
|
||||||
onDownloadAll: () => void;
|
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
|
||||||
onDownloadSelected: () => 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;
|
||||||
onStopDownload: () => void;
|
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
|
||||||
onOpenFolder: () => 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;
|
onCheckAvailability?: (spotifyId: string) => void;
|
||||||
onAlbumClick: (album: { id: string; name: string; external_urls: string }) => void;
|
onDownloadAllLyrics?: () => void;
|
||||||
onArtistClick: (artist: { id: string; name: string; external_urls: string }) => void;
|
onDownloadAllCovers?: () => void;
|
||||||
onTrackClick: (track: TrackMetadata) => void;
|
onDownloadAll: () => void;
|
||||||
|
onDownloadSelected: () => void;
|
||||||
|
onStopDownload: () => void;
|
||||||
|
onOpenFolder: () => void;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
onAlbumClick: (album: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
external_urls: string;
|
||||||
|
}) => void;
|
||||||
|
onArtistClick: (artist: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
external_urls: string;
|
||||||
|
}) => void;
|
||||||
|
onTrackClick: (track: TrackMetadata) => 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({
|
return (<div className="space-y-6">
|
||||||
playlistInfo,
|
|
||||||
trackList,
|
|
||||||
searchQuery,
|
|
||||||
sortBy,
|
|
||||||
selectedTracks,
|
|
||||||
downloadedTracks,
|
|
||||||
failedTracks,
|
|
||||||
skippedTracks,
|
|
||||||
downloadingTrack,
|
|
||||||
isDownloading,
|
|
||||||
bulkDownloadType,
|
|
||||||
downloadProgress,
|
|
||||||
currentDownloadInfo,
|
|
||||||
currentPage,
|
|
||||||
itemsPerPage,
|
|
||||||
downloadedLyrics,
|
|
||||||
failedLyrics,
|
|
||||||
skippedLyrics,
|
|
||||||
downloadingLyricsTrack,
|
|
||||||
onSearchChange,
|
|
||||||
onSortChange,
|
|
||||||
onToggleTrack,
|
|
||||||
onToggleSelectAll,
|
|
||||||
onDownloadTrack,
|
|
||||||
onDownloadLyrics,
|
|
||||||
onDownloadAll,
|
|
||||||
onDownloadSelected,
|
|
||||||
onStopDownload,
|
|
||||||
onOpenFolder,
|
|
||||||
onPageChange,
|
|
||||||
onAlbumClick,
|
|
||||||
onArtistClick,
|
|
||||||
onTrackClick,
|
|
||||||
}: PlaylistInfoProps) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="px-6">
|
<CardContent className="px-6">
|
||||||
<div className="flex gap-6 items-start">
|
<div className="flex gap-6 items-start">
|
||||||
{playlistInfo.owner.images && (
|
{playlistInfo.cover && (<img src={playlistInfo.cover} alt={playlistInfo.owner.name} className="w-48 h-48 rounded-md shadow-lg object-cover"/>)}
|
||||||
<img
|
|
||||||
src={playlistInfo.owner.images}
|
|
||||||
alt={playlistInfo.owner.name}
|
|
||||||
className="w-48 h-48 rounded-md shadow-lg object-cover"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div className="flex-1 space-y-4">
|
<div className="flex-1 space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-sm font-medium">Playlist</p>
|
<p className="text-sm font-medium">Playlist</p>
|
||||||
<h2 className="text-4xl font-bold">{playlistInfo.owner.name}</h2>
|
<h2 className="text-4xl font-bold">{playlistInfo.owner.name}</h2>
|
||||||
|
{playlistInfo.description && (<p className="text-sm text-muted-foreground">{playlistInfo.description}</p>)}
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
<span className="font-medium">{playlistInfo.owner.display_name}</span>
|
<div className="flex items-center gap-2">
|
||||||
|
{playlistInfo.owner.images && (<img src={playlistInfo.owner.images} alt={playlistInfo.owner.display_name} className="w-5 h-5 rounded-full object-cover"/>)}
|
||||||
|
<span className="font-medium">{playlistInfo.owner.display_name}</span>
|
||||||
|
</div>
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
<span>{playlistInfo.tracks.total} songs</span>
|
<span>
|
||||||
|
{playlistInfo.tracks.total} {playlistInfo.tracks.total === 1 ? "song" : "songs"}
|
||||||
|
</span>
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
<span>{playlistInfo.followers.total.toLocaleString()} followers</span>
|
<span>{playlistInfo.followers.total.toLocaleString()} followers</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 flex-wrap">
|
<div className="flex gap-2 flex-wrap">
|
||||||
<Button onClick={onDownloadAll} disabled={isDownloading}>
|
<Button onClick={onDownloadAll} disabled={isDownloading}>
|
||||||
{isDownloading && bulkDownloadType === "all" ? (
|
{isDownloading && bulkDownloadType === "all" ? (<Spinner />) : (<Download className="h-4 w-4"/>)}
|
||||||
<Spinner />
|
|
||||||
) : (
|
|
||||||
<Download className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
Download All
|
Download All
|
||||||
</Button>
|
</Button>
|
||||||
{selectedTracks.length > 0 && (
|
{selectedTracks.length > 0 && (<Button onClick={onDownloadSelected} variant="secondary" disabled={isDownloading}>
|
||||||
<Button
|
{isDownloading && bulkDownloadType === "selected" ? (<Spinner />) : (<Download className="h-4 w-4"/>)}
|
||||||
onClick={onDownloadSelected}
|
|
||||||
variant="secondary"
|
|
||||||
disabled={isDownloading}
|
|
||||||
>
|
|
||||||
{isDownloading && bulkDownloadType === "selected" ? (
|
|
||||||
<Spinner />
|
|
||||||
) : (
|
|
||||||
<Download className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
Download Selected ({selectedTracks.length})
|
Download Selected ({selectedTracks.length})
|
||||||
</Button>
|
</Button>)}
|
||||||
)}
|
{onDownloadAllLyrics && (<Tooltip>
|
||||||
{downloadedTracks.size > 0 && (
|
<TooltipTrigger asChild>
|
||||||
<Button onClick={onOpenFolder} variant="outline">
|
<Button onClick={onDownloadAllLyrics} variant="outline" disabled={isBulkDownloadingLyrics}>
|
||||||
<FolderOpen className="h-4 w-4" />
|
{isBulkDownloadingLyrics ? <Spinner /> : <FileText className="h-4 w-4"/>}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Download All Lyrics</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>)}
|
||||||
|
{onDownloadAllCovers && (<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button onClick={onDownloadAllCovers} variant="outline" disabled={isBulkDownloadingCovers}>
|
||||||
|
{isBulkDownloadingCovers ? <Spinner /> : <ImageDown className="h-4 w-4"/>}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Download All Covers</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>)}
|
||||||
|
{downloadedTracks.size > 0 && (<Button onClick={onOpenFolder} variant="outline">
|
||||||
|
<FolderOpen className="h-4 w-4"/>
|
||||||
Open Folder
|
Open Folder
|
||||||
</Button>
|
</Button>)}
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{isDownloading && (
|
{isDownloading && (<DownloadProgress progress={downloadProgress} currentTrack={currentDownloadInfo} onStop={onStopDownload}/>)}
|
||||||
<DownloadProgress
|
|
||||||
progress={downloadProgress}
|
|
||||||
currentTrack={currentDownloadInfo}
|
|
||||||
onStop={onStopDownload}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<SearchAndSort
|
<SearchAndSort searchQuery={searchQuery} sortBy={sortBy} onSearchChange={onSearchChange} onSortChange={onSortChange}/>
|
||||||
searchQuery={searchQuery}
|
<TrackList tracks={trackList} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={downloadedTracks} failedTracks={failedTracks} skippedTracks={skippedTracks} downloadingTrack={downloadingTrack} isDownloading={isDownloading} currentPage={currentPage} itemsPerPage={itemsPerPage} showCheckboxes={true} hideAlbumColumn={false} folderName={playlistInfo.owner.name} downloadedLyrics={downloadedLyrics} failedLyrics={failedLyrics} skippedLyrics={skippedLyrics} downloadingLyricsTrack={downloadingLyricsTrack} checkingAvailabilityTrack={checkingAvailabilityTrack} availabilityMap={availabilityMap} downloadedCovers={downloadedCovers} failedCovers={failedCovers} skippedCovers={skippedCovers} downloadingCoverTrack={downloadingCoverTrack} onToggleTrack={onToggleTrack} onToggleSelectAll={onToggleSelectAll} onDownloadTrack={onDownloadTrack} onDownloadLyrics={onDownloadLyrics} onDownloadCover={onDownloadCover} onCheckAvailability={onCheckAvailability} onPageChange={onPageChange} onAlbumClick={onAlbumClick} onArtistClick={onArtistClick} onTrackClick={onTrackClick}/>
|
||||||
sortBy={sortBy}
|
|
||||||
onSearchChange={onSearchChange}
|
|
||||||
onSortChange={onSortChange}
|
|
||||||
/>
|
|
||||||
<TrackList
|
|
||||||
tracks={trackList}
|
|
||||||
searchQuery={searchQuery}
|
|
||||||
sortBy={sortBy}
|
|
||||||
selectedTracks={selectedTracks}
|
|
||||||
downloadedTracks={downloadedTracks}
|
|
||||||
failedTracks={failedTracks}
|
|
||||||
skippedTracks={skippedTracks}
|
|
||||||
downloadingTrack={downloadingTrack}
|
|
||||||
isDownloading={isDownloading}
|
|
||||||
currentPage={currentPage}
|
|
||||||
itemsPerPage={itemsPerPage}
|
|
||||||
showCheckboxes={true}
|
|
||||||
hideAlbumColumn={false}
|
|
||||||
folderName={playlistInfo.owner.name}
|
|
||||||
downloadedLyrics={downloadedLyrics}
|
|
||||||
failedLyrics={failedLyrics}
|
|
||||||
skippedLyrics={skippedLyrics}
|
|
||||||
downloadingLyricsTrack={downloadingLyricsTrack}
|
|
||||||
onToggleTrack={onToggleTrack}
|
|
||||||
onToggleSelectAll={onToggleSelectAll}
|
|
||||||
onDownloadTrack={onDownloadTrack}
|
|
||||||
onDownloadLyrics={onDownloadLyrics}
|
|
||||||
onPageChange={onPageChange}
|
|
||||||
onAlbumClick={onAlbumClick}
|
|
||||||
onArtistClick={onArtistClick}
|
|
||||||
onTrackClick={onTrackClick}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,50 +1,25 @@
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import {
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select";
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { Search, ArrowUpDown, XCircle } from "lucide-react";
|
import { Search, ArrowUpDown, XCircle } from "lucide-react";
|
||||||
|
|
||||||
interface SearchAndSortProps {
|
interface SearchAndSortProps {
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
sortBy: string;
|
sortBy: string;
|
||||||
onSearchChange: (value: string) => void;
|
onSearchChange: (value: string) => void;
|
||||||
onSortChange: (value: string) => void;
|
onSortChange: (value: string) => void;
|
||||||
}
|
}
|
||||||
|
export function SearchAndSort({ searchQuery, sortBy, onSearchChange, onSortChange, }: SearchAndSortProps) {
|
||||||
export function SearchAndSort({
|
return (<div className="flex gap-2">
|
||||||
searchQuery,
|
|
||||||
sortBy,
|
|
||||||
onSearchChange,
|
|
||||||
onSortChange,
|
|
||||||
}: SearchAndSortProps) {
|
|
||||||
return (
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<div className="relative flex-1">
|
<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" />
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground"/>
|
||||||
<Input
|
<Input placeholder="Search tracks..." value={searchQuery} onChange={(e) => onSearchChange(e.target.value)} className="pl-10 pr-8"/>
|
||||||
placeholder="Search tracks..."
|
{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={() => onSearchChange("")}>
|
||||||
value={searchQuery}
|
<XCircle className="h-4 w-4"/>
|
||||||
onChange={(e) => onSearchChange(e.target.value)}
|
</button>)}
|
||||||
className="pl-10 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={() => onSearchChange("")}
|
|
||||||
>
|
|
||||||
<XCircle className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<Select value={sortBy} onValueChange={onSortChange}>
|
<Select value={sortBy} onValueChange={onSortChange}>
|
||||||
<SelectTrigger className="w-[200px] gap-1.5">
|
<SelectTrigger className="w-[200px] gap-1.5">
|
||||||
<ArrowUpDown className="h-4 w-4" />
|
<ArrowUpDown className="h-4 w-4"/>
|
||||||
<SelectValue placeholder="Sort by" />
|
<SelectValue placeholder="Sort by"/>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="default">Default</SelectItem>
|
<SelectItem value="default">Default</SelectItem>
|
||||||
@@ -54,10 +29,11 @@ export function SearchAndSort({
|
|||||||
<SelectItem value="artist-desc">Artist (Z-A)</SelectItem>
|
<SelectItem value="artist-desc">Artist (Z-A)</SelectItem>
|
||||||
<SelectItem value="duration-asc">Duration (Short)</SelectItem>
|
<SelectItem value="duration-asc">Duration (Short)</SelectItem>
|
||||||
<SelectItem value="duration-desc">Duration (Long)</SelectItem>
|
<SelectItem value="duration-desc">Duration (Long)</SelectItem>
|
||||||
|
<SelectItem value="plays-asc">Plays (Low)</SelectItem>
|
||||||
|
<SelectItem value="plays-desc">Plays (High)</SelectItem>
|
||||||
<SelectItem value="downloaded">Downloaded</SelectItem>
|
<SelectItem value="downloaded">Downloaded</SelectItem>
|
||||||
<SelectItem value="not-downloaded">Not Downloaded</SelectItem>
|
<SelectItem value="not-downloaded">Not Downloaded</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,93 +1,373 @@
|
|||||||
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { InputWithContext } from "@/components/ui/input-with-context";
|
import { InputWithContext } from "@/components/ui/input-with-context";
|
||||||
import { Label } from "@/components/ui/label";
|
import { CloudDownload, Info, XCircle, Link, Search, X, ChevronDown } from "lucide-react";
|
||||||
import { Search, Info, XCircle } from "lucide-react";
|
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import {
|
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from "@/components/ui/tooltip";
|
|
||||||
import { FetchHistory } from "@/components/FetchHistory";
|
import { FetchHistory } from "@/components/FetchHistory";
|
||||||
import type { HistoryItem } from "@/components/FetchHistory";
|
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";
|
||||||
|
type ResultTab = "tracks" | "albums" | "artists" | "playlists";
|
||||||
|
const RECENT_SEARCHES_KEY = "spotiflac_recent_searches";
|
||||||
|
const MAX_RECENT_SEARCHES = 8;
|
||||||
|
const SEARCH_LIMIT = 50;
|
||||||
interface SearchBarProps {
|
interface SearchBarProps {
|
||||||
url: string;
|
url: string;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
onUrlChange: (url: string) => void;
|
onUrlChange: (url: string) => void;
|
||||||
onFetch: () => void;
|
onFetch: () => void;
|
||||||
history: HistoryItem[];
|
onFetchUrl: (url: string) => Promise<void>;
|
||||||
onHistorySelect: (item: HistoryItem) => void;
|
history: HistoryItem[];
|
||||||
onHistoryRemove: (id: string) => void;
|
onHistorySelect: (item: HistoryItem) => void;
|
||||||
hasResult: boolean;
|
onHistoryRemove: (id: string) => void;
|
||||||
|
hasResult: boolean;
|
||||||
|
searchMode: boolean;
|
||||||
|
onSearchModeChange: (isSearch: boolean) => void;
|
||||||
}
|
}
|
||||||
|
export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, history, onHistorySelect, onHistoryRemove, hasResult, searchMode, onSearchModeChange, }: SearchBarProps) {
|
||||||
export function SearchBar({
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
url,
|
const [searchResults, setSearchResults] = useState<backend.SearchResponse | null>(null);
|
||||||
loading,
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
onUrlChange,
|
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||||
onFetch,
|
const [lastSearchedQuery, setLastSearchedQuery] = useState("");
|
||||||
history,
|
const [activeTab, setActiveTab] = useState<ResultTab>("tracks");
|
||||||
onHistorySelect,
|
const [recentSearches, setRecentSearches] = useState<string[]>([]);
|
||||||
onHistoryRemove,
|
const [hasMore, setHasMore] = useState<Record<ResultTab, boolean>>({
|
||||||
hasResult,
|
tracks: false,
|
||||||
}: SearchBarProps) {
|
albums: false,
|
||||||
return (
|
artists: false,
|
||||||
<div className="space-y-3">
|
playlists: false,
|
||||||
|
});
|
||||||
|
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(RECENT_SEARCHES_KEY);
|
||||||
|
if (saved) {
|
||||||
|
setRecentSearches(JSON.parse(saved));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error("Failed to load recent searches:", error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
const saveRecentSearch = (query: string) => {
|
||||||
|
const trimmed = query.trim();
|
||||||
|
if (!trimmed)
|
||||||
|
return;
|
||||||
|
setRecentSearches((prev) => {
|
||||||
|
const filtered = prev.filter((s) => s.toLowerCase() !== trimmed.toLowerCase());
|
||||||
|
const updated = [trimmed, ...filtered].slice(0, MAX_RECENT_SEARCHES);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(updated));
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error("Failed to save recent searches:", error);
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const removeRecentSearch = (query: string) => {
|
||||||
|
setRecentSearches((prev) => {
|
||||||
|
const updated = prev.filter((s) => s !== query);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(updated));
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error("Failed to save recent searches:", error);
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
useEffect(() => {
|
||||||
|
if (!searchMode || !searchQuery.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (searchQuery.trim() === lastSearchedQuery) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (searchTimeoutRef.current) {
|
||||||
|
clearTimeout(searchTimeoutRef.current);
|
||||||
|
}
|
||||||
|
searchTimeoutRef.current = setTimeout(async () => {
|
||||||
|
setIsSearching(true);
|
||||||
|
try {
|
||||||
|
const results = await SearchSpotify({ query: searchQuery, limit: SEARCH_LIMIT });
|
||||||
|
setSearchResults(results);
|
||||||
|
setLastSearchedQuery(searchQuery.trim());
|
||||||
|
saveRecentSearch(searchQuery.trim());
|
||||||
|
setHasMore({
|
||||||
|
tracks: results.tracks.length === SEARCH_LIMIT,
|
||||||
|
albums: results.albums.length === SEARCH_LIMIT,
|
||||||
|
artists: results.artists.length === SEARCH_LIMIT,
|
||||||
|
playlists: results.playlists.length === SEARCH_LIMIT,
|
||||||
|
});
|
||||||
|
if (results.tracks.length > 0)
|
||||||
|
setActiveTab("tracks");
|
||||||
|
else if (results.albums.length > 0)
|
||||||
|
setActiveTab("albums");
|
||||||
|
else if (results.artists.length > 0)
|
||||||
|
setActiveTab("artists");
|
||||||
|
else if (results.playlists.length > 0)
|
||||||
|
setActiveTab("playlists");
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error("Search failed:", error);
|
||||||
|
setSearchResults(null);
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
setIsSearching(false);
|
||||||
|
}
|
||||||
|
}, 400);
|
||||||
|
return () => {
|
||||||
|
if (searchTimeoutRef.current) {
|
||||||
|
clearTimeout(searchTimeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [searchQuery, searchMode, lastSearchedQuery]);
|
||||||
|
const handleLoadMore = async () => {
|
||||||
|
if (!searchResults || !lastSearchedQuery || isLoadingMore)
|
||||||
|
return;
|
||||||
|
const typeMap: Record<ResultTab, string> = {
|
||||||
|
tracks: "track",
|
||||||
|
albums: "album",
|
||||||
|
artists: "artist",
|
||||||
|
playlists: "playlist",
|
||||||
|
};
|
||||||
|
const currentCount = getTabCount(activeTab);
|
||||||
|
setIsLoadingMore(true);
|
||||||
|
try {
|
||||||
|
const moreResults = await SearchSpotifyByType({
|
||||||
|
query: lastSearchedQuery,
|
||||||
|
search_type: typeMap[activeTab],
|
||||||
|
limit: SEARCH_LIMIT,
|
||||||
|
offset: currentCount,
|
||||||
|
});
|
||||||
|
if (moreResults.length > 0) {
|
||||||
|
setSearchResults((prev) => {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setHasMore((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[activeTab]: moreResults.length === SEARCH_LIMIT,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error("Load more failed:", error);
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
setIsLoadingMore(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleResultClick = (externalUrl: string) => {
|
||||||
|
onSearchModeChange(false);
|
||||||
|
onFetchUrl(externalUrl);
|
||||||
|
};
|
||||||
|
const formatDuration = (ms: number) => {
|
||||||
|
const minutes = Math.floor(ms / 60000);
|
||||||
|
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 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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const tabs: {
|
||||||
|
key: ResultTab;
|
||||||
|
label: string;
|
||||||
|
}[] = [
|
||||||
|
{ key: "tracks", label: "Tracks" },
|
||||||
|
{ key: "albums", label: "Albums" },
|
||||||
|
{ key: "artists", label: "Artists" },
|
||||||
|
{ key: "playlists", label: "Playlists" },
|
||||||
|
];
|
||||||
|
return (<div className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Label htmlFor="spotify-url">Spotify URL</Label>
|
|
||||||
|
<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>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Info className="h-4 w-4 text-muted-foreground cursor-help" />
|
<Info className="h-4 w-4 text-muted-foreground cursor-help"/>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="right">
|
<TooltipContent side="right">
|
||||||
<p>Supports track, album, playlist, and artist URLs</p>
|
{!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>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<InputWithContext
|
{!searchMode ? (<>
|
||||||
id="spotify-url"
|
<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"/>
|
||||||
placeholder="https://open.spotify.com/..."
|
{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("")}>
|
||||||
value={url}
|
<XCircle className="h-4 w-4"/>
|
||||||
onChange={(e) => onUrlChange(e.target.value)}
|
</button>)}
|
||||||
onKeyDown={(e) => e.key === "Enter" && onFetch()}
|
</>) : (<>
|
||||||
className="pr-8"
|
<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={() => {
|
||||||
{url && (
|
setSearchQuery("");
|
||||||
<button
|
setSearchResults(null);
|
||||||
type="button"
|
setLastSearchedQuery("");
|
||||||
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>)}
|
||||||
<XCircle className="h-4 w-4" />
|
</>)}
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={onFetch} disabled={loading}>
|
|
||||||
{loading ? (
|
{!searchMode && (<Button onClick={onFetch} disabled={loading}>
|
||||||
<>
|
{loading ? (<>
|
||||||
<Spinner />
|
<Spinner />
|
||||||
Fetching...
|
Fetching...
|
||||||
</>
|
</>) : (<>
|
||||||
) : (
|
<CloudDownload className="h-4 w-4"/>
|
||||||
<>
|
Fetch
|
||||||
<Search className="h-4 w-4" />
|
</>)}
|
||||||
Fetch
|
</Button>)}
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!hasResult && (
|
|
||||||
<FetchHistory
|
{!searchMode && !hasResult && (<FetchHistory history={history} onSelect={onHistorySelect} onRemove={onHistoryRemove}/>)}
|
||||||
history={history}
|
|
||||||
onSelect={onHistorySelect}
|
|
||||||
onRemove={onHistoryRemove}
|
{searchMode && (<div className="space-y-4">
|
||||||
/>
|
|
||||||
)}
|
{!searchQuery && !searchResults && recentSearches.length > 0 && (<div className="space-y-2">
|
||||||
</div>
|
<p className="text-sm text-muted-foreground">Recent Searches</p>
|
||||||
);
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{recentSearches.map((query) => (<div key={query} className="group relative flex items-center px-3 py-1.5 bg-muted hover:bg-accent rounded-full text-sm cursor-pointer transition-colors" onClick={() => setSearchQuery(query)}>
|
||||||
|
<span>{query}</span>
|
||||||
|
<button type="button" className="absolute -top-1.5 -right-1.5 z-10 w-5 h-5 rounded-full bg-red-500 hover:bg-red-600 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-all cursor-pointer shadow-sm" onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
removeRecentSearch(query);
|
||||||
|
}}>
|
||||||
|
<X className="h-3 w-3 text-red-900" strokeWidth={3}/>
|
||||||
|
</button>
|
||||||
|
</div>))}
|
||||||
|
</div>
|
||||||
|
</div>)}
|
||||||
|
|
||||||
|
{isSearching && (<div className="flex items-center justify-center py-8">
|
||||||
|
<Spinner />
|
||||||
|
<span className="ml-2 text-muted-foreground">Searching...</span>
|
||||||
|
</div>)}
|
||||||
|
|
||||||
|
{!isSearching && searchQuery && !hasAnyResults && (<div className="text-center py-8 text-muted-foreground">
|
||||||
|
No results found for "{searchQuery}"
|
||||||
|
</div>)}
|
||||||
|
|
||||||
|
{!isSearching && hasAnyResults && (<>
|
||||||
|
|
||||||
|
<div className="flex gap-1 border-b">
|
||||||
|
{tabs.map((tab) => {
|
||||||
|
const count = getTabCount(tab.key);
|
||||||
|
if (count === 0)
|
||||||
|
return null;
|
||||||
|
return (<button key={tab.key} type="button" onClick={() => setActiveTab(tab.key)} className={cn("px-4 py-2 text-sm font-medium transition-colors cursor-pointer border-b-2 -mb-px", activeTab === tab.key
|
||||||
|
? "border-primary text-foreground"
|
||||||
|
: "border-transparent text-muted-foreground hover:text-foreground")}>
|
||||||
|
{tab.label} ({count})
|
||||||
|
</button>);
|
||||||
|
})}
|
||||||
|
</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>
|
||||||
|
|
||||||
|
|
||||||
|
{hasMore[activeTab] && (<div className="flex justify-center pt-2">
|
||||||
|
<Button variant="outline" onClick={handleLoadMore} disabled={isLoadingMore}>
|
||||||
|
{isLoadingMore ? (<>
|
||||||
|
<Spinner />
|
||||||
|
Loading...
|
||||||
|
</>) : (<>
|
||||||
|
<ChevronDown className="h-4 w-4"/>
|
||||||
|
Load More
|
||||||
|
</>)}
|
||||||
|
</Button>
|
||||||
|
</div>)}
|
||||||
|
</>)}
|
||||||
|
</div>)}
|
||||||
|
</div>);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,423 +0,0 @@
|
|||||||
import { useState, useEffect } from "react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { InputWithContext } from "@/components/ui/input-with-context";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogFooter,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
|
||||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
|
||||||
import { Settings as SettingsIcon, FolderOpen, Save, RotateCcw, Info, X } from "lucide-react";
|
|
||||||
import { getSettings, getSettingsWithDefaults, saveSettings, resetToDefaultSettings, applyThemeMode, type Settings as SettingsType } from "@/lib/settings";
|
|
||||||
import { themes, applyTheme } from "@/lib/themes";
|
|
||||||
import { SelectFolder } from "../../wailsjs/go/main/App";
|
|
||||||
|
|
||||||
// Service Icons
|
|
||||||
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 DeezerIcon = () => (
|
|
||||||
<svg viewBox="0 0 24 24" className="inline-block w-[1.1em] h-[1.1em] mr-2 fill-muted-foreground">
|
|
||||||
<path d="M18.77 5.55c.19-1.07.46-1.75.76-1.75.56 0 1.02 2.34 1.02 5.23 0 2.89-.46 5.23-1.02 5.23-.23 0-.44-.4-.61-1.06-.27 2.43-.83 4.11-1.48 4.11-.5 0-.96-1-1.26-2.6-.2 3.03-.73 5.17-1.33 5.17-.39 0-.73-.85-.99-2.23-.31 2.85-1.03 4.85-1.86 4.85-.83 0-1.55-2-1.86-4.85-.25 1.38-.6 2.23-.99 2.23-.6 0-1.12-2.14-1.33-5.16-.3 1.58-.75 2.6-1.26 2.6-.65 0-1.2-1.68-1.48-4.12-.17.66-.38 1.06-.61 1.06-.56 0-1.02-2.34-1.02-5.23 0-2.89.46-5.23 1.02-5.23.3 0 .57.68.76 1.75C5.53 3.7 6 2.5 6.56 2.5c.66 0 1.22 1.7 1.49 4.17.26-1.8.66-2.94 1.1-2.94.63 0 1.16 2.25 1.36 5.4.36-1.62.9-2.63 1.5-2.63.58 0 1.12 1.01 1.49 2.62.2-3.14.72-5.4 1.35-5.4.44 0 .84 1.15 1.1 2.95.27-2.47.84-4.17 1.49-4.17.55 0 1.03 1.2 1.33 3.05ZM2 8.52c0-1.3.26-2.34.58-2.34.32 0 .57 1.05.57 2.34 0 1.29-.25 2.34-.57 2.34-.32 0-.58-1.05-.58-2.34Zm18.85 0c0-1.3.25-2.34.57-2.34.32 0 .58 1.05.58 2.34 0 1.29-.26 2.34-.58 2.34-.32 0-.57-1.05-.57-2.34Z"></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>
|
|
||||||
);
|
|
||||||
|
|
||||||
export function Settings() {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [savedSettings, setSavedSettings] = useState<SettingsType>(getSettings());
|
|
||||||
const [tempSettings, setTempSettings] = useState<SettingsType>(savedSettings);
|
|
||||||
const [, setIsLoadingDefaults] = useState(false);
|
|
||||||
const [isDark, setIsDark] = useState(document.documentElement.classList.contains('dark'));
|
|
||||||
|
|
||||||
// Apply saved settings
|
|
||||||
useEffect(() => {
|
|
||||||
applyThemeMode(savedSettings.themeMode);
|
|
||||||
applyTheme(savedSettings.theme);
|
|
||||||
|
|
||||||
// Setup listener for system theme changes
|
|
||||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
|
||||||
const handleChange = () => {
|
|
||||||
if (savedSettings.themeMode === "auto") {
|
|
||||||
applyThemeMode("auto");
|
|
||||||
applyTheme(savedSettings.theme);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
mediaQuery.addEventListener("change", handleChange);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
mediaQuery.removeEventListener("change", handleChange);
|
|
||||||
};
|
|
||||||
}, [savedSettings.themeMode, savedSettings.theme]);
|
|
||||||
|
|
||||||
// Apply temp settings for preview when dialog is open
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) {
|
|
||||||
applyThemeMode(tempSettings.themeMode);
|
|
||||||
applyTheme(tempSettings.theme);
|
|
||||||
|
|
||||||
// Update isDark state after theme is applied
|
|
||||||
setTimeout(() => {
|
|
||||||
setIsDark(document.documentElement.classList.contains('dark'));
|
|
||||||
}, 0);
|
|
||||||
|
|
||||||
// Setup listener for system theme changes during preview
|
|
||||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
|
||||||
const handleChange = () => {
|
|
||||||
if (tempSettings.themeMode === "auto") {
|
|
||||||
applyThemeMode("auto");
|
|
||||||
applyTheme(tempSettings.theme);
|
|
||||||
setTimeout(() => {
|
|
||||||
setIsDark(document.documentElement.classList.contains('dark'));
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
mediaQuery.addEventListener("change", handleChange);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
mediaQuery.removeEventListener("change", handleChange);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, [open, tempSettings.themeMode, tempSettings.theme]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Load settings with defaults from backend on mount
|
|
||||||
const loadDefaults = async () => {
|
|
||||||
if (!savedSettings.downloadPath) {
|
|
||||||
setIsLoadingDefaults(true);
|
|
||||||
const settingsWithDefaults = await getSettingsWithDefaults();
|
|
||||||
setSavedSettings(settingsWithDefaults);
|
|
||||||
setTempSettings(settingsWithDefaults);
|
|
||||||
setIsLoadingDefaults(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
loadDefaults();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Reset temp settings when dialog opens
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) {
|
|
||||||
setTempSettings(savedSettings);
|
|
||||||
}
|
|
||||||
}, [open, savedSettings]);
|
|
||||||
|
|
||||||
const handleSave = () => {
|
|
||||||
saveSettings(tempSettings);
|
|
||||||
setSavedSettings(tempSettings);
|
|
||||||
setOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleReset = async () => {
|
|
||||||
const defaultSettings = await resetToDefaultSettings();
|
|
||||||
setTempSettings(defaultSettings);
|
|
||||||
setSavedSettings(defaultSettings);
|
|
||||||
|
|
||||||
// Apply default theme mode and theme
|
|
||||||
applyThemeMode(defaultSettings.themeMode);
|
|
||||||
applyTheme(defaultSettings.theme);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCancel = () => {
|
|
||||||
// Revert to saved settings
|
|
||||||
applyThemeMode(savedSettings.themeMode);
|
|
||||||
applyTheme(savedSettings.theme);
|
|
||||||
|
|
||||||
setTempSettings(savedSettings);
|
|
||||||
setOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOpenChange = (newOpen: boolean) => {
|
|
||||||
if (!newOpen) {
|
|
||||||
// Dialog is closing, revert to saved settings
|
|
||||||
applyThemeMode(savedSettings.themeMode);
|
|
||||||
applyTheme(savedSettings.theme);
|
|
||||||
setTempSettings(savedSettings);
|
|
||||||
}
|
|
||||||
setOpen(newOpen);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDownloadPathChange = (value: string) => {
|
|
||||||
setTempSettings((prev) => ({ ...prev, downloadPath: value }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDownloaderChange = (value: "auto" | "deezer" | "tidal" | "qobuz" | "amazon") => {
|
|
||||||
setTempSettings((prev) => ({ ...prev, downloader: value }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleThemeChange = (value: string) => {
|
|
||||||
setTempSettings((prev) => ({ ...prev, theme: value }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleThemeModeChange = (value: "auto" | "light" | "dark") => {
|
|
||||||
setTempSettings((prev) => ({ ...prev, themeMode: value }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBrowseFolder = async () => {
|
|
||||||
try {
|
|
||||||
// Call backend to open folder selection dialog
|
|
||||||
const selectedPath = await SelectFolder(tempSettings.downloadPath || "");
|
|
||||||
console.log("Selected path:", selectedPath);
|
|
||||||
|
|
||||||
if (selectedPath && selectedPath.trim() !== "") {
|
|
||||||
setTempSettings((prev) => ({ ...prev, downloadPath: selectedPath }));
|
|
||||||
} else {
|
|
||||||
console.log("No folder selected or user cancelled");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error selecting folder:", error);
|
|
||||||
alert(`Error selecting folder: ${error}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button variant="outline" size="icon">
|
|
||||||
<SettingsIcon className="h-5 w-5" />
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent className="sm:max-w-[600px] flex flex-col p-6 [&>button]:hidden" aria-describedby={undefined}>
|
|
||||||
<div className="absolute right-4 top-4">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-6 w-6 opacity-70 hover:opacity-100"
|
|
||||||
onClick={handleCancel}
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<DialogTitle className="text-sm font-medium">Settings</DialogTitle>
|
|
||||||
<div className="grid grid-cols-[1.2fr_0.8fr] gap-6 py-2">
|
|
||||||
{/* Left Column */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* Download Path */}
|
|
||||||
<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) => handleDownloadPathChange(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>
|
|
||||||
|
|
||||||
{/* Source Selection */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="downloader">Source</Label>
|
|
||||||
<Select
|
|
||||||
value={tempSettings.downloader}
|
|
||||||
onValueChange={handleDownloaderChange}
|
|
||||||
>
|
|
||||||
<SelectTrigger id="downloader">
|
|
||||||
<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="deezer">
|
|
||||||
<span className="flex items-center">
|
|
||||||
<DeezerIcon />
|
|
||||||
Deezer
|
|
||||||
</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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Theme Mode Selection */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="theme-mode">Theme</Label>
|
|
||||||
<Select value={tempSettings.themeMode} onValueChange={handleThemeModeChange}>
|
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* Theme Color Selection */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="theme">Theme Color</Label>
|
|
||||||
<Select value={tempSettings.theme} onValueChange={handleThemeChange}>
|
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* Right Column */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* Filename Format */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-sm">Filename Format</Label>
|
|
||||||
<RadioGroup
|
|
||||||
value={tempSettings.filenameFormat}
|
|
||||||
onValueChange={(value) => setTempSettings(prev => ({ ...prev, filenameFormat: value as any }))}
|
|
||||||
>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<RadioGroupItem value="title-artist" id="title-artist" />
|
|
||||||
<Label htmlFor="title-artist" className="cursor-pointer font-normal text-sm">Title - Artist</Label>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<RadioGroupItem value="artist-title" id="artist-title" />
|
|
||||||
<Label htmlFor="artist-title" className="cursor-pointer font-normal text-sm">Artist - Title</Label>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<RadioGroupItem value="title" id="title" />
|
|
||||||
<Label htmlFor="title" className="cursor-pointer font-normal text-sm">Title</Label>
|
|
||||||
</div>
|
|
||||||
</RadioGroup>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-t" />
|
|
||||||
|
|
||||||
{/* Folder Settings */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<h3 className="font-medium text-sm">Folder Settings</h3>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Checkbox
|
|
||||||
id="track-number"
|
|
||||||
checked={tempSettings.trackNumber}
|
|
||||||
onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, trackNumber: checked as boolean }))}
|
|
||||||
/>
|
|
||||||
<Label htmlFor="track-number" className="cursor-pointer text-sm">Track Number</Label>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="top" className="max-w-xs">
|
|
||||||
<p className="text-xs text-center">Adds track numbers to filenames. Uses album track numbers when Album Subfolder is enabled, otherwise uses playlist position</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Checkbox
|
|
||||||
id="artist-subfolder"
|
|
||||||
checked={tempSettings.artistSubfolder}
|
|
||||||
onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, artistSubfolder: checked as boolean }))}
|
|
||||||
/>
|
|
||||||
<Label htmlFor="artist-subfolder" className="cursor-pointer text-sm">Artist Subfolder</Label>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="top" className="max-w-xs">
|
|
||||||
<p className="text-xs text-center">Playlist only</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Checkbox
|
|
||||||
id="album-subfolder"
|
|
||||||
checked={tempSettings.albumSubfolder}
|
|
||||||
onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, albumSubfolder: checked as boolean }))}
|
|
||||||
/>
|
|
||||||
<Label htmlFor="album-subfolder" className="cursor-pointer text-sm">Album Subfolder</Label>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="top" className="max-w-xs">
|
|
||||||
<p className="text-xs text-center">Playlist & Discography</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DialogFooter className="gap-2 sm:justify-between">
|
|
||||||
<Button variant="outline" onClick={handleReset} className="gap-1.5">
|
|
||||||
<RotateCcw className="h-4 w-4" />
|
|
||||||
Reset to Default
|
|
||||||
</Button>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button variant="outline" onClick={handleCancel}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleSave} className="gap-1.5">
|
|
||||||
<Save className="h-4 w-4" />
|
|
||||||
Save Changes
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,361 @@
|
|||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { flushSync } from "react-dom";
|
||||||
|
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 { 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 { themes, applyTheme } from "@/lib/themes";
|
||||||
|
import { SelectFolder } 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>);
|
||||||
|
interface SettingsPageProps {
|
||||||
|
onUnsavedChangesChange?: (hasUnsavedChanges: boolean) => void;
|
||||||
|
onResetRequest?: (resetFn: () => void) => void;
|
||||||
|
}
|
||||||
|
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 [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'));
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
useEffect(() => {
|
||||||
|
if (onResetRequest) {
|
||||||
|
onResetRequest(resetToSaved);
|
||||||
|
}
|
||||||
|
}, [onResetRequest, resetToSaved]);
|
||||||
|
useEffect(() => {
|
||||||
|
onUnsavedChangesChange?.(hasUnsavedChanges);
|
||||||
|
}, [hasUnsavedChanges, onUnsavedChangesChange]);
|
||||||
|
useEffect(() => {
|
||||||
|
applyThemeMode(savedSettings.themeMode);
|
||||||
|
applyTheme(savedSettings.theme);
|
||||||
|
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||||
|
const handleChange = () => {
|
||||||
|
if (savedSettings.themeMode === "auto") {
|
||||||
|
applyThemeMode("auto");
|
||||||
|
applyTheme(savedSettings.theme);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
mediaQuery.addEventListener("change", handleChange);
|
||||||
|
return () => mediaQuery.removeEventListener("change", handleChange);
|
||||||
|
}, [savedSettings.themeMode, savedSettings.theme]);
|
||||||
|
useEffect(() => {
|
||||||
|
applyThemeMode(tempSettings.themeMode);
|
||||||
|
applyTheme(tempSettings.theme);
|
||||||
|
applyFont(tempSettings.fontFamily);
|
||||||
|
setTimeout(() => {
|
||||||
|
setIsDark(document.documentElement.classList.contains('dark'));
|
||||||
|
}, 0);
|
||||||
|
}, [tempSettings.themeMode, tempSettings.theme, tempSettings.fontFamily]);
|
||||||
|
useEffect(() => {
|
||||||
|
const loadDefaults = async () => {
|
||||||
|
if (!savedSettings.downloadPath) {
|
||||||
|
const settingsWithDefaults = await getSettingsWithDefaults();
|
||||||
|
setSavedSettings(settingsWithDefaults);
|
||||||
|
setTempSettings(settingsWithDefaults);
|
||||||
|
saveSettings(settingsWithDefaults);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadDefaults();
|
||||||
|
}, []);
|
||||||
|
const handleSave = () => {
|
||||||
|
saveSettings(tempSettings);
|
||||||
|
setSavedSettings(tempSettings);
|
||||||
|
toast.success("Settings saved");
|
||||||
|
onUnsavedChangesChange?.(false);
|
||||||
|
};
|
||||||
|
const handleReset = async () => {
|
||||||
|
const defaultSettings = await resetToDefaultSettings();
|
||||||
|
setTempSettings(defaultSettings);
|
||||||
|
setSavedSettings(defaultSettings);
|
||||||
|
applyThemeMode(defaultSettings.themeMode);
|
||||||
|
applyTheme(defaultSettings.theme);
|
||||||
|
applyFont(defaultSettings.fontFamily);
|
||||||
|
setShowResetConfirm(false);
|
||||||
|
toast.success("Settings reset to default");
|
||||||
|
};
|
||||||
|
const handleBrowseFolder = async () => {
|
||||||
|
try {
|
||||||
|
const selectedPath = await SelectFolder(tempSettings.downloadPath || "");
|
||||||
|
if (selectedPath && selectedPath.trim() !== "") {
|
||||||
|
setTempSettings((prev) => ({ ...prev, downloadPath: selectedPath }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error("Error selecting folder:", error);
|
||||||
|
toast.error(`Error selecting folder: ${error}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (<div className="space-y-6">
|
||||||
|
<h1 className="text-2xl font-bold">Settings</h1>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</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">
|
||||||
|
<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="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>
|
||||||
|
</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.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setShowResetConfirm(false)}>Cancel</Button>
|
||||||
|
<Button onClick={handleReset}>Reset</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>);
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { HomeIcon } from "@/components/ui/home";
|
||||||
|
import { SettingsIcon } from "@/components/ui/settings";
|
||||||
|
import { ActivityIcon } 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 { CoffeeIcon } from "@/components/ui/coffee";
|
||||||
|
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";
|
||||||
|
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?labels=bug&body=%23%23%23%20Problem%0AExplain%20the%20issue%20briefly.%0A%0A%23%23%23%20Type%0ATrack%20/%20Album%20/%20Playlist%20/%20Artist%0A%0A%23%23%23%20Spotify%20URL%0APaste%20the%20link%20here.%0A%0A%23%23%23%20OS%0AWindows%20/%20Linux%20/%20macOS")}>
|
||||||
|
<GithubIcon size={20}/>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right">
|
||||||
|
<p>Report Bug</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>);
|
||||||
|
}
|
||||||
@@ -1,289 +1,193 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import type { SpectrumData } from "@/types/api";
|
import type { SpectrumData } from "@/types/api";
|
||||||
|
|
||||||
interface SpectrumVisualizationProps {
|
interface SpectrumVisualizationProps {
|
||||||
sampleRate: number;
|
sampleRate: number;
|
||||||
bitsPerSample: number;
|
bitsPerSample: number;
|
||||||
duration: number;
|
duration: number;
|
||||||
spectrumData?: SpectrumData;
|
spectrumData?: SpectrumData;
|
||||||
}
|
}
|
||||||
|
export function SpectrumVisualization({ sampleRate, bitsPerSample, duration, spectrumData, }: SpectrumVisualizationProps) {
|
||||||
export function SpectrumVisualization({
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
sampleRate,
|
useEffect(() => {
|
||||||
bitsPerSample,
|
const canvas = canvasRef.current;
|
||||||
duration,
|
if (!canvas)
|
||||||
spectrumData,
|
return;
|
||||||
}: SpectrumVisualizationProps) {
|
const ctx = canvas.getContext("2d");
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
if (!ctx)
|
||||||
|
return;
|
||||||
useEffect(() => {
|
const width = canvas.width;
|
||||||
const canvas = canvasRef.current;
|
const height = canvas.height;
|
||||||
if (!canvas) return;
|
const marginLeft = 70;
|
||||||
|
const marginRight = 70;
|
||||||
const ctx = canvas.getContext("2d");
|
const marginTop = 30;
|
||||||
if (!ctx) return;
|
const marginBottom = 65;
|
||||||
|
const plotWidth = width - marginLeft - marginRight;
|
||||||
const width = canvas.width;
|
const plotHeight = height - marginTop - marginBottom;
|
||||||
const height = canvas.height;
|
ctx.fillStyle = "#000000";
|
||||||
|
ctx.fillRect(0, 0, width, height);
|
||||||
// Calculate margins for labels
|
const nyquistFreq = sampleRate / 2;
|
||||||
const marginLeft = 70; // More space for Frequency label
|
if (spectrumData) {
|
||||||
const marginRight = 70; // Space for color bar
|
drawRealSpectrum(ctx, marginLeft, marginTop, plotWidth, plotHeight, spectrumData);
|
||||||
const marginTop = 30; // More space at top
|
}
|
||||||
const marginBottom = 65; // More space at bottom for Time label
|
drawAxesAndLabels(ctx, marginLeft, marginTop, plotWidth, plotHeight, nyquistFreq, duration, sampleRate);
|
||||||
|
drawColorBar(ctx, marginLeft + plotWidth + 15, marginTop, 20, plotHeight);
|
||||||
const plotWidth = width - marginLeft - marginRight;
|
}, [sampleRate, bitsPerSample, duration, spectrumData]);
|
||||||
const plotHeight = height - marginTop - marginBottom;
|
const drawRealSpectrum = (ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, spectrum: SpectrumData) => {
|
||||||
|
const timeSlices = spectrum.time_slices;
|
||||||
// Black background
|
if (timeSlices.length === 0)
|
||||||
ctx.fillStyle = "#000000";
|
return;
|
||||||
ctx.fillRect(0, 0, width, height);
|
const freqBins = timeSlices[0].magnitudes.length;
|
||||||
|
const nyquistFreq = spectrum.max_freq;
|
||||||
// Calculate Nyquist frequency
|
let minDB = 0;
|
||||||
const nyquistFreq = sampleRate / 2;
|
let maxDB = -200;
|
||||||
|
timeSlices.forEach((slice) => {
|
||||||
if (spectrumData) {
|
slice.magnitudes.forEach((db) => {
|
||||||
drawRealSpectrum(
|
if (db > maxDB)
|
||||||
ctx,
|
maxDB = db;
|
||||||
marginLeft,
|
if (db < minDB && db > -200)
|
||||||
marginTop,
|
minDB = db;
|
||||||
plotWidth,
|
});
|
||||||
plotHeight,
|
});
|
||||||
spectrumData
|
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++) {
|
||||||
// Draw axes, labels, and color bar
|
const slice = timeSlices[t];
|
||||||
drawAxesAndLabels(ctx, marginLeft, marginTop, plotWidth, plotHeight, nyquistFreq, duration, sampleRate);
|
const xPos = x + (t / timeSlices.length) * width;
|
||||||
drawColorBar(ctx, marginLeft + plotWidth + 15, marginTop, 20, plotHeight);
|
for (let f = 0; f < freqBins && f < slice.magnitudes.length; f++) {
|
||||||
}, [sampleRate, bitsPerSample, duration, spectrumData]);
|
const db = slice.magnitudes[f];
|
||||||
|
const freq = (f / freqBins) * nyquistFreq;
|
||||||
const drawRealSpectrum = (
|
const freqRatio = freq / nyquistFreq;
|
||||||
ctx: CanvasRenderingContext2D,
|
const yPos = y + height - (freqRatio * height);
|
||||||
x: number,
|
const nextFreq = ((f + 1) / freqBins) * nyquistFreq;
|
||||||
y: number,
|
const nextFreqRatio = nextFreq / nyquistFreq;
|
||||||
width: number,
|
const nextYPos = y + height - (nextFreqRatio * height);
|
||||||
height: number,
|
const binHeight = Math.max(1, Math.abs(yPos - nextYPos) + 1);
|
||||||
spectrum: SpectrumData
|
const intensity = Math.max(0, Math.min(1, (db - minDB) / dbRange));
|
||||||
) => {
|
const color = getSpekColor(intensity);
|
||||||
const timeSlices = spectrum.time_slices;
|
ctx.fillStyle = color;
|
||||||
if (timeSlices.length === 0) return;
|
ctx.fillRect(xPos, nextYPos, sliceWidth, binHeight);
|
||||||
|
}
|
||||||
const freqBins = timeSlices[0].magnitudes.length;
|
}
|
||||||
const nyquistFreq = spectrum.max_freq;
|
};
|
||||||
|
const getSpekColor = (intensity: number): string => {
|
||||||
// Find min/max dB values
|
if (intensity < 0.08) {
|
||||||
let minDB = 0;
|
const t = intensity / 0.08;
|
||||||
let maxDB = -200;
|
return `rgb(0, 0, ${Math.floor(t * 80)})`;
|
||||||
|
}
|
||||||
timeSlices.forEach((slice) => {
|
else if (intensity < 0.18) {
|
||||||
slice.magnitudes.forEach((db) => {
|
const t = (intensity - 0.08) / 0.10;
|
||||||
if (db > maxDB) maxDB = db;
|
return `rgb(${Math.floor(t * 50)}, ${Math.floor(t * 30)}, ${Math.floor(80 + t * 175)})`;
|
||||||
if (db < minDB && db > -200) minDB = db;
|
}
|
||||||
});
|
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)})`;
|
||||||
// Clamp range for better visualization
|
}
|
||||||
minDB = Math.max(minDB, maxDB - 90); // 90dB dynamic range
|
else if (intensity < 0.40) {
|
||||||
const dbRange = maxDB - minDB;
|
const t = (intensity - 0.28) / 0.12;
|
||||||
|
return `rgb(${Math.floor(200 + t * 55)}, 0, ${Math.floor(200 - t * 200)})`;
|
||||||
const sliceWidth = Math.ceil(width / timeSlices.length);
|
}
|
||||||
|
else if (intensity < 0.52) {
|
||||||
for (let t = 0; t < timeSlices.length; t++) {
|
const t = (intensity - 0.40) / 0.12;
|
||||||
const slice = timeSlices[t];
|
return `rgb(255, ${Math.floor(t * 100)}, 0)`;
|
||||||
const xPos = x + (t / timeSlices.length) * width;
|
}
|
||||||
|
else if (intensity < 0.65) {
|
||||||
for (let f = 0; f < freqBins && f < slice.magnitudes.length; f++) {
|
const t = (intensity - 0.52) / 0.13;
|
||||||
const db = slice.magnitudes[f];
|
return `rgb(255, ${Math.floor(100 + t * 80)}, 0)`;
|
||||||
|
}
|
||||||
// Linear frequency scale
|
else if (intensity < 0.78) {
|
||||||
const freq = (f / freqBins) * nyquistFreq;
|
const t = (intensity - 0.65) / 0.13;
|
||||||
const freqRatio = freq / nyquistFreq;
|
return `rgb(255, ${Math.floor(180 + t * 55)}, ${Math.floor(t * 30)})`;
|
||||||
|
}
|
||||||
const yPos = y + height - (freqRatio * height);
|
else if (intensity < 0.90) {
|
||||||
|
const t = (intensity - 0.78) / 0.12;
|
||||||
// Calculate bin height
|
return `rgb(255, ${Math.floor(235 + t * 20)}, ${Math.floor(30 + t * 100)})`;
|
||||||
const nextFreq = ((f + 1) / freqBins) * nyquistFreq;
|
}
|
||||||
const nextFreqRatio = nextFreq / nyquistFreq;
|
else {
|
||||||
const nextYPos = y + height - (nextFreqRatio * height);
|
const t = (intensity - 0.90) / 0.10;
|
||||||
|
return `rgb(255, 255, ${Math.floor(130 + t * 125)})`;
|
||||||
const binHeight = Math.max(1, Math.abs(yPos - nextYPos) + 1);
|
}
|
||||||
|
};
|
||||||
// Normalize intensity
|
const drawAxesAndLabels = (ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, nyquistFreq: number, duration: number, sampleRate: number) => {
|
||||||
const intensity = Math.max(0, Math.min(1, (db - minDB) / dbRange));
|
ctx.fillStyle = "#CCCCCC";
|
||||||
|
ctx.font = "12px Arial";
|
||||||
const color = getSpekColor(intensity);
|
ctx.textAlign = "right";
|
||||||
ctx.fillStyle = color;
|
ctx.textBaseline = "middle";
|
||||||
ctx.fillRect(xPos, nextYPos, sliceWidth, binHeight);
|
const freqLabels = generateFreqLabels(nyquistFreq);
|
||||||
}
|
freqLabels.forEach(freq => {
|
||||||
}
|
if (freq <= nyquistFreq) {
|
||||||
};
|
const freqRatio = freq / nyquistFreq;
|
||||||
|
const yPos = y + height - (freqRatio * height);
|
||||||
// Vibrant color scheme like Spek - NGEJERENG!
|
const label = freq >= 1000 ? `${freq / 1000}k` : `${freq}`;
|
||||||
const getSpekColor = (intensity: number): string => {
|
ctx.fillText(label, x - 8, yPos);
|
||||||
if (intensity < 0.08) {
|
}
|
||||||
// Black to deep blue
|
});
|
||||||
const t = intensity / 0.08;
|
ctx.fillText("0", x - 8, y + height);
|
||||||
return `rgb(0, 0, ${Math.floor(t * 80)})`;
|
ctx.textAlign = "center";
|
||||||
} else if (intensity < 0.18) {
|
ctx.textBaseline = "top";
|
||||||
// Deep blue to bright blue
|
const timeStep = getTimeStep(duration);
|
||||||
const t = (intensity - 0.08) / 0.10;
|
for (let t = 0; t <= duration; t += timeStep) {
|
||||||
return `rgb(${Math.floor(t * 50)}, ${Math.floor(t * 30)}, ${Math.floor(80 + t * 175)})`;
|
const xPos = x + (t / duration) * width;
|
||||||
} else if (intensity < 0.28) {
|
ctx.fillText(`${Math.round(t)}s`, xPos, y + height + 8);
|
||||||
// Blue to magenta/purple
|
}
|
||||||
const t = (intensity - 0.18) / 0.10;
|
ctx.fillStyle = "#FFFFFF";
|
||||||
return `rgb(${Math.floor(50 + t * 150)}, ${Math.floor(30 - t * 30)}, ${Math.floor(255 - t * 55)})`;
|
ctx.font = "13px Arial";
|
||||||
} else if (intensity < 0.40) {
|
ctx.save();
|
||||||
// Magenta to bright red
|
ctx.translate(12, y + height / 2);
|
||||||
const t = (intensity - 0.28) / 0.12;
|
ctx.rotate(-Math.PI / 2);
|
||||||
return `rgb(${Math.floor(200 + t * 55)}, 0, ${Math.floor(200 - t * 200)})`;
|
ctx.textAlign = "center";
|
||||||
} else if (intensity < 0.52) {
|
ctx.fillText("Frequency (Hz)", 0, 0);
|
||||||
// Red to orange-red
|
ctx.restore();
|
||||||
const t = (intensity - 0.40) / 0.12;
|
ctx.textAlign = "center";
|
||||||
return `rgb(255, ${Math.floor(t * 100)}, 0)`;
|
ctx.fillText("Time (seconds)", x + width / 2, y + height + 35);
|
||||||
} else if (intensity < 0.65) {
|
ctx.textAlign = "right";
|
||||||
// Orange-red to bright orange
|
ctx.fillStyle = "#CCCCCC";
|
||||||
const t = (intensity - 0.52) / 0.13;
|
ctx.font = "12px Arial";
|
||||||
return `rgb(255, ${Math.floor(100 + t * 80)}, 0)`;
|
ctx.fillText(`Sample Rate: ${sampleRate} Hz`, x + width - 5, y - 3);
|
||||||
} else if (intensity < 0.78) {
|
};
|
||||||
// Orange to yellow-orange
|
const generateFreqLabels = (nyquistFreq: number): number[] => {
|
||||||
const t = (intensity - 0.65) / 0.13;
|
if (nyquistFreq <= 24000) {
|
||||||
return `rgb(255, ${Math.floor(180 + t * 55)}, ${Math.floor(t * 30)})`;
|
return [2000, 4000, 6000, 8000, 10000, 12000, 14000, 16000, 18000, 20000, 22000];
|
||||||
} else if (intensity < 0.90) {
|
}
|
||||||
// Yellow-orange to bright yellow
|
else if (nyquistFreq <= 48000) {
|
||||||
const t = (intensity - 0.78) / 0.12;
|
return [5000, 10000, 15000, 20000, 25000, 30000, 35000, 40000, 45000];
|
||||||
return `rgb(255, ${Math.floor(235 + t * 20)}, ${Math.floor(30 + t * 100)})`;
|
}
|
||||||
} else {
|
else if (nyquistFreq <= 96000) {
|
||||||
// Yellow to white (hottest)
|
return [10000, 20000, 30000, 40000, 50000, 60000, 70000, 80000, 90000];
|
||||||
const t = (intensity - 0.90) / 0.10;
|
}
|
||||||
return `rgb(255, 255, ${Math.floor(130 + t * 125)})`;
|
else {
|
||||||
}
|
return [20000, 40000, 60000, 80000, 100000, 120000, 140000, 160000, 180000];
|
||||||
};
|
}
|
||||||
|
};
|
||||||
const drawAxesAndLabels = (
|
const getTimeStep = (duration: number): number => {
|
||||||
ctx: CanvasRenderingContext2D,
|
if (duration <= 60)
|
||||||
x: number,
|
return 15;
|
||||||
y: number,
|
if (duration <= 120)
|
||||||
width: number,
|
return 30;
|
||||||
height: number,
|
if (duration <= 300)
|
||||||
nyquistFreq: number,
|
return 30;
|
||||||
duration: number,
|
if (duration <= 600)
|
||||||
sampleRate: number
|
return 60;
|
||||||
) => {
|
return 60;
|
||||||
// Frequency labels on Y-axis
|
};
|
||||||
ctx.fillStyle = "#CCCCCC";
|
const drawColorBar = (ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number) => {
|
||||||
ctx.font = "12px Arial";
|
for (let i = 0; i < height; i++) {
|
||||||
ctx.textAlign = "right";
|
const intensity = 1 - (i / height);
|
||||||
ctx.textBaseline = "middle";
|
const color = getSpekColor(intensity);
|
||||||
|
ctx.fillStyle = color;
|
||||||
// Generate frequency labels based on Nyquist
|
ctx.fillRect(x, y + i, width, 1);
|
||||||
const freqLabels = generateFreqLabels(nyquistFreq);
|
}
|
||||||
|
ctx.strokeStyle = "#666666";
|
||||||
freqLabels.forEach(freq => {
|
ctx.lineWidth = 1;
|
||||||
if (freq <= nyquistFreq) {
|
ctx.strokeRect(x, y, width, height);
|
||||||
const freqRatio = freq / nyquistFreq;
|
ctx.fillStyle = "#FFFFFF";
|
||||||
const yPos = y + height - (freqRatio * height);
|
ctx.font = "11px Arial";
|
||||||
const label = freq >= 1000 ? `${freq / 1000}k` : `${freq}`;
|
ctx.textAlign = "left";
|
||||||
ctx.fillText(label, x - 8, yPos);
|
ctx.textBaseline = "middle";
|
||||||
}
|
ctx.fillText("High", x + width + 5, y + 10);
|
||||||
});
|
ctx.fillText("Low", x + width + 5, y + height - 10);
|
||||||
|
};
|
||||||
// "0" at bottom
|
return (<div className="border border-white/10 rounded-lg overflow-hidden bg-black shadow-xl">
|
||||||
ctx.fillText("0", x - 8, y + height);
|
<canvas ref={canvasRef} width={1200} height={600} className="w-full h-auto" style={{ imageRendering: "auto" }}/>
|
||||||
|
</div>);
|
||||||
// Time labels on X-axis
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Axis titles
|
|
||||||
ctx.fillStyle = "#FFFFFF";
|
|
||||||
ctx.font = "13px Arial";
|
|
||||||
|
|
||||||
// Y-axis title: "Frequency (Hz)"
|
|
||||||
ctx.save();
|
|
||||||
ctx.translate(12, y + height / 2);
|
|
||||||
ctx.rotate(-Math.PI / 2);
|
|
||||||
ctx.textAlign = "center";
|
|
||||||
ctx.fillText("Frequency (Hz)", 0, 0);
|
|
||||||
ctx.restore();
|
|
||||||
|
|
||||||
// X-axis title: "Time (seconds)"
|
|
||||||
ctx.textAlign = "center";
|
|
||||||
ctx.fillText("Time (seconds)", x + width / 2, y + height + 35);
|
|
||||||
|
|
||||||
// Sample rate info in top right
|
|
||||||
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 => {
|
|
||||||
// Always use 30s intervals like the reference image
|
|
||||||
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
|
|
||||||
) => {
|
|
||||||
// Draw gradient color bar
|
|
||||||
for (let i = 0; i < height; i++) {
|
|
||||||
const intensity = 1 - (i / height); // Top is high, bottom is low
|
|
||||||
const color = getSpekColor(intensity);
|
|
||||||
ctx.fillStyle = color;
|
|
||||||
ctx.fillRect(x, y + i, width, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Border around color bar
|
|
||||||
ctx.strokeStyle = "#666666";
|
|
||||||
ctx.lineWidth = 1;
|
|
||||||
ctx.strokeRect(x, y, width, height);
|
|
||||||
|
|
||||||
// Labels
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,70 +1,30 @@
|
|||||||
import { useState } from "react";
|
import { X, Minus, Maximize } from "lucide-react";
|
||||||
import { X, Minus, Square } from "lucide-react";
|
|
||||||
import { WindowMinimise, WindowToggleMaximise, Quit } from "../../wailsjs/runtime/runtime";
|
import { WindowMinimise, WindowToggleMaximise, Quit } from "../../wailsjs/runtime/runtime";
|
||||||
|
|
||||||
export function TitleBar() {
|
export function TitleBar() {
|
||||||
const [hoveredButton, setHoveredButton] = useState<string | null>(null);
|
const handleMinimize = () => {
|
||||||
|
WindowMinimise();
|
||||||
const handleMinimize = () => {
|
};
|
||||||
WindowMinimise();
|
const handleMaximize = () => {
|
||||||
};
|
WindowToggleMaximise();
|
||||||
|
};
|
||||||
const handleMaximize = () => {
|
const handleClose = () => {
|
||||||
WindowToggleMaximise();
|
Quit();
|
||||||
};
|
};
|
||||||
|
return (<>
|
||||||
const handleClose = () => {
|
|
||||||
Quit();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* Draggable area */}
|
|
||||||
<div
|
|
||||||
className="fixed top-0 left-0 right-0 h-12 z-40"
|
|
||||||
style={{ "--wails-draggable": "drag" } as React.CSSProperties}
|
|
||||||
onDoubleClick={handleMaximize}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Window control buttons */}
|
<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-4 left-4 z-50 flex gap-2">
|
|
||||||
<button
|
|
||||||
onClick={handleClose}
|
<div className="fixed top-1.5 right-2 z-50 flex h-7 gap-0.5">
|
||||||
onMouseEnter={() => setHoveredButton("close")}
|
<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">
|
||||||
onMouseLeave={() => setHoveredButton(null)}
|
<Minus className="w-3.5 h-3.5"/>
|
||||||
className="w-3 h-3 rounded-full bg-red-500 hover:bg-red-600 transition-colors flex items-center justify-center"
|
|
||||||
style={{ "--wails-draggable": "no-drag" } as React.CSSProperties}
|
|
||||||
aria-label="Close"
|
|
||||||
>
|
|
||||||
{hoveredButton === "close" && (
|
|
||||||
<X className="w-2 h-2 text-red-900" strokeWidth={3} />
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button onClick={handleMaximize} 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="Maximize">
|
||||||
onClick={handleMinimize}
|
<Maximize className="w-3.5 h-3.5"/>
|
||||||
onMouseEnter={() => setHoveredButton("minimize")}
|
|
||||||
onMouseLeave={() => setHoveredButton(null)}
|
|
||||||
className="w-3 h-3 rounded-full bg-yellow-500 hover:bg-yellow-600 transition-colors flex items-center justify-center"
|
|
||||||
style={{ "--wails-draggable": "no-drag" } as React.CSSProperties}
|
|
||||||
aria-label="Minimize"
|
|
||||||
>
|
|
||||||
{hoveredButton === "minimize" && (
|
|
||||||
<Minus className="w-2 h-2 text-yellow-900" strokeWidth={3} />
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button onClick={handleClose} className="w-8 h-7 flex items-center justify-center hover:bg-destructive hover:text-white transition-colors rounded" style={{ "--wails-draggable": "no-drag" } as React.CSSProperties} aria-label="Close">
|
||||||
onClick={handleMaximize}
|
<X className="w-3.5 h-3.5"/>
|
||||||
onMouseEnter={() => setHoveredButton("maximize")}
|
|
||||||
onMouseLeave={() => setHoveredButton(null)}
|
|
||||||
className="w-3 h-3 rounded-full bg-green-500 hover:bg-green-600 transition-colors flex items-center justify-center"
|
|
||||||
style={{ "--wails-draggable": "no-drag" } as React.CSSProperties}
|
|
||||||
aria-label="Maximize"
|
|
||||||
>
|
|
||||||
{hoveredButton === "maximize" && (
|
|
||||||
<Square className="w-1.5 h-1.5 text-green-900" strokeWidth={3} />
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,123 +1,139 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Download, FolderOpen, CheckCircle, XCircle, FileText, SkipForward } from "lucide-react";
|
import { Download, FolderOpen, CheckCircle, XCircle, FileText, FileCheck, Globe, ImageDown } from "lucide-react";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import type { TrackMetadata } from "@/types/api";
|
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
|
||||||
|
import type { TrackMetadata, TrackAvailability } from "@/types/api";
|
||||||
|
import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons";
|
||||||
interface TrackInfoProps {
|
interface TrackInfoProps {
|
||||||
track: TrackMetadata & { album_name: string; release_date: string };
|
track: TrackMetadata & {
|
||||||
isDownloading: boolean;
|
album_name: string;
|
||||||
downloadingTrack: string | null;
|
release_date: string;
|
||||||
isDownloaded: boolean;
|
};
|
||||||
isFailed: boolean;
|
isDownloading: boolean;
|
||||||
downloadingLyricsTrack?: string | null;
|
downloadingTrack: string | null;
|
||||||
downloadedLyrics?: boolean;
|
isDownloaded: boolean;
|
||||||
failedLyrics?: boolean;
|
isFailed: boolean;
|
||||||
skippedLyrics?: boolean;
|
isSkipped: boolean;
|
||||||
onDownload: (isrc: string, name: string, artists: string, albumName?: string, spotifyId?: string) => void;
|
downloadingLyricsTrack?: string | null;
|
||||||
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName?: string) => void;
|
downloadedLyrics?: boolean;
|
||||||
onOpenFolder: () => void;
|
failedLyrics?: boolean;
|
||||||
|
skippedLyrics?: boolean;
|
||||||
|
checkingAvailability?: boolean;
|
||||||
|
availability?: TrackAvailability;
|
||||||
|
downloadingCover?: boolean;
|
||||||
|
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;
|
||||||
|
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
|
||||||
|
onCheckAvailability?: (spotifyId: string, isrc?: 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;
|
||||||
}
|
}
|
||||||
|
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({
|
const formatDuration = (ms: number) => {
|
||||||
track,
|
const minutes = Math.floor(ms / 60000);
|
||||||
isDownloading,
|
const seconds = Math.floor((ms % 60000) / 1000);
|
||||||
downloadingTrack,
|
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
||||||
isDownloaded,
|
};
|
||||||
isFailed,
|
const formatPlays = (plays: string) => {
|
||||||
downloadingLyricsTrack,
|
const num = parseInt(plays, 10);
|
||||||
downloadedLyrics,
|
if (isNaN(num))
|
||||||
failedLyrics,
|
return plays;
|
||||||
skippedLyrics,
|
return num.toLocaleString();
|
||||||
onDownload,
|
};
|
||||||
onDownloadLyrics,
|
return (<Card>
|
||||||
onOpenFolder,
|
|
||||||
}: TrackInfoProps) {
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="px-6">
|
<CardContent className="px-6">
|
||||||
<div className="flex gap-6 items-start">
|
<div className="flex gap-6 items-start">
|
||||||
{track.images && (
|
<div className="shrink-0">
|
||||||
<img
|
{track.images && (<div className="relative w-48 h-48 rounded-md shadow-lg overflow-hidden">
|
||||||
src={track.images}
|
<img src={track.images} alt={track.name} className="w-full h-full object-cover"/>
|
||||||
alt={track.name}
|
<div className="absolute bottom-1 right-1 bg-black/80 text-white px-1.5 py-0.5 text-xs font-medium rounded">
|
||||||
className="w-48 h-48 rounded-md shadow-lg object-cover shrink-0"
|
{formatDuration(track.duration_ms)}
|
||||||
/>
|
</div>
|
||||||
)}
|
</div>)}
|
||||||
|
</div>
|
||||||
<div className="flex-1 space-y-4 min-w-0">
|
<div className="flex-1 space-y-4 min-w-0">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<h1 className="text-3xl font-bold wrap-break-word">{track.name}</h1>
|
<h1 className="text-3xl font-bold wrap-break-word">{track.name}</h1>
|
||||||
{isDownloaded && (
|
{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}
|
||||||
<CheckCircle className="h-6 w-6 text-green-500 shrink-0" />
|
|
||||||
)}
|
|
||||||
{isFailed && (
|
|
||||||
<XCircle className="h-6 w-6 text-red-500 shrink-0" />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-lg text-muted-foreground">{track.artists}</p>
|
<p className="text-lg text-muted-foreground">{track.artists}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||||
<div>
|
<div className="space-y-1">
|
||||||
<p className="text-xs text-muted-foreground">Album</p>
|
<div>
|
||||||
<p className="font-medium truncate">{track.album_name}</p>
|
<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>
|
||||||
<div>
|
<div className="space-y-1">
|
||||||
<p className="text-xs text-muted-foreground">Release Date</p>
|
<div>
|
||||||
<p className="font-medium">{track.release_date}</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
{track.isrc && (
|
{track.isrc && (<div className="flex gap-2 flex-wrap">
|
||||||
<div className="flex gap-2">
|
<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}>
|
||||||
<Button
|
{downloadingTrack === track.isrc ? (<Spinner />) : (<>
|
||||||
onClick={() => onDownload(track.isrc, track.name, track.artists, track.album_name, track.spotify_id)}
|
<Download className="h-4 w-4"/>
|
||||||
disabled={isDownloading || downloadingTrack === track.isrc}
|
|
||||||
>
|
|
||||||
{downloadingTrack === track.isrc ? (
|
|
||||||
<Spinner />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Download className="h-4 w-4" />
|
|
||||||
Download
|
Download
|
||||||
</>
|
</>)}
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
{track.spotify_id && onDownloadLyrics && (
|
{track.spotify_id && onDownloadLyrics && (<Tooltip>
|
||||||
<Button
|
<TooltipTrigger asChild>
|
||||||
onClick={() => onDownloadLyrics(track.spotify_id!, track.name, track.artists, track.album_name)}
|
<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}>
|
||||||
variant="secondary"
|
{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"/>)}
|
||||||
disabled={downloadingLyricsTrack === track.spotify_id}
|
</Button>
|
||||||
>
|
</TooltipTrigger>
|
||||||
{downloadingLyricsTrack === track.spotify_id ? (
|
<TooltipContent>
|
||||||
<Spinner />
|
<p>Download Lyric</p>
|
||||||
) : (
|
</TooltipContent>
|
||||||
<>
|
</Tooltip>)}
|
||||||
<FileText className="h-4 w-4" />
|
{track.images && onDownloadCover && (<Tooltip>
|
||||||
Download Lyric
|
<TooltipTrigger asChild>
|
||||||
{skippedLyrics && (
|
<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}>
|
||||||
<SkipForward className="h-4 w-4 text-yellow-500 ml-1" />
|
{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>
|
||||||
{downloadedLyrics && !skippedLyrics && (
|
</TooltipTrigger>
|
||||||
<CheckCircle className="h-4 w-4 text-green-500 ml-1" />
|
<TooltipContent>
|
||||||
)}
|
<p>Download Cover</p>
|
||||||
{failedLyrics && (
|
</TooltipContent>
|
||||||
<XCircle className="h-4 w-4 text-red-500 ml-1" />
|
</Tooltip>)}
|
||||||
)}
|
{track.spotify_id && onCheckAvailability && (<Tooltip>
|
||||||
</>
|
<TooltipTrigger asChild>
|
||||||
)}
|
<Button onClick={() => onCheckAvailability(track.spotify_id!, track.isrc)} variant="outline" disabled={checkingAvailability}>
|
||||||
</Button>
|
{checkingAvailability ? (<Spinner />) : availability ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : (<Globe className="h-4 w-4"/>)}
|
||||||
)}
|
</Button>
|
||||||
{isDownloaded && (
|
</TooltipTrigger>
|
||||||
<Button onClick={onOpenFolder} variant="outline">
|
<TooltipContent>
|
||||||
<FolderOpen className="h-4 w-4" />
|
{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
|
Open Folder
|
||||||
</Button>
|
</Button>)}
|
||||||
)}
|
</div>)}
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,388 +1,326 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Download, CheckCircle, XCircle, SkipForward, FileText } from "lucide-react";
|
import { Download, CheckCircle, XCircle, FileCheck, FileText, Globe, ImageDown } from "lucide-react";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import {
|
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
|
||||||
Tooltip,
|
import { Pagination, PaginationContent, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, } from "@/components/ui/pagination";
|
||||||
TooltipContent,
|
import type { TrackMetadata, TrackAvailability } from "@/types/api";
|
||||||
TooltipTrigger,
|
import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons";
|
||||||
} from "@/components/ui/tooltip";
|
|
||||||
import {
|
|
||||||
Pagination,
|
|
||||||
PaginationContent,
|
|
||||||
PaginationItem,
|
|
||||||
PaginationLink,
|
|
||||||
PaginationNext,
|
|
||||||
PaginationPrevious,
|
|
||||||
} from "@/components/ui/pagination";
|
|
||||||
import type { TrackMetadata } from "@/types/api";
|
|
||||||
|
|
||||||
interface TrackListProps {
|
interface TrackListProps {
|
||||||
tracks: TrackMetadata[];
|
tracks: TrackMetadata[];
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
sortBy: string;
|
sortBy: string;
|
||||||
selectedTracks: string[];
|
selectedTracks: string[];
|
||||||
downloadedTracks: Set<string>;
|
downloadedTracks: Set<string>;
|
||||||
failedTracks: Set<string>;
|
failedTracks: Set<string>;
|
||||||
skippedTracks: Set<string>;
|
skippedTracks: Set<string>;
|
||||||
downloadingTrack: string | null;
|
downloadingTrack: string | null;
|
||||||
isDownloading: boolean;
|
isDownloading: boolean;
|
||||||
currentPage: number;
|
currentPage: number;
|
||||||
itemsPerPage: number;
|
itemsPerPage: number;
|
||||||
showCheckboxes?: boolean;
|
showCheckboxes?: boolean;
|
||||||
hideAlbumColumn?: boolean;
|
hideAlbumColumn?: boolean;
|
||||||
folderName?: string;
|
folderName?: string;
|
||||||
isArtistDiscography?: boolean;
|
isArtistDiscography?: boolean;
|
||||||
// Lyrics props
|
downloadedLyrics?: Set<string>;
|
||||||
downloadedLyrics?: Set<string>;
|
failedLyrics?: Set<string>;
|
||||||
failedLyrics?: Set<string>;
|
skippedLyrics?: Set<string>;
|
||||||
skippedLyrics?: Set<string>;
|
downloadingLyricsTrack?: string | null;
|
||||||
downloadingLyricsTrack?: string | null;
|
checkingAvailabilityTrack?: string | null;
|
||||||
onToggleTrack: (isrc: string) => void;
|
availabilityMap?: Map<string, TrackAvailability>;
|
||||||
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
|
downloadedCovers?: Set<string>;
|
||||||
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, isArtistDiscography?: boolean) => void;
|
failedCovers?: Set<string>;
|
||||||
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean) => void;
|
skippedCovers?: Set<string>;
|
||||||
onPageChange: (page: number) => void;
|
downloadingCoverTrack?: string | null;
|
||||||
onAlbumClick?: (album: { id: string; name: string; external_urls: string }) => void;
|
onToggleTrack: (isrc: string) => void;
|
||||||
onArtistClick?: (artist: { id: string; name: string; external_urls: string }) => void;
|
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
|
||||||
onTrackClick?: (track: 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;
|
||||||
|
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;
|
||||||
|
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: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
external_urls: string;
|
||||||
|
}) => void;
|
||||||
|
onArtistClick?: (artist: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
external_urls: string;
|
||||||
|
}) => void;
|
||||||
|
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) {
|
||||||
export function TrackList({
|
let filteredTracks = tracks.filter((track) => {
|
||||||
tracks,
|
if (!searchQuery)
|
||||||
searchQuery,
|
return true;
|
||||||
sortBy,
|
const query = searchQuery.toLowerCase();
|
||||||
selectedTracks,
|
return (track.name.toLowerCase().includes(query) ||
|
||||||
downloadedTracks,
|
track.artists.toLowerCase().includes(query) ||
|
||||||
failedTracks,
|
track.album_name.toLowerCase().includes(query));
|
||||||
skippedTracks,
|
|
||||||
downloadingTrack,
|
|
||||||
isDownloading,
|
|
||||||
currentPage,
|
|
||||||
itemsPerPage,
|
|
||||||
showCheckboxes = false,
|
|
||||||
hideAlbumColumn = false,
|
|
||||||
folderName,
|
|
||||||
isArtistDiscography = false,
|
|
||||||
downloadedLyrics,
|
|
||||||
failedLyrics,
|
|
||||||
skippedLyrics,
|
|
||||||
downloadingLyricsTrack,
|
|
||||||
onToggleTrack,
|
|
||||||
onToggleSelectAll,
|
|
||||||
onDownloadTrack,
|
|
||||||
onDownloadLyrics,
|
|
||||||
onPageChange,
|
|
||||||
onAlbumClick,
|
|
||||||
onArtistClick,
|
|
||||||
onTrackClick,
|
|
||||||
}: TrackListProps) {
|
|
||||||
let filteredTracks = tracks.filter((track) => {
|
|
||||||
if (!searchQuery) return true;
|
|
||||||
const query = searchQuery.toLowerCase();
|
|
||||||
return (
|
|
||||||
track.name.toLowerCase().includes(query) ||
|
|
||||||
track.artists.toLowerCase().includes(query) ||
|
|
||||||
track.album_name.toLowerCase().includes(query)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Apply sorting
|
|
||||||
if (sortBy === "title-asc") {
|
|
||||||
filteredTracks = [...filteredTracks].sort((a, b) => a.name.localeCompare(b.name));
|
|
||||||
} else if (sortBy === "title-desc") {
|
|
||||||
filteredTracks = [...filteredTracks].sort((a, b) => b.name.localeCompare(a.name));
|
|
||||||
} else if (sortBy === "artist-asc") {
|
|
||||||
filteredTracks = [...filteredTracks].sort((a, b) => a.artists.localeCompare(b.artists));
|
|
||||||
} else if (sortBy === "artist-desc") {
|
|
||||||
filteredTracks = [...filteredTracks].sort((a, b) => b.artists.localeCompare(a.artists));
|
|
||||||
} else if (sortBy === "duration-asc") {
|
|
||||||
filteredTracks = [...filteredTracks].sort((a, b) => a.duration_ms - b.duration_ms);
|
|
||||||
} else if (sortBy === "duration-desc") {
|
|
||||||
filteredTracks = [...filteredTracks].sort((a, b) => b.duration_ms - a.duration_ms);
|
|
||||||
} else if (sortBy === "downloaded") {
|
|
||||||
filteredTracks = [...filteredTracks].sort((a, b) => {
|
|
||||||
const aDownloaded = downloadedTracks.has(a.isrc);
|
|
||||||
const bDownloaded = downloadedTracks.has(b.isrc);
|
|
||||||
return (bDownloaded ? 1 : 0) - (aDownloaded ? 1 : 0);
|
|
||||||
});
|
});
|
||||||
} else if (sortBy === "not-downloaded") {
|
if (sortBy === "title-asc") {
|
||||||
filteredTracks = [...filteredTracks].sort((a, b) => {
|
filteredTracks = [...filteredTracks].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
const aDownloaded = downloadedTracks.has(a.isrc);
|
}
|
||||||
const bDownloaded = downloadedTracks.has(b.isrc);
|
else if (sortBy === "title-desc") {
|
||||||
return (aDownloaded ? 1 : 0) - (bDownloaded ? 1 : 0);
|
filteredTracks = [...filteredTracks].sort((a, b) => b.name.localeCompare(a.name));
|
||||||
});
|
}
|
||||||
}
|
else if (sortBy === "artist-asc") {
|
||||||
|
filteredTracks = [...filteredTracks].sort((a, b) => a.artists.localeCompare(b.artists));
|
||||||
const totalPages = Math.ceil(filteredTracks.length / itemsPerPage);
|
}
|
||||||
const startIndex = (currentPage - 1) * itemsPerPage;
|
else if (sortBy === "artist-desc") {
|
||||||
const endIndex = startIndex + itemsPerPage;
|
filteredTracks = [...filteredTracks].sort((a, b) => b.artists.localeCompare(a.artists));
|
||||||
const paginatedTracks = filteredTracks.slice(startIndex, endIndex);
|
}
|
||||||
|
else if (sortBy === "duration-asc") {
|
||||||
const tracksWithIsrc = filteredTracks.filter((track) => track.isrc);
|
filteredTracks = [...filteredTracks].sort((a, b) => a.duration_ms - b.duration_ms);
|
||||||
const allSelected =
|
}
|
||||||
tracksWithIsrc.length > 0 &&
|
else if (sortBy === "duration-desc") {
|
||||||
tracksWithIsrc.every((track) => selectedTracks.includes(track.isrc));
|
filteredTracks = [...filteredTracks].sort((a, b) => b.duration_ms - a.duration_ms);
|
||||||
|
}
|
||||||
const formatDuration = (ms: number) => {
|
else if (sortBy === "plays-asc") {
|
||||||
const minutes = Math.floor(ms / 60000);
|
filteredTracks = [...filteredTracks].sort((a, b) => {
|
||||||
const seconds = Math.floor((ms % 60000) / 1000);
|
const aPlays = a.plays ? parseInt(a.plays, 10) : 0;
|
||||||
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
const bPlays = b.plays ? parseInt(b.plays, 10) : 0;
|
||||||
};
|
if (isNaN(aPlays))
|
||||||
|
return 1;
|
||||||
return (
|
if (isNaN(bPlays))
|
||||||
<div className="space-y-4">
|
return -1;
|
||||||
|
return aPlays - bPlays;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else if (sortBy === "plays-desc") {
|
||||||
|
filteredTracks = [...filteredTracks].sort((a, b) => {
|
||||||
|
const aPlays = a.plays ? parseInt(a.plays, 10) : 0;
|
||||||
|
const bPlays = b.plays ? parseInt(b.plays, 10) : 0;
|
||||||
|
if (isNaN(aPlays))
|
||||||
|
return 1;
|
||||||
|
if (isNaN(bPlays))
|
||||||
|
return -1;
|
||||||
|
return bPlays - aPlays;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else if (sortBy === "downloaded") {
|
||||||
|
filteredTracks = [...filteredTracks].sort((a, b) => {
|
||||||
|
const aDownloaded = downloadedTracks.has(a.isrc);
|
||||||
|
const bDownloaded = downloadedTracks.has(b.isrc);
|
||||||
|
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);
|
||||||
|
return (aDownloaded ? 1 : 0) - (bDownloaded ? 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 formatDuration = (ms: number) => {
|
||||||
|
const minutes = Math.floor(ms / 60000);
|
||||||
|
const seconds = Math.floor((ms % 60000) / 1000);
|
||||||
|
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
||||||
|
};
|
||||||
|
const formatPlays = (plays: string | undefined) => {
|
||||||
|
if (!plays)
|
||||||
|
return "";
|
||||||
|
const num = parseInt(plays, 10);
|
||||||
|
if (isNaN(num))
|
||||||
|
return plays;
|
||||||
|
return num.toLocaleString();
|
||||||
|
};
|
||||||
|
return (<div className="space-y-4">
|
||||||
<div className="rounded-md border">
|
<div className="rounded-md border">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b bg-muted/50">
|
<tr className="border-b bg-muted/50">
|
||||||
{showCheckboxes && (
|
{showCheckboxes && (<th className="h-12 px-4 text-left align-middle w-12">
|
||||||
<th className="h-12 px-4 text-left align-middle w-12">
|
<Checkbox checked={allSelected} onCheckedChange={() => onToggleSelectAll(filteredTracks)}/>
|
||||||
<Checkbox
|
</th>)}
|
||||||
checked={allSelected}
|
|
||||||
onCheckedChange={() => onToggleSelectAll(filteredTracks)}
|
|
||||||
/>
|
|
||||||
</th>
|
|
||||||
)}
|
|
||||||
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground w-12">
|
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground w-12">
|
||||||
#
|
#
|
||||||
</th>
|
</th>
|
||||||
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground">
|
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground">
|
||||||
Title
|
Title
|
||||||
</th>
|
</th>
|
||||||
{!hideAlbumColumn && (
|
{!hideAlbumColumn && (<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground hidden md:table-cell">
|
||||||
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground hidden md:table-cell">
|
|
||||||
Album
|
Album
|
||||||
</th>
|
</th>)}
|
||||||
)}
|
|
||||||
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground hidden lg:table-cell w-24">
|
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground hidden lg:table-cell w-24">
|
||||||
Duration
|
Duration
|
||||||
</th>
|
</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">
|
<th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground w-32">
|
||||||
Actions
|
Actions
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{paginatedTracks.map((track, index) => (
|
{paginatedTracks.map((track, index) => (<tr key={index} className="border-b transition-colors hover:bg-muted/50">
|
||||||
<tr key={index} className="border-b transition-colors hover:bg-muted/50">
|
{showCheckboxes && (<td className="p-4 align-middle">
|
||||||
{showCheckboxes && (
|
{track.isrc && (<Checkbox checked={selectedTracks.includes(track.isrc)} onCheckedChange={() => onToggleTrack(track.isrc)}/>)}
|
||||||
<td className="p-4 align-middle">
|
</td>)}
|
||||||
{track.isrc && (
|
|
||||||
<Checkbox
|
|
||||||
checked={selectedTracks.includes(track.isrc)}
|
|
||||||
onCheckedChange={() => onToggleTrack(track.isrc)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
)}
|
|
||||||
<td className="p-4 align-middle text-sm text-muted-foreground">
|
<td className="p-4 align-middle text-sm text-muted-foreground">
|
||||||
{startIndex + index + 1}
|
<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>)}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="p-4 align-middle">
|
<td className="p-4 align-middle">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{track.images && (
|
{track.images && (<img src={track.images} alt={track.name} className="w-10 h-10 rounded object-cover"/>)}
|
||||||
<img
|
|
||||||
src={track.images}
|
|
||||||
alt={track.name}
|
|
||||||
className="w-10 h-10 rounded object-cover"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{onTrackClick ? (
|
{onTrackClick ? (<span className="font-medium cursor-pointer hover:underline" onClick={() => onTrackClick(track)}>
|
||||||
<span
|
|
||||||
className="font-medium cursor-pointer hover:underline"
|
|
||||||
onClick={() => onTrackClick(track)}
|
|
||||||
>
|
|
||||||
{track.name}
|
{track.name}
|
||||||
</span>
|
</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}
|
||||||
<span className="font-medium">{track.name}</span>
|
|
||||||
)}
|
|
||||||
{skippedTracks.has(track.isrc) ? (
|
|
||||||
<SkipForward 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>
|
</div>
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
{track.artists_data && track.artists_data.length > 0 ? (
|
{track.artists_data && track.artists_data.length > 0 ? ((() => {
|
||||||
track.artists_data.map((artist, i, arr) => (
|
const artistNames = track.artists.split(", ").map(name => name.trim());
|
||||||
<span key={artist.id}>
|
return artistNames.map((name, i) => {
|
||||||
{onArtistClick ? (
|
const artistData = track.artists_data![i];
|
||||||
<span
|
const hasArtistData = artistData && artistData.id && artistData.external_urls;
|
||||||
className="cursor-pointer hover:underline"
|
return (<span key={artistData?.id || i}>
|
||||||
onClick={() =>
|
{onArtistClick && hasArtistData ? (<span className="cursor-pointer hover:underline" onClick={() => onArtistClick({
|
||||||
onArtistClick({
|
id: artistData.id,
|
||||||
id: artist.id,
|
name: name,
|
||||||
name: artist.name,
|
external_urls: artistData.external_urls,
|
||||||
external_urls: artist.external_urls,
|
})}>
|
||||||
})
|
{name}
|
||||||
}
|
</span>) : (name)}
|
||||||
>
|
{i < artistNames.length - 1 && ", "}
|
||||||
{artist.name}
|
</span>);
|
||||||
</span>
|
});
|
||||||
) : (
|
})()) : onArtistClick && track.artist_id && track.artist_url ? (<span className="cursor-pointer hover:underline" onClick={() => onArtistClick({
|
||||||
artist.name
|
id: track.artist_id!,
|
||||||
)}
|
name: track.artists,
|
||||||
{i < arr.length - 1 && ", "}
|
external_urls: track.artist_url!,
|
||||||
</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}
|
{track.artists}
|
||||||
</span>
|
</span>) : (track.artists)}
|
||||||
) : (
|
|
||||||
track.artists
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
{!hideAlbumColumn && (
|
{!hideAlbumColumn && (<td className="p-4 align-middle text-sm text-muted-foreground hidden md:table-cell">
|
||||||
<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({
|
||||||
{onAlbumClick && track.album_id && track.album_url ? (
|
id: track.album_id!,
|
||||||
<span
|
name: track.album_name,
|
||||||
className="cursor-pointer hover:underline"
|
external_urls: track.album_url!,
|
||||||
onClick={() =>
|
})}>
|
||||||
onAlbumClick({
|
|
||||||
id: track.album_id!,
|
|
||||||
name: track.album_name,
|
|
||||||
external_urls: track.album_url!,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{track.album_name}
|
{track.album_name}
|
||||||
</span>
|
</span>) : (track.album_name)}
|
||||||
) : (
|
</td>)}
|
||||||
track.album_name
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
)}
|
|
||||||
<td className="p-4 align-middle text-sm text-muted-foreground hidden lg:table-cell">
|
<td className="p-4 align-middle text-sm text-muted-foreground hidden lg:table-cell">
|
||||||
{formatDuration(track.duration_ms)}
|
{formatDuration(track.duration_ms)}
|
||||||
</td>
|
</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">
|
<td className="p-4 align-middle text-center">
|
||||||
<div className="flex items-center justify-center gap-1">
|
<div className="flex items-center justify-center gap-1">
|
||||||
{track.isrc && (
|
{track.isrc && (<Tooltip>
|
||||||
<Button
|
|
||||||
onClick={() =>
|
|
||||||
onDownloadTrack(track.isrc, track.name, track.artists, track.album_name, track.spotify_id, folderName, isArtistDiscography)
|
|
||||||
}
|
|
||||||
size="sm"
|
|
||||||
className="gap-1.5"
|
|
||||||
disabled={isDownloading || downloadingTrack === track.isrc}
|
|
||||||
>
|
|
||||||
{downloadingTrack === track.isrc ? (
|
|
||||||
<Spinner />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Download className="h-4 w-4" />
|
|
||||||
Download
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{track.spotify_id && onDownloadLyrics && (
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<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}>
|
||||||
onClick={() =>
|
{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"/>)}
|
||||||
onDownloadLyrics(track.spotify_id!, track.name, track.artists, track.album_name, folderName, isArtistDiscography)
|
</Button>
|
||||||
}
|
</TooltipTrigger>
|
||||||
size="sm"
|
<TooltipContent>
|
||||||
variant="outline"
|
{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>)}
|
||||||
disabled={downloadingLyricsTrack === track.spotify_id}
|
</TooltipContent>
|
||||||
>
|
</Tooltip>)}
|
||||||
{downloadingLyricsTrack === track.spotify_id ? (
|
{track.spotify_id && onDownloadLyrics && (<Tooltip>
|
||||||
<Spinner />
|
<TooltipTrigger asChild>
|
||||||
) : skippedLyrics?.has(track.spotify_id) ? (
|
<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}>
|
||||||
<SkipForward className="h-4 w-4 text-yellow-500" />
|
{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"/>)}
|
||||||
) : 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>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>Download Lyric</p>
|
<p>Download Lyric</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>))}
|
||||||
))}
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{totalPages > 1 && (
|
{totalPages > 1 && (<Pagination>
|
||||||
<Pagination>
|
|
||||||
<PaginationContent>
|
<PaginationContent>
|
||||||
<PaginationItem>
|
<PaginationItem>
|
||||||
<PaginationPrevious
|
<PaginationPrevious href="#" onClick={(e) => {
|
||||||
href="#"
|
e.preventDefault();
|
||||||
onClick={(e) => {
|
if (currentPage > 1)
|
||||||
e.preventDefault();
|
onPageChange(currentPage - 1);
|
||||||
if (currentPage > 1) onPageChange(currentPage - 1);
|
}} className={currentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}/>
|
||||||
}}
|
|
||||||
className={
|
|
||||||
currentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</PaginationItem>
|
</PaginationItem>
|
||||||
|
|
||||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
|
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (<PaginationItem key={page}>
|
||||||
<PaginationItem key={page}>
|
<PaginationLink href="#" onClick={(e) => {
|
||||||
<PaginationLink
|
|
||||||
href="#"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onPageChange(page);
|
onPageChange(page);
|
||||||
}}
|
}} isActive={currentPage === page} className="cursor-pointer">
|
||||||
isActive={currentPage === page}
|
|
||||||
className="cursor-pointer"
|
|
||||||
>
|
|
||||||
{page}
|
{page}
|
||||||
</PaginationLink>
|
</PaginationLink>
|
||||||
</PaginationItem>
|
</PaginationItem>))}
|
||||||
))}
|
|
||||||
|
|
||||||
<PaginationItem>
|
<PaginationItem>
|
||||||
<PaginationNext
|
<PaginationNext href="#" onClick={(e) => {
|
||||||
href="#"
|
e.preventDefault();
|
||||||
onClick={(e) => {
|
if (currentPage < totalPages)
|
||||||
e.preventDefault();
|
onPageChange(currentPage + 1);
|
||||||
if (currentPage < totalPages) onPageChange(currentPage + 1);
|
}} className={currentPage === totalPages
|
||||||
}}
|
? "pointer-events-none opacity-50"
|
||||||
className={
|
: "cursor-pointer"}/>
|
||||||
currentPage === totalPages
|
|
||||||
? "pointer-events-none opacity-50"
|
|
||||||
: "cursor-pointer"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</PaginationItem>
|
</PaginationItem>
|
||||||
</PaginationContent>
|
</PaginationContent>
|
||||||
</Pagination>
|
</Pagination>)}
|
||||||
)}
|
</div>);
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
'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';
|
||||||
|
export interface ActivityIconHandle {
|
||||||
|
startAnimation: () => void;
|
||||||
|
stopAnimation: () => void;
|
||||||
|
}
|
||||||
|
interface ActivityIconProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
const PATH_VARIANTS: Variants = {
|
||||||
|
normal: {
|
||||||
|
pathLength: 1,
|
||||||
|
opacity: 1,
|
||||||
|
pathOffset: 0,
|
||||||
|
},
|
||||||
|
animate: {
|
||||||
|
pathLength: [0, 1],
|
||||||
|
opacity: [0, 1],
|
||||||
|
pathOffset: [1, 0],
|
||||||
|
transition: {
|
||||||
|
duration: 0.8,
|
||||||
|
ease: 'easeInOut',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const ActivityIcon = forwardRef<ActivityIconHandle, ActivityIconProps>(({ 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) {
|
||||||
|
controls.start('animate');
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
onMouseEnter?.(e);
|
||||||
|
}
|
||||||
|
}, [controls, onMouseEnter]);
|
||||||
|
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (!isControlledRef.current) {
|
||||||
|
controls.start('normal');
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
onMouseLeave?.(e);
|
||||||
|
}
|
||||||
|
}, [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">
|
||||||
|
<motion.path d="M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2" variants={PATH_VARIANTS} animate={controls} initial="normal"/>
|
||||||
|
</svg>
|
||||||
|
</div>);
|
||||||
|
});
|
||||||
|
ActivityIcon.displayName = 'ActivityIcon';
|
||||||
|
export { ActivityIcon };
|
||||||
@@ -1,46 +1,24 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import { Slot } from "@radix-ui/react-slot"
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
import { cn } from "@/lib/utils"
|
const badgeVariants = cva("inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", {
|
||||||
|
|
||||||
const badgeVariants = cva(
|
|
||||||
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
|
||||||
{
|
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default:
|
default: "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||||
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
secondary: "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||||
secondary:
|
destructive: "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||||
destructive:
|
},
|
||||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
|
||||||
outline:
|
|
||||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
variant: "default",
|
variant: "default",
|
||||||
},
|
},
|
||||||
}
|
});
|
||||||
)
|
function Badge({ className, variant, asChild = false, ...props }: React.ComponentProps<"span"> & VariantProps<typeof badgeVariants> & {
|
||||||
|
asChild?: boolean;
|
||||||
function Badge({
|
}) {
|
||||||
className,
|
const Comp = asChild ? Slot : "span";
|
||||||
variant,
|
return (<Comp data-slot="badge" className={cn(badgeVariants({ variant }), className)} {...props}/>);
|
||||||
asChild = false,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"span"> &
|
|
||||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
|
||||||
const Comp = asChild ? Slot : "span"
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Comp
|
|
||||||
data-slot="badge"
|
|
||||||
className={cn(badgeVariants({ variant }), className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
export { Badge, badgeVariants };
|
||||||
export { Badge, badgeVariants }
|
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
'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';
|
||||||
|
export interface BlocksIconHandle {
|
||||||
|
startAnimation: () => void;
|
||||||
|
stopAnimation: () => void;
|
||||||
|
}
|
||||||
|
interface BlocksIconProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
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 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) {
|
||||||
|
controls.start('animate');
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
onMouseEnter?.(e);
|
||||||
|
}
|
||||||
|
}, [controls, onMouseEnter]);
|
||||||
|
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (!isControlledRef.current) {
|
||||||
|
controls.start('normal');
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
onMouseLeave?.(e);
|
||||||
|
}
|
||||||
|
}, [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">
|
||||||
|
<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}/>
|
||||||
|
</svg>
|
||||||
|
</div>);
|
||||||
|
});
|
||||||
|
BlocksIcon.displayName = 'BlocksIcon';
|
||||||
|
export { BlocksIcon };
|
||||||
@@ -1,60 +1,35 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import { Slot } from "@radix-ui/react-slot"
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
import { cn } from "@/lib/utils"
|
const buttonVariants = cva("inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all cursor-pointer disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", {
|
||||||
|
|
||||||
const buttonVariants = cva(
|
|
||||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all cursor-pointer disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
|
||||||
{
|
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
destructive:
|
destructive: "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
outline: "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||||
outline:
|
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||||
secondary:
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
},
|
||||||
ghost:
|
size: {
|
||||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
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",
|
||||||
size: {
|
icon: "size-9",
|
||||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
"icon-sm": "size-8",
|
||||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
"icon-lg": "size-10",
|
||||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
},
|
||||||
icon: "size-9",
|
|
||||||
"icon-sm": "size-8",
|
|
||||||
"icon-lg": "size-10",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
variant: "default",
|
variant: "default",
|
||||||
size: "default",
|
size: "default",
|
||||||
},
|
},
|
||||||
}
|
});
|
||||||
)
|
function Button({ className, variant, size, asChild = false, ...props }: React.ComponentProps<"button"> & VariantProps<typeof buttonVariants> & {
|
||||||
|
asChild?: boolean;
|
||||||
function Button({
|
}) {
|
||||||
className,
|
const Comp = asChild ? Slot : "button";
|
||||||
variant,
|
return (<Comp data-slot="button" className={cn(buttonVariants({ variant, size, className }))} {...props}/>);
|
||||||
size,
|
|
||||||
asChild = false,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"button"> &
|
|
||||||
VariantProps<typeof buttonVariants> & {
|
|
||||||
asChild?: boolean
|
|
||||||
}) {
|
|
||||||
const Comp = asChild ? Slot : "button"
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Comp
|
|
||||||
data-slot="button"
|
|
||||||
className={cn(buttonVariants({ variant, size, className }))}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
export { Button, buttonVariants };
|
||||||
export { Button, buttonVariants }
|
|
||||||
|
|||||||
@@ -1,92 +1,24 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (<div data-slot="card" className={cn("bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm", className)} {...props}/>);
|
||||||
<div
|
|
||||||
data-slot="card"
|
|
||||||
className={cn(
|
|
||||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (<div data-slot="card-header" className={cn("@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6", className)} {...props}/>);
|
||||||
<div
|
|
||||||
data-slot="card-header"
|
|
||||||
className={cn(
|
|
||||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (<div data-slot="card-title" className={cn("leading-none font-semibold", className)} {...props}/>);
|
||||||
<div
|
|
||||||
data-slot="card-title"
|
|
||||||
className={cn("leading-none font-semibold", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (<div data-slot="card-description" className={cn("text-muted-foreground text-sm", className)} {...props}/>);
|
||||||
<div
|
|
||||||
data-slot="card-description"
|
|
||||||
className={cn("text-muted-foreground text-sm", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (<div data-slot="card-action" className={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)} {...props}/>);
|
||||||
<div
|
|
||||||
data-slot="card-action"
|
|
||||||
className={cn(
|
|
||||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (<div data-slot="card-content" className={cn("px-6", className)} {...props}/>);
|
||||||
<div
|
|
||||||
data-slot="card-content"
|
|
||||||
className={cn("px-6", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (<div data-slot="card-footer" className={cn("flex items-center px-6 [.border-t]:pt-6", className)} {...props}/>);
|
||||||
<div
|
|
||||||
data-slot="card-footer"
|
|
||||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
|
||||||
Card,
|
|
||||||
CardHeader,
|
|
||||||
CardFooter,
|
|
||||||
CardTitle,
|
|
||||||
CardAction,
|
|
||||||
CardDescription,
|
|
||||||
CardContent,
|
|
||||||
}
|
}
|
||||||
|
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent, };
|
||||||
|
|||||||
@@ -1,32 +1,13 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
import * as React from "react";
|
||||||
import * as React from "react"
|
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
||||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
import { CheckIcon } from "lucide-react";
|
||||||
import { CheckIcon } from "lucide-react"
|
import { cn } from "@/lib/utils";
|
||||||
|
function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||||
import { cn } from "@/lib/utils"
|
return (<CheckboxPrimitive.Root data-slot="checkbox" className={cn("peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50", className)} {...props}>
|
||||||
|
<CheckboxPrimitive.Indicator data-slot="checkbox-indicator" className="grid place-content-center text-current transition-none">
|
||||||
function Checkbox({
|
<CheckIcon className="size-3.5"/>
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
|
||||||
return (
|
|
||||||
<CheckboxPrimitive.Root
|
|
||||||
data-slot="checkbox"
|
|
||||||
className={cn(
|
|
||||||
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<CheckboxPrimitive.Indicator
|
|
||||||
data-slot="checkbox-indicator"
|
|
||||||
className="grid place-content-center text-current transition-none"
|
|
||||||
>
|
|
||||||
<CheckIcon className="size-3.5" />
|
|
||||||
</CheckboxPrimitive.Indicator>
|
</CheckboxPrimitive.Indicator>
|
||||||
</CheckboxPrimitive.Root>
|
</CheckboxPrimitive.Root>);
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
export { Checkbox };
|
||||||
export { Checkbox }
|
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
'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';
|
||||||
|
export interface CoffeeIconHandle {
|
||||||
|
startAnimation: () => void;
|
||||||
|
stopAnimation: () => void;
|
||||||
|
}
|
||||||
|
interface CoffeeIconProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
const PATH_VARIANTS: Variants = {
|
||||||
|
normal: {
|
||||||
|
y: 0,
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
animate: (custom: number) => ({
|
||||||
|
y: -3,
|
||||||
|
opacity: [0, 1, 0],
|
||||||
|
transition: {
|
||||||
|
repeat: Infinity,
|
||||||
|
duration: 1.5,
|
||||||
|
ease: 'easeInOut',
|
||||||
|
delay: 0.2 * custom,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const CoffeeIcon = forwardRef<CoffeeIconHandle, CoffeeIconProps>(({ 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) {
|
||||||
|
controls.start('animate');
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
onMouseEnter?.(e);
|
||||||
|
}
|
||||||
|
}, [controls, onMouseEnter]);
|
||||||
|
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (!isControlledRef.current) {
|
||||||
|
controls.start('normal');
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
onMouseLeave?.(e);
|
||||||
|
}
|
||||||
|
}, [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"/>
|
||||||
|
</svg>
|
||||||
|
</div>);
|
||||||
|
});
|
||||||
|
CoffeeIcon.displayName = 'CoffeeIcon';
|
||||||
|
export { CoffeeIcon };
|
||||||
@@ -1,252 +1,77 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
import * as React from "react";
|
||||||
import * as React from "react"
|
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu";
|
||||||
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
|
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
|
||||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
import { cn } from "@/lib/utils";
|
||||||
|
function ContextMenu({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
|
||||||
import { cn } from "@/lib/utils"
|
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props}/>;
|
||||||
|
|
||||||
function ContextMenu({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
|
|
||||||
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
|
|
||||||
}
|
}
|
||||||
|
function ContextMenuTrigger({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
|
||||||
function ContextMenuTrigger({
|
return (<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props}/>);
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
|
|
||||||
return (
|
|
||||||
<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
function ContextMenuGroup({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
|
||||||
function ContextMenuGroup({
|
return (<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props}/>);
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
|
|
||||||
return (
|
|
||||||
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
function ContextMenuPortal({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
|
||||||
function ContextMenuPortal({
|
return (<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props}/>);
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
|
|
||||||
return (
|
|
||||||
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
function ContextMenuSub({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
|
||||||
function ContextMenuSub({
|
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props}/>;
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
|
|
||||||
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />
|
|
||||||
}
|
}
|
||||||
|
function ContextMenuRadioGroup({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
|
||||||
function ContextMenuRadioGroup({
|
return (<ContextMenuPrimitive.RadioGroup data-slot="context-menu-radio-group" {...props}/>);
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
|
|
||||||
return (
|
|
||||||
<ContextMenuPrimitive.RadioGroup
|
|
||||||
data-slot="context-menu-radio-group"
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
function ContextMenuSubTrigger({ className, inset, children, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
|
||||||
function ContextMenuSubTrigger({
|
inset?: boolean;
|
||||||
className,
|
|
||||||
inset,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
|
|
||||||
inset?: boolean
|
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (<ContextMenuPrimitive.SubTrigger data-slot="context-menu-sub-trigger" data-inset={inset} className={cn("focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-inset:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className)} {...props}>
|
||||||
<ContextMenuPrimitive.SubTrigger
|
|
||||||
data-slot="context-menu-sub-trigger"
|
|
||||||
data-inset={inset}
|
|
||||||
className={cn(
|
|
||||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-inset:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
<ChevronRightIcon className="ml-auto" />
|
<ChevronRightIcon className="ml-auto"/>
|
||||||
</ContextMenuPrimitive.SubTrigger>
|
</ContextMenuPrimitive.SubTrigger>);
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
function ContextMenuSubContent({ className, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
|
||||||
function ContextMenuSubContent({
|
return (<ContextMenuPrimitive.SubContent data-slot="context-menu-sub-content" className={cn("bg-popover text-popover-foreground 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 z-50 min-w-32 origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg", className)} {...props}/>);
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
|
|
||||||
return (
|
|
||||||
<ContextMenuPrimitive.SubContent
|
|
||||||
data-slot="context-menu-sub-content"
|
|
||||||
className={cn(
|
|
||||||
"bg-popover text-popover-foreground 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 z-50 min-w-32 origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
function ContextMenuContent({ className, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
|
||||||
function ContextMenuContent({
|
return (<ContextMenuPrimitive.Portal>
|
||||||
className,
|
<ContextMenuPrimitive.Content data-slot="context-menu-content" className={cn("bg-popover text-popover-foreground 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 z-50 max-h-(--radix-context-menu-content-available-height) min-w-32 origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md", className)} {...props}/>
|
||||||
...props
|
</ContextMenuPrimitive.Portal>);
|
||||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
|
|
||||||
return (
|
|
||||||
<ContextMenuPrimitive.Portal>
|
|
||||||
<ContextMenuPrimitive.Content
|
|
||||||
data-slot="context-menu-content"
|
|
||||||
className={cn(
|
|
||||||
"bg-popover text-popover-foreground 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 z-50 max-h-(--radix-context-menu-content-available-height) min-w-32 origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
</ContextMenuPrimitive.Portal>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
function ContextMenuItem({ className, inset, variant = "default", ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
|
||||||
function ContextMenuItem({
|
inset?: boolean;
|
||||||
className,
|
variant?: "default" | "destructive";
|
||||||
inset,
|
|
||||||
variant = "default",
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
|
|
||||||
inset?: boolean
|
|
||||||
variant?: "default" | "destructive"
|
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (<ContextMenuPrimitive.Item data-slot="context-menu-item" data-inset={inset} data-variant={variant} className={cn("focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive! [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className)} {...props}/>);
|
||||||
<ContextMenuPrimitive.Item
|
|
||||||
data-slot="context-menu-item"
|
|
||||||
data-inset={inset}
|
|
||||||
data-variant={variant}
|
|
||||||
className={cn(
|
|
||||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive! [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
function ContextMenuCheckboxItem({ className, children, checked, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
|
||||||
function ContextMenuCheckboxItem({
|
return (<ContextMenuPrimitive.CheckboxItem data-slot="context-menu-checkbox-item" className={cn("focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none 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}>
|
||||||
className,
|
|
||||||
children,
|
|
||||||
checked,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
|
|
||||||
return (
|
|
||||||
<ContextMenuPrimitive.CheckboxItem
|
|
||||||
data-slot="context-menu-checkbox-item"
|
|
||||||
className={cn(
|
|
||||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none 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">
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
<ContextMenuPrimitive.ItemIndicator>
|
<ContextMenuPrimitive.ItemIndicator>
|
||||||
<CheckIcon className="size-4" />
|
<CheckIcon className="size-4"/>
|
||||||
</ContextMenuPrimitive.ItemIndicator>
|
</ContextMenuPrimitive.ItemIndicator>
|
||||||
</span>
|
</span>
|
||||||
{children}
|
{children}
|
||||||
</ContextMenuPrimitive.CheckboxItem>
|
</ContextMenuPrimitive.CheckboxItem>);
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
function ContextMenuRadioItem({ className, children, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
|
||||||
function ContextMenuRadioItem({
|
return (<ContextMenuPrimitive.RadioItem data-slot="context-menu-radio-item" className={cn("focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className)} {...props}>
|
||||||
className,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
|
|
||||||
return (
|
|
||||||
<ContextMenuPrimitive.RadioItem
|
|
||||||
data-slot="context-menu-radio-item"
|
|
||||||
className={cn(
|
|
||||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none 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">
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
<ContextMenuPrimitive.ItemIndicator>
|
<ContextMenuPrimitive.ItemIndicator>
|
||||||
<CircleIcon className="size-2 fill-current" />
|
<CircleIcon className="size-2 fill-current"/>
|
||||||
</ContextMenuPrimitive.ItemIndicator>
|
</ContextMenuPrimitive.ItemIndicator>
|
||||||
</span>
|
</span>
|
||||||
{children}
|
{children}
|
||||||
</ContextMenuPrimitive.RadioItem>
|
</ContextMenuPrimitive.RadioItem>);
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
function ContextMenuLabel({ className, inset, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
|
||||||
function ContextMenuLabel({
|
inset?: boolean;
|
||||||
className,
|
|
||||||
inset,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
|
|
||||||
inset?: boolean
|
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (<ContextMenuPrimitive.Label data-slot="context-menu-label" data-inset={inset} className={cn("text-foreground px-2 py-1.5 text-sm font-medium data-inset:pl-8", className)} {...props}/>);
|
||||||
<ContextMenuPrimitive.Label
|
|
||||||
data-slot="context-menu-label"
|
|
||||||
data-inset={inset}
|
|
||||||
className={cn(
|
|
||||||
"text-foreground px-2 py-1.5 text-sm font-medium data-inset:pl-8",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
function ContextMenuSeparator({ className, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
|
||||||
function ContextMenuSeparator({
|
return (<ContextMenuPrimitive.Separator data-slot="context-menu-separator" className={cn("bg-border -mx-1 my-1 h-px", className)} {...props}/>);
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
|
|
||||||
return (
|
|
||||||
<ContextMenuPrimitive.Separator
|
|
||||||
data-slot="context-menu-separator"
|
|
||||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
function ContextMenuShortcut({ className, ...props }: React.ComponentProps<"span">) {
|
||||||
function ContextMenuShortcut({
|
return (<span data-slot="context-menu-shortcut" className={cn("text-muted-foreground ml-auto text-xs tracking-widest", className)} {...props}/>);
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"span">) {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
data-slot="context-menu-shortcut"
|
|
||||||
className={cn(
|
|
||||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
|
||||||
ContextMenu,
|
|
||||||
ContextMenuTrigger,
|
|
||||||
ContextMenuContent,
|
|
||||||
ContextMenuItem,
|
|
||||||
ContextMenuCheckboxItem,
|
|
||||||
ContextMenuRadioItem,
|
|
||||||
ContextMenuLabel,
|
|
||||||
ContextMenuSeparator,
|
|
||||||
ContextMenuShortcut,
|
|
||||||
ContextMenuGroup,
|
|
||||||
ContextMenuPortal,
|
|
||||||
ContextMenuSub,
|
|
||||||
ContextMenuSubContent,
|
|
||||||
ContextMenuSubTrigger,
|
|
||||||
ContextMenuRadioGroup,
|
|
||||||
}
|
}
|
||||||
|
export { ContextMenu, ContextMenuTrigger, ContextMenuContent, ContextMenuItem, ContextMenuCheckboxItem, ContextMenuRadioItem, ContextMenuLabel, ContextMenuSeparator, ContextMenuShortcut, ContextMenuGroup, ContextMenuPortal, ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuRadioGroup, };
|
||||||
|
|||||||
@@ -1,143 +1,47 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
import * as React from "react";
|
||||||
import * as React from "react"
|
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
import { XIcon } from "lucide-react";
|
||||||
import { XIcon } from "lucide-react"
|
import { cn } from "@/lib/utils";
|
||||||
|
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||||
import { cn } from "@/lib/utils"
|
return <DialogPrimitive.Root data-slot="dialog" {...props}/>;
|
||||||
|
|
||||||
function Dialog({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
|
||||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
|
||||||
}
|
}
|
||||||
|
function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||||
function DialogTrigger({
|
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props}/>;
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
|
||||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
|
||||||
}
|
}
|
||||||
|
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||||
function DialogPortal({
|
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props}/>;
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
|
||||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
|
||||||
}
|
}
|
||||||
|
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||||
function DialogClose({
|
return <DialogPrimitive.Close data-slot="dialog-close" {...props}/>;
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
|
||||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
|
||||||
}
|
}
|
||||||
|
function DialogOverlay({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||||
function DialogOverlay({
|
return (<DialogPrimitive.Overlay data-slot="dialog-overlay" className={cn("data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50", className)} {...props}/>);
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
|
||||||
return (
|
|
||||||
<DialogPrimitive.Overlay
|
|
||||||
data-slot="dialog-overlay"
|
|
||||||
className={cn(
|
|
||||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
function DialogContent({ className, children, showCloseButton = true, ...props }: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||||
function DialogContent({
|
showCloseButton?: boolean;
|
||||||
className,
|
|
||||||
children,
|
|
||||||
showCloseButton = true,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
|
||||||
showCloseButton?: boolean
|
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (<DialogPortal data-slot="dialog-portal">
|
||||||
<DialogPortal data-slot="dialog-portal">
|
|
||||||
<DialogOverlay />
|
<DialogOverlay />
|
||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content data-slot="dialog-content" className={cn("bg-background 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 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200", className)} {...props}>
|
||||||
data-slot="dialog-content"
|
|
||||||
className={cn(
|
|
||||||
"bg-background 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 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
{showCloseButton && (
|
{showCloseButton && (<DialogPrimitive.Close data-slot="dialog-close" className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
|
||||||
<DialogPrimitive.Close
|
|
||||||
data-slot="dialog-close"
|
|
||||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
|
||||||
>
|
|
||||||
<XIcon />
|
<XIcon />
|
||||||
<span className="sr-only">Close</span>
|
<span className="sr-only">Close</span>
|
||||||
</DialogPrimitive.Close>
|
</DialogPrimitive.Close>)}
|
||||||
)}
|
|
||||||
</DialogPrimitive.Content>
|
</DialogPrimitive.Content>
|
||||||
</DialogPortal>
|
</DialogPortal>);
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (<div data-slot="dialog-header" className={cn("flex flex-col gap-2 text-center sm:text-left", className)} {...props}/>);
|
||||||
<div
|
|
||||||
data-slot="dialog-header"
|
|
||||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (<div data-slot="dialog-footer" className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)} {...props}/>);
|
||||||
<div
|
|
||||||
data-slot="dialog-footer"
|
|
||||||
className={cn(
|
|
||||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||||
function DialogTitle({
|
return (<DialogPrimitive.Title data-slot="dialog-title" className={cn("text-lg leading-none font-semibold", className)} {...props}/>);
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
|
||||||
return (
|
|
||||||
<DialogPrimitive.Title
|
|
||||||
data-slot="dialog-title"
|
|
||||||
className={cn("text-lg leading-none font-semibold", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
function DialogDescription({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||||
function DialogDescription({
|
return (<DialogPrimitive.Description data-slot="dialog-description" className={cn("text-muted-foreground text-sm", className)} {...props}/>);
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
|
||||||
return (
|
|
||||||
<DialogPrimitive.Description
|
|
||||||
data-slot="dialog-description"
|
|
||||||
className={cn("text-muted-foreground text-sm", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
|
||||||
Dialog,
|
|
||||||
DialogClose,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogOverlay,
|
|
||||||
DialogPortal,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
}
|
}
|
||||||
|
export { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger, };
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
'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';
|
||||||
|
|
||||||
|
export interface FileMusicIconHandle {
|
||||||
|
startAnimation: () => void;
|
||||||
|
stopAnimation: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileMusicIconProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PATH_VARIANTS: Variants = {
|
||||||
|
normal: {
|
||||||
|
pathLength: 1,
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
animate: {
|
||||||
|
pathLength: [0, 1],
|
||||||
|
opacity: [0, 1],
|
||||||
|
transition: {
|
||||||
|
duration: 0.6,
|
||||||
|
ease: 'easeInOut',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const FileMusicIcon = forwardRef<FileMusicIconHandle, FileMusicIconProps>(
|
||||||
|
({ 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) {
|
||||||
|
controls.start('animate');
|
||||||
|
} else {
|
||||||
|
onMouseEnter?.(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[controls, onMouseEnter]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMouseLeave = useCallback(
|
||||||
|
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (!isControlledRef.current) {
|
||||||
|
controls.start('normal');
|
||||||
|
} else {
|
||||||
|
onMouseLeave?.(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[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"
|
||||||
|
>
|
||||||
|
<motion.path
|
||||||
|
d="M11.65 22H18a2 2 0 0 0 2-2V8a2.4 2.4 0 0 0-.706-1.706l-3.588-3.588A2.4 2.4 0 0 0 14 2H6a2 2 0 0 0-2 2v10.35"
|
||||||
|
variants={PATH_VARIANTS}
|
||||||
|
animate={controls}
|
||||||
|
initial="normal"
|
||||||
|
/>
|
||||||
|
<motion.path
|
||||||
|
d="M14 2v5a1 1 0 0 0 1 1h5"
|
||||||
|
variants={PATH_VARIANTS}
|
||||||
|
animate={controls}
|
||||||
|
initial="normal"
|
||||||
|
/>
|
||||||
|
<motion.path
|
||||||
|
d="M8 20v-7l3 1.474"
|
||||||
|
variants={PATH_VARIANTS}
|
||||||
|
animate={controls}
|
||||||
|
initial="normal"
|
||||||
|
/>
|
||||||
|
<motion.circle
|
||||||
|
cx="6"
|
||||||
|
cy="20"
|
||||||
|
r="2"
|
||||||
|
variants={PATH_VARIANTS}
|
||||||
|
animate={controls}
|
||||||
|
initial="normal"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
FileMusicIcon.displayName = 'FileMusicIcon';
|
||||||
|
export { FileMusicIcon };
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
'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';
|
||||||
|
|
||||||
|
export interface FilePenIconHandle {
|
||||||
|
startAnimation: () => void;
|
||||||
|
stopAnimation: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FilePenIconProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PATH_VARIANTS: Variants = {
|
||||||
|
normal: {
|
||||||
|
pathLength: 1,
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
animate: {
|
||||||
|
pathLength: [0, 1],
|
||||||
|
opacity: [0, 1],
|
||||||
|
transition: {
|
||||||
|
duration: 0.6,
|
||||||
|
ease: 'easeInOut',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const FilePenIcon = forwardRef<FilePenIconHandle, FilePenIconProps>(
|
||||||
|
({ 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) {
|
||||||
|
controls.start('animate');
|
||||||
|
} else {
|
||||||
|
onMouseEnter?.(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[controls, onMouseEnter]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMouseLeave = useCallback(
|
||||||
|
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (!isControlledRef.current) {
|
||||||
|
controls.start('normal');
|
||||||
|
} else {
|
||||||
|
onMouseLeave?.(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[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"
|
||||||
|
>
|
||||||
|
<motion.path
|
||||||
|
d="M12.659 22H18a2 2 0 0 0 2-2V8a2.4 2.4 0 0 0-.706-1.706l-3.588-3.588A2.4 2.4 0 0 0 14 2H6a2 2 0 0 0-2 2v9.34"
|
||||||
|
variants={PATH_VARIANTS}
|
||||||
|
animate={controls}
|
||||||
|
initial="normal"
|
||||||
|
/>
|
||||||
|
<motion.path
|
||||||
|
d="M14 2v5a1 1 0 0 0 1 1h5"
|
||||||
|
variants={PATH_VARIANTS}
|
||||||
|
animate={controls}
|
||||||
|
initial="normal"
|
||||||
|
/>
|
||||||
|
<motion.path
|
||||||
|
d="M10.378 12.622a1 1 0 0 1 3 3.003L8.36 20.637a2 2 0 0 1-.854.506l-2.867.837a.5.5 0 0 1-.62-.62l.836-2.869a2 2 0 0 1 .506-.853z"
|
||||||
|
variants={PATH_VARIANTS}
|
||||||
|
animate={controls}
|
||||||
|
initial="normal"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
FilePenIcon.displayName = 'FilePenIcon';
|
||||||
|
export { FilePenIcon };
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
'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';
|
||||||
|
export interface GithubIconHandle {
|
||||||
|
startAnimation: () => void;
|
||||||
|
stopAnimation: () => void;
|
||||||
|
}
|
||||||
|
interface GithubIconProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
const BODY_VARIANTS: Variants = {
|
||||||
|
normal: {
|
||||||
|
opacity: 1,
|
||||||
|
pathLength: 1,
|
||||||
|
scale: 1,
|
||||||
|
transition: {
|
||||||
|
duration: 0.3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
animate: {
|
||||||
|
opacity: [0, 1],
|
||||||
|
pathLength: [0, 1],
|
||||||
|
scale: [0.9, 1],
|
||||||
|
transition: {
|
||||||
|
duration: 0.4,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const TAIL_VARIANTS: Variants = {
|
||||||
|
normal: {
|
||||||
|
pathLength: 1,
|
||||||
|
rotate: 0,
|
||||||
|
transition: {
|
||||||
|
duration: 0.3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
draw: {
|
||||||
|
pathLength: [0, 1],
|
||||||
|
rotate: 0,
|
||||||
|
transition: {
|
||||||
|
duration: 0.5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wag: {
|
||||||
|
pathLength: 1,
|
||||||
|
rotate: [0, -15, 15, -10, 10, -5, 5],
|
||||||
|
transition: {
|
||||||
|
duration: 2.5,
|
||||||
|
ease: 'easeInOut',
|
||||||
|
repeat: Infinity,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const GithubIcon = forwardRef<GithubIconHandle, GithubIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
|
||||||
|
const bodyControls = useAnimation();
|
||||||
|
const tailControls = useAnimation();
|
||||||
|
const isControlledRef = useRef(false);
|
||||||
|
useImperativeHandle(ref, () => {
|
||||||
|
isControlledRef.current = true;
|
||||||
|
return {
|
||||||
|
startAnimation: async () => {
|
||||||
|
bodyControls.start('animate');
|
||||||
|
await tailControls.start('draw');
|
||||||
|
tailControls.start('wag');
|
||||||
|
},
|
||||||
|
stopAnimation: () => {
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
onMouseEnter?.(e);
|
||||||
|
}
|
||||||
|
}, [bodyControls, onMouseEnter, tailControls]);
|
||||||
|
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (!isControlledRef.current) {
|
||||||
|
bodyControls.start('normal');
|
||||||
|
tailControls.start('normal');
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
onMouseLeave?.(e);
|
||||||
|
}
|
||||||
|
}, [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>
|
||||||
|
</div>);
|
||||||
|
});
|
||||||
|
GithubIcon.displayName = 'GithubIcon';
|
||||||
|
export { GithubIcon };
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
'use client';
|
||||||
|
import type { Transition, 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';
|
||||||
|
export interface HomeIconHandle {
|
||||||
|
startAnimation: () => void;
|
||||||
|
stopAnimation: () => void;
|
||||||
|
}
|
||||||
|
interface HomeIconProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
const DEFAULT_TRANSITION: Transition = {
|
||||||
|
duration: 0.6,
|
||||||
|
opacity: { duration: 0.2 },
|
||||||
|
};
|
||||||
|
const PATH_VARIANTS: Variants = {
|
||||||
|
normal: {
|
||||||
|
pathLength: 1,
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
animate: {
|
||||||
|
opacity: [0, 1],
|
||||||
|
pathLength: [0, 1],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const HomeIcon = forwardRef<HomeIconHandle, HomeIconProps>(({ 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) {
|
||||||
|
controls.start('animate');
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
onMouseEnter?.(e);
|
||||||
|
}
|
||||||
|
}, [controls, onMouseEnter]);
|
||||||
|
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (!isControlledRef.current) {
|
||||||
|
controls.start('normal');
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
onMouseLeave?.(e);
|
||||||
|
}
|
||||||
|
}, [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">
|
||||||
|
<path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
|
||||||
|
<motion.path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8" variants={PATH_VARIANTS} transition={DEFAULT_TRANSITION} animate={controls}/>
|
||||||
|
</svg>
|
||||||
|
</div>);
|
||||||
|
});
|
||||||
|
HomeIcon.displayName = 'HomeIcon';
|
||||||
|
export { HomeIcon };
|
||||||
@@ -1,214 +1,156 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import {
|
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger, } from "@/components/ui/context-menu";
|
||||||
ContextMenu,
|
|
||||||
ContextMenuContent,
|
|
||||||
ContextMenuItem,
|
|
||||||
ContextMenuSeparator,
|
|
||||||
ContextMenuTrigger,
|
|
||||||
} from "@/components/ui/context-menu";
|
|
||||||
import { Scissors, Copy, Clipboard, Type } from "lucide-react";
|
import { Scissors, Copy, Clipboard, Type } from "lucide-react";
|
||||||
|
export interface InputWithContextProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||||
export interface InputWithContextProps
|
onValueChange?: (value: string) => void;
|
||||||
extends React.InputHTMLAttributes<HTMLInputElement> {
|
|
||||||
onValueChange?: (value: string) => void;
|
|
||||||
}
|
}
|
||||||
|
const InputWithContext = React.forwardRef<HTMLInputElement, InputWithContextProps>(({ className, type, onValueChange, onChange, ...props }, ref) => {
|
||||||
const InputWithContext = React.forwardRef<HTMLInputElement, InputWithContextProps>(
|
|
||||||
({ className, type, onValueChange, onChange, ...props }, ref) => {
|
|
||||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||||
const [hasSelection, setHasSelection] = React.useState(false);
|
const [hasSelection, setHasSelection] = React.useState(false);
|
||||||
const [canPaste, setCanPaste] = React.useState(false);
|
const [canPaste, setCanPaste] = React.useState(false);
|
||||||
|
|
||||||
// Combine refs
|
|
||||||
React.useImperativeHandle(ref, () => inputRef.current as HTMLInputElement);
|
React.useImperativeHandle(ref, () => inputRef.current as HTMLInputElement);
|
||||||
|
|
||||||
// Check selection state
|
|
||||||
const updateSelectionState = () => {
|
const updateSelectionState = () => {
|
||||||
const input = inputRef.current;
|
const input = inputRef.current;
|
||||||
if (!input) return;
|
if (!input)
|
||||||
const start = input.selectionStart ?? 0;
|
return;
|
||||||
const end = input.selectionEnd ?? 0;
|
|
||||||
setHasSelection(start !== end);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check clipboard permission
|
|
||||||
const checkClipboard = async () => {
|
|
||||||
try {
|
|
||||||
const text = await navigator.clipboard.readText();
|
|
||||||
setCanPaste(text.length > 0);
|
|
||||||
} catch {
|
|
||||||
setCanPaste(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
checkClipboard();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleCut = async () => {
|
|
||||||
const input = inputRef.current;
|
|
||||||
if (!input) return;
|
|
||||||
|
|
||||||
const start = input.selectionStart ?? 0;
|
|
||||||
const end = input.selectionEnd ?? 0;
|
|
||||||
const selectedText = input.value.substring(start, end);
|
|
||||||
|
|
||||||
if (selectedText) {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(selectedText);
|
|
||||||
const newValue = input.value.substring(0, start) + input.value.substring(end);
|
|
||||||
|
|
||||||
// Update value and trigger change
|
|
||||||
input.value = newValue;
|
|
||||||
input.setSelectionRange(start, start);
|
|
||||||
|
|
||||||
// Trigger React onChange
|
|
||||||
if (onChange) {
|
|
||||||
const event = {
|
|
||||||
target: input,
|
|
||||||
currentTarget: input,
|
|
||||||
} as React.ChangeEvent<HTMLInputElement>;
|
|
||||||
onChange(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (onValueChange) {
|
|
||||||
onValueChange(newValue);
|
|
||||||
}
|
|
||||||
|
|
||||||
input.focus();
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to cut:", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCopy = async () => {
|
|
||||||
const input = inputRef.current;
|
|
||||||
if (!input) return;
|
|
||||||
|
|
||||||
const start = input.selectionStart ?? 0;
|
|
||||||
const end = input.selectionEnd ?? 0;
|
|
||||||
const selectedText = input.value.substring(start, end);
|
|
||||||
|
|
||||||
if (selectedText) {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(selectedText);
|
|
||||||
input.focus();
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to copy:", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePaste = async () => {
|
|
||||||
const input = inputRef.current;
|
|
||||||
if (!input) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const text = await navigator.clipboard.readText();
|
|
||||||
const start = input.selectionStart ?? 0;
|
const start = input.selectionStart ?? 0;
|
||||||
const end = input.selectionEnd ?? 0;
|
const end = input.selectionEnd ?? 0;
|
||||||
|
setHasSelection(start !== end);
|
||||||
const newValue =
|
};
|
||||||
input.value.substring(0, start) + text + input.value.substring(end);
|
const checkClipboard = async () => {
|
||||||
|
try {
|
||||||
// Update value and trigger change
|
const text = await navigator.clipboard.readText();
|
||||||
input.value = newValue;
|
setCanPaste(text.length > 0);
|
||||||
const newPosition = start + text.length;
|
}
|
||||||
input.setSelectionRange(newPosition, newPosition);
|
catch {
|
||||||
|
setCanPaste(false);
|
||||||
// Trigger React onChange
|
}
|
||||||
if (onChange) {
|
};
|
||||||
const event = {
|
const handleCut = async () => {
|
||||||
target: input,
|
const input = inputRef.current;
|
||||||
currentTarget: input,
|
if (!input)
|
||||||
} as React.ChangeEvent<HTMLInputElement>;
|
return;
|
||||||
onChange(event);
|
const start = input.selectionStart ?? 0;
|
||||||
}
|
const end = input.selectionEnd ?? 0;
|
||||||
|
const selectedText = input.value.substring(start, end);
|
||||||
if (onValueChange) {
|
if (selectedText) {
|
||||||
onValueChange(newValue);
|
try {
|
||||||
}
|
await navigator.clipboard.writeText(selectedText);
|
||||||
|
const newValue = input.value.substring(0, start) + input.value.substring(end);
|
||||||
input.focus();
|
input.value = newValue;
|
||||||
await checkClipboard();
|
input.setSelectionRange(start, start);
|
||||||
} catch (err) {
|
if (onChange) {
|
||||||
console.error("Failed to paste:", err);
|
const event = {
|
||||||
}
|
target: input,
|
||||||
|
currentTarget: input,
|
||||||
|
} as React.ChangeEvent<HTMLInputElement>;
|
||||||
|
onChange(event);
|
||||||
|
}
|
||||||
|
if (onValueChange) {
|
||||||
|
onValueChange(newValue);
|
||||||
|
}
|
||||||
|
input.focus();
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.error("Failed to cut:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleCopy = async () => {
|
||||||
|
const input = inputRef.current;
|
||||||
|
if (!input)
|
||||||
|
return;
|
||||||
|
const start = input.selectionStart ?? 0;
|
||||||
|
const end = input.selectionEnd ?? 0;
|
||||||
|
const selectedText = input.value.substring(start, end);
|
||||||
|
if (selectedText) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(selectedText);
|
||||||
|
input.focus();
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.error("Failed to copy:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handlePaste = async () => {
|
||||||
|
const input = inputRef.current;
|
||||||
|
if (!input)
|
||||||
|
return;
|
||||||
|
try {
|
||||||
|
const text = await navigator.clipboard.readText();
|
||||||
|
const start = input.selectionStart ?? 0;
|
||||||
|
const end = input.selectionEnd ?? 0;
|
||||||
|
const newValue = input.value.substring(0, start) + text + input.value.substring(end);
|
||||||
|
input.value = newValue;
|
||||||
|
const newPosition = start + text.length;
|
||||||
|
input.setSelectionRange(newPosition, newPosition);
|
||||||
|
if (onChange) {
|
||||||
|
const event = {
|
||||||
|
target: input,
|
||||||
|
currentTarget: input,
|
||||||
|
} as React.ChangeEvent<HTMLInputElement>;
|
||||||
|
onChange(event);
|
||||||
|
}
|
||||||
|
if (onValueChange) {
|
||||||
|
onValueChange(newValue);
|
||||||
|
}
|
||||||
|
input.focus();
|
||||||
|
await checkClipboard();
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.error("Failed to paste:", err);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectAll = () => {
|
const handleSelectAll = () => {
|
||||||
const input = inputRef.current;
|
const input = inputRef.current;
|
||||||
if (!input) return;
|
if (!input)
|
||||||
input.select();
|
return;
|
||||||
input.focus();
|
input.select();
|
||||||
updateSelectionState();
|
input.focus();
|
||||||
|
updateSelectionState();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
if (onChange) {
|
if (onChange) {
|
||||||
onChange(e);
|
onChange(e);
|
||||||
}
|
}
|
||||||
if (onValueChange) {
|
if (onValueChange) {
|
||||||
onValueChange(e.target.value);
|
onValueChange(e.target.value);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
return (<ContextMenu onOpenChange={(open) => {
|
||||||
return (
|
if (open) {
|
||||||
<ContextMenu onOpenChange={checkClipboard}>
|
checkClipboard();
|
||||||
|
}
|
||||||
|
}}>
|
||||||
<ContextMenuTrigger asChild>
|
<ContextMenuTrigger asChild>
|
||||||
<Input
|
<Input ref={inputRef} type={type} className={className} onChange={handleInputChange} onSelect={updateSelectionState} onMouseUp={updateSelectionState} onKeyUp={updateSelectionState} {...props}/>
|
||||||
ref={inputRef}
|
|
||||||
type={type}
|
|
||||||
className={className}
|
|
||||||
onChange={handleInputChange}
|
|
||||||
onSelect={updateSelectionState}
|
|
||||||
onMouseUp={updateSelectionState}
|
|
||||||
onKeyUp={updateSelectionState}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
</ContextMenuTrigger>
|
</ContextMenuTrigger>
|
||||||
<ContextMenuContent className="w-48">
|
<ContextMenuContent className="w-48">
|
||||||
<ContextMenuItem
|
<ContextMenuItem onSelect={handleCut} disabled={!hasSelection || props.disabled || props.readOnly}>
|
||||||
onSelect={handleCut}
|
<Scissors className="mr-2 h-4 w-4"/>
|
||||||
disabled={!hasSelection || props.disabled || props.readOnly}
|
|
||||||
>
|
|
||||||
<Scissors className="mr-2 h-4 w-4" />
|
|
||||||
Cut
|
Cut
|
||||||
<span className="ml-auto text-xs text-muted-foreground">Ctrl+X</span>
|
<span className="ml-auto text-xs text-muted-foreground">Ctrl+X</span>
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
<ContextMenuItem
|
<ContextMenuItem onSelect={handleCopy} disabled={!hasSelection || props.disabled}>
|
||||||
onSelect={handleCopy}
|
<Copy className="mr-2 h-4 w-4"/>
|
||||||
disabled={!hasSelection || props.disabled}
|
|
||||||
>
|
|
||||||
<Copy className="mr-2 h-4 w-4" />
|
|
||||||
Copy
|
Copy
|
||||||
<span className="ml-auto text-xs text-muted-foreground">Ctrl+C</span>
|
<span className="ml-auto text-xs text-muted-foreground">Ctrl+C</span>
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
<ContextMenuItem
|
<ContextMenuItem onSelect={handlePaste} disabled={!canPaste || props.disabled || props.readOnly}>
|
||||||
onSelect={handlePaste}
|
<Clipboard className="mr-2 h-4 w-4"/>
|
||||||
disabled={!canPaste || props.disabled || props.readOnly}
|
|
||||||
>
|
|
||||||
<Clipboard className="mr-2 h-4 w-4" />
|
|
||||||
Paste
|
Paste
|
||||||
<span className="ml-auto text-xs text-muted-foreground">Ctrl+V</span>
|
<span className="ml-auto text-xs text-muted-foreground">Ctrl+V</span>
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
<ContextMenuSeparator />
|
<ContextMenuSeparator />
|
||||||
<ContextMenuItem
|
<ContextMenuItem onSelect={handleSelectAll} disabled={!inputRef.current?.value || props.disabled}>
|
||||||
onSelect={handleSelectAll}
|
<Type className="mr-2 h-4 w-4"/>
|
||||||
disabled={!inputRef.current?.value || props.disabled}
|
|
||||||
>
|
|
||||||
<Type className="mr-2 h-4 w-4" />
|
|
||||||
Select All
|
Select All
|
||||||
<span className="ml-auto text-xs text-muted-foreground">Ctrl+A</span>
|
<span className="ml-auto text-xs text-muted-foreground">Ctrl+A</span>
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
</ContextMenuContent>
|
</ContextMenuContent>
|
||||||
</ContextMenu>
|
</ContextMenu>);
|
||||||
);
|
});
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
InputWithContext.displayName = "InputWithContext";
|
InputWithContext.displayName = "InputWithContext";
|
||||||
|
|
||||||
export { InputWithContext };
|
export { InputWithContext };
|
||||||
|
|||||||
@@ -1,21 +1,6 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||||
return (
|
return (<input type={type} data-slot="input" className={cn("file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", className)} {...props}/>);
|
||||||
<input
|
|
||||||
type={type}
|
|
||||||
data-slot="input"
|
|
||||||
className={cn(
|
|
||||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
|
||||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
|
||||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
export { Input };
|
||||||
export { Input }
|
|
||||||
|
|||||||
@@ -1,24 +1,8 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
import * as React from "react";
|
||||||
import * as React from "react"
|
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
import { cn } from "@/lib/utils";
|
||||||
|
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||||
import { cn } from "@/lib/utils"
|
return (<LabelPrimitive.Root data-slot="label" className={cn("flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50", className)} {...props}/>);
|
||||||
|
|
||||||
function Label({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
|
||||||
return (
|
|
||||||
<LabelPrimitive.Root
|
|
||||||
data-slot="label"
|
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
export { Label };
|
||||||
export { Label }
|
|
||||||
|
|||||||
@@ -1,127 +1,41 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import {
|
import { ChevronLeftIcon, ChevronRightIcon, MoreHorizontalIcon, } from "lucide-react";
|
||||||
ChevronLeftIcon,
|
import { cn } from "@/lib/utils";
|
||||||
ChevronRightIcon,
|
import { Button, buttonVariants } from "@/components/ui/button";
|
||||||
MoreHorizontalIcon,
|
|
||||||
} from "lucide-react"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
|
||||||
|
|
||||||
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
|
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
|
||||||
return (
|
return (<nav role="navigation" aria-label="pagination" data-slot="pagination" className={cn("mx-auto flex w-full justify-center", className)} {...props}/>);
|
||||||
<nav
|
|
||||||
role="navigation"
|
|
||||||
aria-label="pagination"
|
|
||||||
data-slot="pagination"
|
|
||||||
className={cn("mx-auto flex w-full justify-center", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
function PaginationContent({ className, ...props }: React.ComponentProps<"ul">) {
|
||||||
function PaginationContent({
|
return (<ul data-slot="pagination-content" className={cn("flex flex-row items-center gap-1", className)} {...props}/>);
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"ul">) {
|
|
||||||
return (
|
|
||||||
<ul
|
|
||||||
data-slot="pagination-content"
|
|
||||||
className={cn("flex flex-row items-center gap-1", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
|
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
|
||||||
return <li data-slot="pagination-item" {...props} />
|
return <li data-slot="pagination-item" {...props}/>;
|
||||||
}
|
}
|
||||||
|
|
||||||
type PaginationLinkProps = {
|
type PaginationLinkProps = {
|
||||||
isActive?: boolean
|
isActive?: boolean;
|
||||||
} & Pick<React.ComponentProps<typeof Button>, "size"> &
|
} & Pick<React.ComponentProps<typeof Button>, "size"> & React.ComponentProps<"a">;
|
||||||
React.ComponentProps<"a">
|
function PaginationLink({ className, isActive, size = "icon", ...props }: PaginationLinkProps) {
|
||||||
|
return (<a aria-current={isActive ? "page" : undefined} data-slot="pagination-link" data-active={isActive} className={cn(buttonVariants({
|
||||||
function PaginationLink({
|
variant: isActive ? "outline" : "ghost",
|
||||||
className,
|
size,
|
||||||
isActive,
|
}), className)} {...props}/>);
|
||||||
size = "icon",
|
|
||||||
...props
|
|
||||||
}: PaginationLinkProps) {
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
aria-current={isActive ? "page" : undefined}
|
|
||||||
data-slot="pagination-link"
|
|
||||||
data-active={isActive}
|
|
||||||
className={cn(
|
|
||||||
buttonVariants({
|
|
||||||
variant: isActive ? "outline" : "ghost",
|
|
||||||
size,
|
|
||||||
}),
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
function PaginationPrevious({ className, ...props }: React.ComponentProps<typeof PaginationLink>) {
|
||||||
function PaginationPrevious({
|
return (<PaginationLink aria-label="Go to previous page" size="default" className={cn("gap-1 px-2.5 sm:pl-2.5", className)} {...props}>
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof PaginationLink>) {
|
|
||||||
return (
|
|
||||||
<PaginationLink
|
|
||||||
aria-label="Go to previous page"
|
|
||||||
size="default"
|
|
||||||
className={cn("gap-1 px-2.5 sm:pl-2.5", className)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<ChevronLeftIcon />
|
<ChevronLeftIcon />
|
||||||
<span className="hidden sm:block">Previous</span>
|
<span className="hidden sm:block">Previous</span>
|
||||||
</PaginationLink>
|
</PaginationLink>);
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
function PaginationNext({ className, ...props }: React.ComponentProps<typeof PaginationLink>) {
|
||||||
function PaginationNext({
|
return (<PaginationLink aria-label="Go to next page" size="default" className={cn("gap-1 px-2.5 sm:pr-2.5", className)} {...props}>
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof PaginationLink>) {
|
|
||||||
return (
|
|
||||||
<PaginationLink
|
|
||||||
aria-label="Go to next page"
|
|
||||||
size="default"
|
|
||||||
className={cn("gap-1 px-2.5 sm:pr-2.5", className)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<span className="hidden sm:block">Next</span>
|
<span className="hidden sm:block">Next</span>
|
||||||
<ChevronRightIcon />
|
<ChevronRightIcon />
|
||||||
</PaginationLink>
|
</PaginationLink>);
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
function PaginationEllipsis({ className, ...props }: React.ComponentProps<"span">) {
|
||||||
function PaginationEllipsis({
|
return (<span aria-hidden data-slot="pagination-ellipsis" className={cn("flex size-9 items-center justify-center", className)} {...props}>
|
||||||
className,
|
<MoreHorizontalIcon className="size-4"/>
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"span">) {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
aria-hidden
|
|
||||||
data-slot="pagination-ellipsis"
|
|
||||||
className={cn("flex size-9 items-center justify-center", className)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<MoreHorizontalIcon className="size-4" />
|
|
||||||
<span className="sr-only">More pages</span>
|
<span className="sr-only">More pages</span>
|
||||||
</span>
|
</span>);
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
|
||||||
Pagination,
|
|
||||||
PaginationContent,
|
|
||||||
PaginationLink,
|
|
||||||
PaginationItem,
|
|
||||||
PaginationPrevious,
|
|
||||||
PaginationNext,
|
|
||||||
PaginationEllipsis,
|
|
||||||
}
|
}
|
||||||
|
export { Pagination, PaginationContent, PaginationLink, PaginationItem, PaginationPrevious, PaginationNext, PaginationEllipsis, };
|
||||||
|
|||||||
@@ -1,31 +1,10 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
import * as React from "react";
|
||||||
import * as React from "react"
|
import * as ProgressPrimitive from "@radix-ui/react-progress";
|
||||||
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
import { cn } from "@/lib/utils";
|
||||||
|
function Progress({ className, value, ...props }: React.ComponentProps<typeof ProgressPrimitive.Root>) {
|
||||||
import { cn } from "@/lib/utils"
|
return (<ProgressPrimitive.Root data-slot="progress" className={cn("bg-primary/20 relative h-2 w-full overflow-hidden rounded-full", className)} {...props}>
|
||||||
|
<ProgressPrimitive.Indicator data-slot="progress-indicator" className="bg-primary h-full w-full flex-1 transition-all" style={{ transform: `translateX(-${100 - (value || 0)}%)` }}/>
|
||||||
function Progress({
|
</ProgressPrimitive.Root>);
|
||||||
className,
|
|
||||||
value,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
|
|
||||||
return (
|
|
||||||
<ProgressPrimitive.Root
|
|
||||||
data-slot="progress"
|
|
||||||
className={cn(
|
|
||||||
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<ProgressPrimitive.Indicator
|
|
||||||
data-slot="progress-indicator"
|
|
||||||
className="bg-primary h-full w-full flex-1 transition-all"
|
|
||||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
|
||||||
/>
|
|
||||||
</ProgressPrimitive.Root>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
export { Progress };
|
||||||
export { Progress }
|
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
|
|
||||||
import { CircleIcon } from "lucide-react"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
function RadioGroup({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
|
|
||||||
return (
|
|
||||||
<RadioGroupPrimitive.Root
|
|
||||||
data-slot="radio-group"
|
|
||||||
className={cn("grid gap-3", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function RadioGroupItem({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
|
|
||||||
return (
|
|
||||||
<RadioGroupPrimitive.Item
|
|
||||||
data-slot="radio-group-item"
|
|
||||||
className={cn(
|
|
||||||
"border-input text-primary 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 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<RadioGroupPrimitive.Indicator
|
|
||||||
data-slot="radio-group-indicator"
|
|
||||||
className="relative flex items-center justify-center"
|
|
||||||
>
|
|
||||||
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
|
|
||||||
</RadioGroupPrimitive.Indicator>
|
|
||||||
</RadioGroupPrimitive.Item>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { RadioGroup, RadioGroupItem }
|
|
||||||
@@ -1,185 +1,63 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
import { cn } from "@/lib/utils"
|
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||||
|
return <SelectPrimitive.Root data-slot="select" {...props}/>;
|
||||||
function Select({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
|
||||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
|
||||||
}
|
}
|
||||||
|
function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||||
function SelectGroup({
|
return <SelectPrimitive.Group data-slot="select-group" {...props}/>;
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
|
||||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
|
||||||
}
|
}
|
||||||
|
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||||
function SelectValue({
|
return <SelectPrimitive.Value data-slot="select-value" {...props}/>;
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
|
||||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
|
||||||
}
|
}
|
||||||
|
function SelectTrigger({ className, size = "default", children, ...props }: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||||
function SelectTrigger({
|
size?: "sm" | "default";
|
||||||
className,
|
|
||||||
size = "default",
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
|
||||||
size?: "sm" | "default"
|
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (<SelectPrimitive.Trigger data-slot="select-trigger" data-size={size} className={cn("border-input data-placeholder:text-muted-foreground [&_svg:not([class*='text-'])]: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 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className)} {...props}>
|
||||||
<SelectPrimitive.Trigger
|
|
||||||
data-slot="select-trigger"
|
|
||||||
data-size={size}
|
|
||||||
className={cn(
|
|
||||||
"border-input data-placeholder:text-muted-foreground [&_svg:not([class*='text-'])]: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 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
<SelectPrimitive.Icon asChild>
|
<SelectPrimitive.Icon asChild>
|
||||||
<ChevronDownIcon className="size-4 opacity-50" />
|
<ChevronDownIcon className="size-4 opacity-50"/>
|
||||||
</SelectPrimitive.Icon>
|
</SelectPrimitive.Icon>
|
||||||
</SelectPrimitive.Trigger>
|
</SelectPrimitive.Trigger>);
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
function SelectContent({ className, children, position = "popper", align = "center", ...props }: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||||
function SelectContent({
|
return (<SelectPrimitive.Portal>
|
||||||
className,
|
<SelectPrimitive.Content data-slot="select-content" className={cn("bg-popover text-popover-foreground 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 relative z-50 max-h-(--radix-select-content-available-height) min-w-32 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md", position === "popper" &&
|
||||||
children,
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", className)} position={position} align={align} {...props}>
|
||||||
position = "popper",
|
|
||||||
align = "center",
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
|
||||||
return (
|
|
||||||
<SelectPrimitive.Portal>
|
|
||||||
<SelectPrimitive.Content
|
|
||||||
data-slot="select-content"
|
|
||||||
className={cn(
|
|
||||||
"bg-popover text-popover-foreground 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 relative z-50 max-h-(--radix-select-content-available-height) min-w-32 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
|
||||||
position === "popper" &&
|
|
||||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
position={position}
|
|
||||||
align={align}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<SelectScrollUpButton />
|
<SelectScrollUpButton />
|
||||||
<SelectPrimitive.Viewport
|
<SelectPrimitive.Viewport className={cn("p-1", position === "popper" &&
|
||||||
className={cn(
|
"h-(--radix-select-trigger-height) w-full min-w-(--radix-select-trigger-width) scroll-my-1")}>
|
||||||
"p-1",
|
|
||||||
position === "popper" &&
|
|
||||||
"h-(--radix-select-trigger-height) w-full min-w-(--radix-select-trigger-width) scroll-my-1"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</SelectPrimitive.Viewport>
|
</SelectPrimitive.Viewport>
|
||||||
<SelectScrollDownButton />
|
<SelectScrollDownButton />
|
||||||
</SelectPrimitive.Content>
|
</SelectPrimitive.Content>
|
||||||
</SelectPrimitive.Portal>
|
</SelectPrimitive.Portal>);
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||||
function SelectLabel({
|
return (<SelectPrimitive.Label data-slot="select-label" className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)} {...props}/>);
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
|
||||||
return (
|
|
||||||
<SelectPrimitive.Label
|
|
||||||
data-slot="select-label"
|
|
||||||
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
function SelectItem({ className, children, ...props }: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||||
function SelectItem({
|
return (<SelectPrimitive.Item data-slot="select-item" className={cn("focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2", className)} {...props}>
|
||||||
className,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
|
||||||
return (
|
|
||||||
<SelectPrimitive.Item
|
|
||||||
data-slot="select-item"
|
|
||||||
className={cn(
|
|
||||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||||
<SelectPrimitive.ItemIndicator>
|
<SelectPrimitive.ItemIndicator>
|
||||||
<CheckIcon className="size-4" />
|
<CheckIcon className="size-4"/>
|
||||||
</SelectPrimitive.ItemIndicator>
|
</SelectPrimitive.ItemIndicator>
|
||||||
</span>
|
</span>
|
||||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
</SelectPrimitive.Item>
|
</SelectPrimitive.Item>);
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
function SelectSeparator({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||||
function SelectSeparator({
|
return (<SelectPrimitive.Separator data-slot="select-separator" className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)} {...props}/>);
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
|
||||||
return (
|
|
||||||
<SelectPrimitive.Separator
|
|
||||||
data-slot="select-separator"
|
|
||||||
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
function SelectScrollUpButton({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||||
function SelectScrollUpButton({
|
return (<SelectPrimitive.ScrollUpButton data-slot="select-scroll-up-button" className={cn("flex cursor-default items-center justify-center py-1", className)} {...props}>
|
||||||
className,
|
<ChevronUpIcon className="size-4"/>
|
||||||
...props
|
</SelectPrimitive.ScrollUpButton>);
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
|
||||||
return (
|
|
||||||
<SelectPrimitive.ScrollUpButton
|
|
||||||
data-slot="select-scroll-up-button"
|
|
||||||
className={cn(
|
|
||||||
"flex cursor-default items-center justify-center py-1",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<ChevronUpIcon className="size-4" />
|
|
||||||
</SelectPrimitive.ScrollUpButton>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
function SelectScrollDownButton({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||||
function SelectScrollDownButton({
|
return (<SelectPrimitive.ScrollDownButton data-slot="select-scroll-down-button" className={cn("flex cursor-default items-center justify-center py-1", className)} {...props}>
|
||||||
className,
|
<ChevronDownIcon className="size-4"/>
|
||||||
...props
|
</SelectPrimitive.ScrollDownButton>);
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
|
||||||
return (
|
|
||||||
<SelectPrimitive.ScrollDownButton
|
|
||||||
data-slot="select-scroll-down-button"
|
|
||||||
className={cn(
|
|
||||||
"flex cursor-default items-center justify-center py-1",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<ChevronDownIcon className="size-4" />
|
|
||||||
</SelectPrimitive.ScrollDownButton>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectGroup,
|
|
||||||
SelectItem,
|
|
||||||
SelectLabel,
|
|
||||||
SelectScrollDownButton,
|
|
||||||
SelectScrollUpButton,
|
|
||||||
SelectSeparator,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
}
|
}
|
||||||
|
export { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectScrollDownButton, SelectScrollUpButton, SelectSeparator, SelectTrigger, SelectValue, };
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
'use client';
|
||||||
|
import type { HTMLAttributes } from 'react';
|
||||||
|
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
|
||||||
|
import { motion, useAnimation } from 'motion/react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
export interface SettingsIconHandle {
|
||||||
|
startAnimation: () => void;
|
||||||
|
stopAnimation: () => void;
|
||||||
|
}
|
||||||
|
interface SettingsIconProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
const SettingsIcon = forwardRef<SettingsIconHandle, SettingsIconProps>(({ 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) {
|
||||||
|
controls.start('animate');
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
onMouseEnter?.(e);
|
||||||
|
}
|
||||||
|
}, [controls, onMouseEnter]);
|
||||||
|
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (!isControlledRef.current) {
|
||||||
|
controls.start('normal');
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
onMouseLeave?.(e);
|
||||||
|
}
|
||||||
|
}, [controls, onMouseLeave]);
|
||||||
|
return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
|
||||||
|
<motion.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" transition={{ type: 'spring', stiffness: 50, damping: 10 }} variants={{
|
||||||
|
normal: {
|
||||||
|
rotate: 0,
|
||||||
|
},
|
||||||
|
animate: {
|
||||||
|
rotate: 180,
|
||||||
|
},
|
||||||
|
}} animate={controls}>
|
||||||
|
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/>
|
||||||
|
<circle cx="12" cy="12" r="3"/>
|
||||||
|
</motion.svg>
|
||||||
|
</div>);
|
||||||
|
});
|
||||||
|
SettingsIcon.displayName = 'SettingsIcon';
|
||||||
|
export { SettingsIcon };
|
||||||
@@ -1,46 +1,27 @@
|
|||||||
import {
|
import { CircleCheckIcon, InfoIcon, Loader2Icon, OctagonXIcon, TriangleAlertIcon, } from "lucide-react";
|
||||||
CircleCheckIcon,
|
import { useTheme } from "next-themes";
|
||||||
InfoIcon,
|
import { Toaster as Sonner, type ToasterProps } from "sonner";
|
||||||
Loader2Icon,
|
|
||||||
OctagonXIcon,
|
|
||||||
TriangleAlertIcon,
|
|
||||||
} from "lucide-react"
|
|
||||||
import { useTheme } from "next-themes"
|
|
||||||
import { Toaster as Sonner, type ToasterProps } from "sonner"
|
|
||||||
|
|
||||||
const Toaster = ({ ...props }: ToasterProps) => {
|
const Toaster = ({ ...props }: ToasterProps) => {
|
||||||
const { theme = "system" } = useTheme()
|
const { theme = "system" } = useTheme();
|
||||||
|
return (<Sonner theme={theme as ToasterProps["theme"]} className="toaster group" icons={{
|
||||||
return (
|
success: <CircleCheckIcon className="size-4"/>,
|
||||||
<Sonner
|
info: <InfoIcon className="size-4"/>,
|
||||||
theme={theme as ToasterProps["theme"]}
|
warning: <TriangleAlertIcon className="size-4"/>,
|
||||||
className="toaster group"
|
error: <OctagonXIcon className="size-4"/>,
|
||||||
icons={{
|
loading: <Loader2Icon className="size-4 animate-spin"/>,
|
||||||
success: <CircleCheckIcon className="size-4" />,
|
}} toastOptions={{
|
||||||
info: <InfoIcon className="size-4" />,
|
classNames: {
|
||||||
warning: <TriangleAlertIcon className="size-4" />,
|
success: "border-green-500 bg-green-50 text-green-900 dark:bg-green-950 dark:text-green-100 [&>svg]:text-green-500",
|
||||||
error: <OctagonXIcon className="size-4" />,
|
error: "border-red-500 bg-red-50 text-red-900 dark:bg-red-950 dark:text-red-100 [&>svg]:text-red-500",
|
||||||
loading: <Loader2Icon className="size-4 animate-spin" />,
|
warning: "border-yellow-500 bg-yellow-50 text-yellow-900 dark:bg-yellow-950 dark:text-yellow-100 [&>svg]:text-yellow-500",
|
||||||
}}
|
info: "border-blue-500 bg-blue-50 text-blue-900 dark:bg-blue-950 dark:text-blue-100 [&>svg]:text-blue-500",
|
||||||
toastOptions={{
|
},
|
||||||
classNames: {
|
}} style={{
|
||||||
success: "border-green-500 bg-green-50 text-green-900 dark:bg-green-950 dark:text-green-100 [&>svg]:text-green-500",
|
"--normal-bg": "var(--popover)",
|
||||||
error: "border-red-500 bg-red-50 text-red-900 dark:bg-red-950 dark:text-red-100 [&>svg]:text-red-500",
|
"--normal-text": "var(--popover-foreground)",
|
||||||
warning: "border-yellow-500 bg-yellow-50 text-yellow-900 dark:bg-yellow-950 dark:text-yellow-100 [&>svg]:text-yellow-500",
|
"--normal-border": "var(--border)",
|
||||||
info: "border-blue-500 bg-blue-50 text-blue-900 dark:bg-blue-950 dark:text-blue-100 [&>svg]:text-blue-500",
|
"--border-radius": "var(--radius)",
|
||||||
},
|
left: "calc(56px + 1rem)",
|
||||||
}}
|
} as React.CSSProperties} {...props}/>);
|
||||||
style={
|
};
|
||||||
{
|
export { Toaster };
|
||||||
"--normal-bg": "var(--popover)",
|
|
||||||
"--normal-text": "var(--popover-foreground)",
|
|
||||||
"--normal-border": "var(--border)",
|
|
||||||
"--border-radius": "var(--radius)",
|
|
||||||
} as React.CSSProperties
|
|
||||||
}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Toaster }
|
|
||||||
|
|||||||
@@ -1,15 +1,6 @@
|
|||||||
import { Loader2 } from "lucide-react"
|
import { Loader2 } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
|
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
|
||||||
return (
|
return (<Loader2 role="status" aria-label="Loading" className={cn("size-4 animate-spin", className)} {...props}/>);
|
||||||
<Loader2
|
|
||||||
role="status"
|
|
||||||
aria-label="Loading"
|
|
||||||
className={cn("size-4 animate-spin", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
export { Spinner };
|
||||||
export { Spinner }
|
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
"use client";
|
||||||
|
import * as React from "react";
|
||||||
|
import * as SwitchPrimitive from "@radix-ui/react-switch";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
function Switch({ className, ...props }: React.ComponentProps<typeof SwitchPrimitive.Root>) {
|
||||||
|
return (<SwitchPrimitive.Root data-slot="switch" className={cn("peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50", className)} {...props}>
|
||||||
|
<SwitchPrimitive.Thumb data-slot="switch-thumb" className={cn("pointer-events-none block size-4 rounded-full shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0 data-[state=checked]:bg-primary-foreground data-[state=unchecked]:bg-background")}/>
|
||||||
|
</SwitchPrimitive.Root>);
|
||||||
|
}
|
||||||
|
export { Switch };
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
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(
|
|
||||||
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
||||||
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 }
|
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
'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';
|
||||||
|
export interface TerminalIconHandle {
|
||||||
|
startAnimation: () => void;
|
||||||
|
stopAnimation: () => void;
|
||||||
|
}
|
||||||
|
interface TerminalIconProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
const LINE_VARIANTS: Variants = {
|
||||||
|
normal: { opacity: 1 },
|
||||||
|
animate: {
|
||||||
|
opacity: [1, 0, 1],
|
||||||
|
transition: {
|
||||||
|
duration: 0.8,
|
||||||
|
repeat: Infinity,
|
||||||
|
ease: 'linear',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const TerminalIcon = forwardRef<TerminalIconHandle, TerminalIconProps>(({ 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) {
|
||||||
|
controls.start('animate');
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
onMouseEnter?.(e);
|
||||||
|
}
|
||||||
|
}, [controls, onMouseEnter]);
|
||||||
|
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (!isControlledRef.current) {
|
||||||
|
controls.start('normal');
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
onMouseLeave?.(e);
|
||||||
|
}
|
||||||
|
}, [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"/>
|
||||||
|
</svg>
|
||||||
|
</div>);
|
||||||
|
});
|
||||||
|
TerminalIcon.displayName = 'TerminalIcon';
|
||||||
|
export { TerminalIcon };
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"use client";
|
||||||
|
import * as React from "react";
|
||||||
|
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group";
|
||||||
|
import { type VariantProps } from "class-variance-authority";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { toggleVariants } from "@/components/ui/toggle";
|
||||||
|
const ToggleGroupContext = React.createContext<VariantProps<typeof toggleVariants> & {
|
||||||
|
spacing?: number;
|
||||||
|
}>({
|
||||||
|
size: "default",
|
||||||
|
variant: "default",
|
||||||
|
spacing: 0,
|
||||||
|
});
|
||||||
|
function ToggleGroup({ className, variant, size, spacing = 0, children, ...props }: React.ComponentProps<typeof ToggleGroupPrimitive.Root> & VariantProps<typeof toggleVariants> & {
|
||||||
|
spacing?: number;
|
||||||
|
}) {
|
||||||
|
return (<ToggleGroupPrimitive.Root data-slot="toggle-group" data-variant={variant} data-size={size} data-spacing={spacing} style={{ "--gap": spacing } as React.CSSProperties} className={cn("group/toggle-group flex w-fit items-center gap-[--spacing(var(--gap))] rounded-md data-[spacing=default]:data-[variant=outline]:shadow-xs", className)} {...props}>
|
||||||
|
<ToggleGroupContext.Provider value={{ variant, size, spacing }}>
|
||||||
|
{children}
|
||||||
|
</ToggleGroupContext.Provider>
|
||||||
|
</ToggleGroupPrimitive.Root>);
|
||||||
|
}
|
||||||
|
function ToggleGroupItem({ className, children, variant, size, ...props }: React.ComponentProps<typeof ToggleGroupPrimitive.Item> & VariantProps<typeof toggleVariants>) {
|
||||||
|
const context = React.useContext(ToggleGroupContext);
|
||||||
|
return (<ToggleGroupPrimitive.Item data-slot="toggle-group-item" data-variant={context.variant || variant} data-size={context.size || size} data-spacing={context.spacing} className={cn(toggleVariants({
|
||||||
|
variant: context.variant || variant,
|
||||||
|
size: context.size || size,
|
||||||
|
}), "w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10", "data-[spacing=0]:rounded-none data-[spacing=0]:shadow-none data-[spacing=0]:first:rounded-l-md data-[spacing=0]:last:rounded-r-md data-[spacing=0]:data-[variant=outline]:border-l-0 data-[spacing=0]:data-[variant=outline]:first:border-l", className)} {...props}>
|
||||||
|
{children}
|
||||||
|
</ToggleGroupPrimitive.Item>);
|
||||||
|
}
|
||||||
|
export { ToggleGroup, ToggleGroupItem };
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"use client";
|
||||||
|
import * as React from "react";
|
||||||
|
import * as TogglePrimitive from "@radix-ui/react-toggle";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
const toggleVariants = cva("inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap", {
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-transparent",
|
||||||
|
outline: "border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 px-2 min-w-9",
|
||||||
|
sm: "h-8 px-1.5 min-w-8",
|
||||||
|
lg: "h-10 px-2.5 min-w-10",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
function Toggle({ className, variant, size, ...props }: React.ComponentProps<typeof TogglePrimitive.Root> & VariantProps<typeof toggleVariants>) {
|
||||||
|
return (<TogglePrimitive.Root data-slot="toggle" className={cn(toggleVariants({ variant, size, className }))} {...props}/>);
|
||||||
|
}
|
||||||
|
export { Toggle, toggleVariants };
|
||||||
@@ -1,61 +1,24 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
import * as React from "react";
|
||||||
import * as React from "react"
|
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
import { cn } from "@/lib/utils";
|
||||||
|
function TooltipProvider({ delayDuration = 0, ...props }: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||||
import { cn } from "@/lib/utils"
|
return (<TooltipPrimitive.Provider data-slot="tooltip-provider" delayDuration={delayDuration} {...props}/>);
|
||||||
|
|
||||||
function TooltipProvider({
|
|
||||||
delayDuration = 0,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
|
||||||
return (
|
|
||||||
<TooltipPrimitive.Provider
|
|
||||||
data-slot="tooltip-provider"
|
|
||||||
delayDuration={delayDuration}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||||
function Tooltip({
|
return (<TooltipProvider>
|
||||||
...props
|
<TooltipPrimitive.Root data-slot="tooltip" {...props}/>
|
||||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
</TooltipProvider>);
|
||||||
return (
|
|
||||||
<TooltipProvider>
|
|
||||||
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
|
||||||
</TooltipProvider>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||||
function TooltipTrigger({
|
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props}/>;
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
|
||||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
|
||||||
}
|
}
|
||||||
|
function TooltipContent({ className, sideOffset = 0, children, ...props }: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||||
function TooltipContent({
|
return (<TooltipPrimitive.Portal>
|
||||||
className,
|
<TooltipPrimitive.Content data-slot="tooltip-content" sideOffset={sideOffset} className={cn("bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance", className)} {...props}>
|
||||||
sideOffset = 0,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
|
||||||
return (
|
|
||||||
<TooltipPrimitive.Portal>
|
|
||||||
<TooltipPrimitive.Content
|
|
||||||
data-slot="tooltip-content"
|
|
||||||
sideOffset={sideOffset}
|
|
||||||
className={cn(
|
|
||||||
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px]" />
|
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px]"/>
|
||||||
</TooltipPrimitive.Content>
|
</TooltipPrimitive.Content>
|
||||||
</TooltipPrimitive.Portal>
|
</TooltipPrimitive.Portal>);
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
|
||||||
|
|||||||
@@ -1,60 +1,147 @@
|
|||||||
import { useState, useCallback } from "react";
|
import { useState, useCallback, useEffect } from "react";
|
||||||
import { AnalyzeTrack } from "../../wailsjs/go/main/App";
|
import { AnalyzeTrack } from "../../wailsjs/go/main/App";
|
||||||
import type { AnalysisResult } from "@/types/api";
|
import type { AnalysisResult } from "@/types/api";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||||
|
import { setSpectrumCache, getSpectrumCache, clearSpectrumCache } from "@/lib/spectrum-cache";
|
||||||
|
const STORAGE_KEY = "spotiflac_audio_analysis_state";
|
||||||
export function useAudioAnalysis() {
|
export function useAudioAnalysis() {
|
||||||
const [analyzing, setAnalyzing] = useState(false);
|
const [analyzing, setAnalyzing] = useState(false);
|
||||||
const [result, setResult] = useState<AnalysisResult | null>(null);
|
const [result, setResult] = useState<AnalysisResult | null>(() => {
|
||||||
const [error, setError] = useState<string | null>(null);
|
try {
|
||||||
|
const saved = sessionStorage.getItem(STORAGE_KEY);
|
||||||
const analyzeFile = useCallback(async (filePath: string) => {
|
if (saved) {
|
||||||
if (!filePath) {
|
const parsed = JSON.parse(saved);
|
||||||
setError("No file path provided");
|
if (parsed.filePath && parsed.result) {
|
||||||
return null;
|
return {
|
||||||
}
|
...parsed.result,
|
||||||
|
spectrum: undefined,
|
||||||
setAnalyzing(true);
|
};
|
||||||
setError(null);
|
}
|
||||||
setResult(null);
|
}
|
||||||
|
}
|
||||||
try {
|
catch (err) {
|
||||||
logger.info(`Analyzing audio file: ${filePath}`);
|
console.error("Failed to load saved analysis state:", err);
|
||||||
const startTime = Date.now();
|
}
|
||||||
|
return null;
|
||||||
const response = await AnalyzeTrack(filePath);
|
});
|
||||||
const analysisResult: AnalysisResult = JSON.parse(response);
|
const [selectedFilePath, setSelectedFilePath] = useState<string>(() => {
|
||||||
|
try {
|
||||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(2);
|
const saved = sessionStorage.getItem(STORAGE_KEY);
|
||||||
logger.success(`Audio analysis completed in ${elapsed}s`);
|
if (saved) {
|
||||||
|
const parsed = JSON.parse(saved);
|
||||||
setResult(analysisResult);
|
return parsed.filePath || "";
|
||||||
|
}
|
||||||
return analysisResult;
|
}
|
||||||
} catch (err) {
|
catch (err) {
|
||||||
const errorMessage = err instanceof Error ? err.message : "Failed to analyze audio file";
|
}
|
||||||
logger.error(`Analysis error: ${errorMessage}`);
|
return "";
|
||||||
setError(errorMessage);
|
});
|
||||||
toast.error("Audio Analysis Failed", {
|
const [error, setError] = useState<string | null>(null);
|
||||||
description: errorMessage,
|
const [spectrumLoading, setSpectrumLoading] = useState(() => {
|
||||||
});
|
try {
|
||||||
return null;
|
const saved = sessionStorage.getItem(STORAGE_KEY);
|
||||||
} finally {
|
if (saved) {
|
||||||
setAnalyzing(false);
|
const parsed = JSON.parse(saved);
|
||||||
}
|
if (parsed.filePath && parsed.result) {
|
||||||
}, []);
|
return true;
|
||||||
|
}
|
||||||
const clearResult = useCallback(() => {
|
}
|
||||||
setResult(null);
|
}
|
||||||
setError(null);
|
catch (err) {
|
||||||
}, []);
|
}
|
||||||
|
return false;
|
||||||
return {
|
});
|
||||||
analyzing,
|
const analyzeFile = useCallback(async (filePath: string) => {
|
||||||
result,
|
if (!filePath) {
|
||||||
error,
|
setError("No file path provided");
|
||||||
analyzeFile,
|
return null;
|
||||||
clearResult,
|
}
|
||||||
};
|
setAnalyzing(true);
|
||||||
|
setError(null);
|
||||||
|
setResult(null);
|
||||||
|
setSelectedFilePath(filePath);
|
||||||
|
try {
|
||||||
|
logger.info(`Analyzing audio file: ${filePath}`);
|
||||||
|
const startTime = Date.now();
|
||||||
|
const response = await AnalyzeTrack(filePath);
|
||||||
|
const analysisResult: AnalysisResult = JSON.parse(response);
|
||||||
|
const elapsed = ((Date.now() - startTime) / 1000).toFixed(2);
|
||||||
|
logger.success(`Audio analysis completed in ${elapsed}s`);
|
||||||
|
if (analysisResult.spectrum) {
|
||||||
|
setSpectrumCache(filePath, analysisResult.spectrum);
|
||||||
|
}
|
||||||
|
const { spectrum, ...detailResult } = analysisResult;
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(STORAGE_KEY, JSON.stringify({
|
||||||
|
filePath,
|
||||||
|
result: detailResult,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.error("Failed to save analysis state:", err);
|
||||||
|
}
|
||||||
|
setResult(analysisResult);
|
||||||
|
setSpectrumLoading(false);
|
||||||
|
return analysisResult;
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : "Failed to analyze audio file";
|
||||||
|
logger.error(`Analysis error: ${errorMessage}`);
|
||||||
|
setError(errorMessage);
|
||||||
|
toast.error("Audio Analysis Failed", {
|
||||||
|
description: errorMessage,
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
setAnalyzing(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
const clearResult = useCallback(() => {
|
||||||
|
setResult(null);
|
||||||
|
setError(null);
|
||||||
|
setSelectedFilePath("");
|
||||||
|
try {
|
||||||
|
sessionStorage.removeItem(STORAGE_KEY);
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
}
|
||||||
|
clearSpectrumCache();
|
||||||
|
}, []);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!result || !selectedFilePath || result.spectrum || !spectrumLoading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let rafId: number;
|
||||||
|
const loadSpectrum = () => {
|
||||||
|
rafId = requestAnimationFrame(() => {
|
||||||
|
const cachedSpectrum = getSpectrumCache(selectedFilePath);
|
||||||
|
if (cachedSpectrum) {
|
||||||
|
setResult(prev => prev ? { ...prev, spectrum: cachedSpectrum } : null);
|
||||||
|
setSpectrumLoading(false);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
setSpectrumLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(loadSpectrum);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
if (rafId) {
|
||||||
|
cancelAnimationFrame(rafId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [result, selectedFilePath, spectrumLoading]);
|
||||||
|
return {
|
||||||
|
analyzing,
|
||||||
|
result,
|
||||||
|
error,
|
||||||
|
selectedFilePath,
|
||||||
|
spectrumLoading,
|
||||||
|
analyzeFile,
|
||||||
|
clearResult,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { useState, useCallback } from "react";
|
||||||
|
import { CheckTrackAvailability } from "../../wailsjs/go/main/App";
|
||||||
|
import type { TrackAvailability } from "@/types/api";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
|
export function useAvailability() {
|
||||||
|
const [checking, setChecking] = useState(false);
|
||||||
|
const [checkingTrackId, setCheckingTrackId] = useState<string | null>(null);
|
||||||
|
const [availabilityMap, setAvailabilityMap] = useState<Map<string, TrackAvailability>>(new Map());
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const checkAvailability = useCallback(async (spotifyId: string, isrc?: string) => {
|
||||||
|
if (!spotifyId) {
|
||||||
|
setError("No Spotify ID provided");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (availabilityMap.has(spotifyId)) {
|
||||||
|
return availabilityMap.get(spotifyId)!;
|
||||||
|
}
|
||||||
|
setChecking(true);
|
||||||
|
setCheckingTrackId(spotifyId);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
logger.info(`Checking availability for track: ${spotifyId}`);
|
||||||
|
const response = await CheckTrackAvailability(spotifyId, isrc || "");
|
||||||
|
const availability: TrackAvailability = JSON.parse(response);
|
||||||
|
setAvailabilityMap((prev) => {
|
||||||
|
const newMap = new Map(prev);
|
||||||
|
newMap.set(spotifyId, availability);
|
||||||
|
return newMap;
|
||||||
|
});
|
||||||
|
logger.success(`Availability check completed for ${spotifyId}`);
|
||||||
|
return availability;
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : "Failed to check availability";
|
||||||
|
logger.error(`Availability check error: ${errorMessage}`);
|
||||||
|
setError(errorMessage);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
setChecking(false);
|
||||||
|
setCheckingTrackId(null);
|
||||||
|
}
|
||||||
|
}, [availabilityMap]);
|
||||||
|
const getAvailability = useCallback((spotifyId: string) => {
|
||||||
|
return availabilityMap.get(spotifyId);
|
||||||
|
}, [availabilityMap]);
|
||||||
|
const clearAvailability = useCallback(() => {
|
||||||
|
setAvailabilityMap(new Map());
|
||||||
|
setError(null);
|
||||||
|
}, []);
|
||||||
|
return {
|
||||||
|
checking,
|
||||||
|
checkingTrackId,
|
||||||
|
availabilityMap,
|
||||||
|
error,
|
||||||
|
checkAvailability,
|
||||||
|
getAvailability,
|
||||||
|
clearAvailability,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
import { useState, useRef } from "react";
|
||||||
|
import { downloadCover } from "@/lib/api";
|
||||||
|
import { getSettings, parseTemplate, type TemplateData } from "@/lib/settings";
|
||||||
|
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||||
|
import { joinPath, sanitizePath } from "@/lib/utils";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
|
import type { TrackMetadata } from "@/types/api";
|
||||||
|
export function useCover() {
|
||||||
|
const [downloadingCover, setDownloadingCover] = useState(false);
|
||||||
|
const [downloadingCoverTrack, setDownloadingCoverTrack] = useState<string | null>(null);
|
||||||
|
const [downloadedCovers, setDownloadedCovers] = useState<Set<string>>(new Set());
|
||||||
|
const [failedCovers, setFailedCovers] = useState<Set<string>>(new Set());
|
||||||
|
const [skippedCovers, setSkippedCovers] = useState<Set<string>>(new Set());
|
||||||
|
const [isBulkDownloadingCovers, setIsBulkDownloadingCovers] = useState(false);
|
||||||
|
const [coverDownloadProgress, setCoverDownloadProgress] = useState(0);
|
||||||
|
const stopBulkDownloadRef = useRef(false);
|
||||||
|
const handleDownloadCover = async (coverUrl: string, trackName: string, artistName: string, albumName?: string, playlistName?: string, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number, isAlbum?: boolean) => {
|
||||||
|
if (!coverUrl) {
|
||||||
|
toast.error("No cover URL found for this track");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const id = trackId || `${trackName}-${artistName}`;
|
||||||
|
logger.info(`downloading cover: ${trackName} - ${artistName}`);
|
||||||
|
const settings = getSettings();
|
||||||
|
setDownloadingCover(true);
|
||||||
|
setDownloadingCoverTrack(id);
|
||||||
|
try {
|
||||||
|
const os = settings.operatingSystem;
|
||||||
|
let outputDir = settings.downloadPath;
|
||||||
|
const placeholder = "__SLASH_PLACEHOLDER__";
|
||||||
|
const templateData: TemplateData = {
|
||||||
|
artist: artistName?.replace(/\//g, placeholder),
|
||||||
|
album: albumName?.replace(/\//g, placeholder),
|
||||||
|
title: trackName?.replace(/\//g, placeholder),
|
||||||
|
track: position,
|
||||||
|
playlist: playlistName?.replace(/\//g, placeholder),
|
||||||
|
};
|
||||||
|
if (playlistName && !isAlbum) {
|
||||||
|
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) {
|
||||||
|
const sanitizedPart = part.replace(new RegExp(placeholder, "g"), " ");
|
||||||
|
outputDir = joinPath(os, outputDir, sanitizePath(sanitizedPart, os));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const response = await downloadCover({
|
||||||
|
cover_url: coverUrl,
|
||||||
|
track_name: trackName,
|
||||||
|
artist_name: artistName,
|
||||||
|
album_name: albumName || "",
|
||||||
|
album_artist: albumArtist || "",
|
||||||
|
release_date: releaseDate || "",
|
||||||
|
output_dir: outputDir,
|
||||||
|
filename_format: settings.filenameTemplate || "{title}",
|
||||||
|
track_number: settings.trackNumber,
|
||||||
|
position: position || 0,
|
||||||
|
disc_number: discNumber || 0,
|
||||||
|
});
|
||||||
|
if (response.success) {
|
||||||
|
if (response.already_exists) {
|
||||||
|
toast.info("Cover file already exists");
|
||||||
|
setSkippedCovers((prev) => new Set(prev).add(id));
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
toast.success("Cover downloaded successfully");
|
||||||
|
setDownloadedCovers((prev) => new Set(prev).add(id));
|
||||||
|
}
|
||||||
|
setFailedCovers((prev) => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
newSet.delete(id);
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
toast.error(response.error || "Failed to download cover");
|
||||||
|
setFailedCovers((prev) => new Set(prev).add(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "Failed to download cover");
|
||||||
|
setFailedCovers((prev) => new Set(prev).add(id));
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
setDownloadingCover(false);
|
||||||
|
setDownloadingCoverTrack(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleDownloadAllCovers = async (tracks: TrackMetadata[], playlistName?: string, isAlbum?: boolean) => {
|
||||||
|
if (tracks.length === 0) {
|
||||||
|
toast.error("No tracks to download covers");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const settings = getSettings();
|
||||||
|
setIsBulkDownloadingCovers(true);
|
||||||
|
setCoverDownloadProgress(0);
|
||||||
|
stopBulkDownloadRef.current = false;
|
||||||
|
let completed = 0;
|
||||||
|
let success = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
let failed = 0;
|
||||||
|
for (let i = 0; i < tracks.length; i++) {
|
||||||
|
if (stopBulkDownloadRef.current) {
|
||||||
|
toast.info("Cover download stopped");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const track = tracks[i];
|
||||||
|
if (!track.images) {
|
||||||
|
completed++;
|
||||||
|
setCoverDownloadProgress(Math.round((completed / tracks.length) * 100));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const id = track.spotify_id || `${track.name}-${track.artists}`;
|
||||||
|
setDownloadingCoverTrack(id);
|
||||||
|
try {
|
||||||
|
const os = settings.operatingSystem;
|
||||||
|
let outputDir = settings.downloadPath;
|
||||||
|
const placeholder = "__SLASH_PLACEHOLDER__";
|
||||||
|
const useAlbumTrackNumber = settings.folderTemplate?.includes("{album}") || false;
|
||||||
|
const trackPosition = useAlbumTrackNumber ? (track.track_number || i + 1) : (i + 1);
|
||||||
|
const templateData: TemplateData = {
|
||||||
|
artist: track.artists?.replace(/\//g, placeholder),
|
||||||
|
album: track.album_name?.replace(/\//g, placeholder),
|
||||||
|
title: track.name?.replace(/\//g, placeholder),
|
||||||
|
track: trackPosition,
|
||||||
|
playlist: playlistName?.replace(/\//g, placeholder),
|
||||||
|
};
|
||||||
|
if (playlistName && !isAlbum) {
|
||||||
|
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) {
|
||||||
|
const sanitizedPart = part.replace(new RegExp(placeholder, "g"), " ");
|
||||||
|
outputDir = joinPath(os, outputDir, sanitizePath(sanitizedPart, os));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const response = await downloadCover({
|
||||||
|
cover_url: track.images,
|
||||||
|
track_name: track.name,
|
||||||
|
artist_name: track.artists,
|
||||||
|
album_name: track.album_name,
|
||||||
|
album_artist: track.album_artist,
|
||||||
|
release_date: track.release_date,
|
||||||
|
output_dir: outputDir,
|
||||||
|
filename_format: settings.filenameTemplate || "{title}",
|
||||||
|
track_number: settings.trackNumber,
|
||||||
|
position: trackPosition,
|
||||||
|
disc_number: track.disc_number,
|
||||||
|
});
|
||||||
|
if (response.success) {
|
||||||
|
if (response.already_exists) {
|
||||||
|
skipped++;
|
||||||
|
setSkippedCovers((prev) => new Set(prev).add(id));
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
success++;
|
||||||
|
setDownloadedCovers((prev) => new Set(prev).add(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
failed++;
|
||||||
|
setFailedCovers((prev) => new Set(prev).add(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
failed++;
|
||||||
|
setFailedCovers((prev) => new Set(prev).add(id));
|
||||||
|
}
|
||||||
|
completed++;
|
||||||
|
setCoverDownloadProgress(Math.round((completed / tracks.length) * 100));
|
||||||
|
}
|
||||||
|
setDownloadingCoverTrack(null);
|
||||||
|
setIsBulkDownloadingCovers(false);
|
||||||
|
setCoverDownloadProgress(0);
|
||||||
|
if (!stopBulkDownloadRef.current) {
|
||||||
|
toast.success(`Covers: ${success} downloaded, ${skipped} skipped, ${failed} failed`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleStopCoverDownload = () => {
|
||||||
|
stopBulkDownloadRef.current = true;
|
||||||
|
};
|
||||||
|
const resetCoverState = () => {
|
||||||
|
setDownloadedCovers(new Set());
|
||||||
|
setFailedCovers(new Set());
|
||||||
|
setSkippedCovers(new Set());
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
downloadingCover,
|
||||||
|
downloadingCoverTrack,
|
||||||
|
downloadedCovers,
|
||||||
|
failedCovers,
|
||||||
|
skippedCovers,
|
||||||
|
isBulkDownloadingCovers,
|
||||||
|
coverDownloadProgress,
|
||||||
|
handleDownloadCover,
|
||||||
|
handleDownloadAllCovers,
|
||||||
|
handleStopCoverDownload,
|
||||||
|
resetCoverState,
|
||||||
|
};
|
||||||
|
}
|
||||||
+873
-489
File diff suppressed because it is too large
Load Diff
@@ -1,44 +1,34 @@
|
|||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { GetDownloadProgress } from "../../wailsjs/go/main/App";
|
import { GetDownloadProgress } from "../../wailsjs/go/main/App";
|
||||||
|
|
||||||
export interface DownloadProgressInfo {
|
export interface DownloadProgressInfo {
|
||||||
is_downloading: boolean;
|
is_downloading: boolean;
|
||||||
mb_downloaded: number;
|
mb_downloaded: number;
|
||||||
speed_mbps: number;
|
speed_mbps: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useDownloadProgress() {
|
export function useDownloadProgress() {
|
||||||
const [progress, setProgress] = useState<DownloadProgressInfo>({
|
const [progress, setProgress] = useState<DownloadProgressInfo>({
|
||||||
is_downloading: false,
|
is_downloading: false,
|
||||||
mb_downloaded: 0,
|
mb_downloaded: 0,
|
||||||
speed_mbps: 0,
|
speed_mbps: 0,
|
||||||
});
|
});
|
||||||
const intervalRef = useRef<number | null>(null);
|
const intervalRef = useRef<number | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
useEffect(() => {
|
const pollProgress = async () => {
|
||||||
// Poll progress every 200ms for smooth updates
|
try {
|
||||||
const pollProgress = async () => {
|
const progressInfo = await GetDownloadProgress();
|
||||||
try {
|
setProgress(progressInfo);
|
||||||
const progressInfo = await GetDownloadProgress();
|
}
|
||||||
setProgress(progressInfo);
|
catch (error) {
|
||||||
} catch (error) {
|
console.error("Failed to get download progress:", error);
|
||||||
console.error("Failed to get download progress:", error);
|
}
|
||||||
}
|
};
|
||||||
};
|
intervalRef.current = window.setInterval(pollProgress, 200);
|
||||||
|
pollProgress();
|
||||||
// Start polling
|
return () => {
|
||||||
intervalRef.current = window.setInterval(pollProgress, 200);
|
if (intervalRef.current) {
|
||||||
|
clearInterval(intervalRef.current);
|
||||||
// Initial fetch
|
}
|
||||||
pollProgress();
|
};
|
||||||
|
}, []);
|
||||||
// Cleanup
|
return progress;
|
||||||
return () => {
|
|
||||||
if (intervalRef.current) {
|
|
||||||
clearInterval(intervalRef.current);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return progress;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { GetDownloadQueue } from "../../wailsjs/go/main/App";
|
||||||
|
import { backend } from "../../wailsjs/go/models";
|
||||||
|
export function useDownloadQueueData() {
|
||||||
|
const [queueInfo, setQueueInfo] = useState<backend.DownloadQueueInfo>(new backend.DownloadQueueInfo({
|
||||||
|
is_downloading: false,
|
||||||
|
queue: [],
|
||||||
|
current_speed: 0,
|
||||||
|
total_downloaded: 0,
|
||||||
|
session_start_time: 0,
|
||||||
|
queued_count: 0,
|
||||||
|
completed_count: 0,
|
||||||
|
failed_count: 0,
|
||||||
|
skipped_count: 0,
|
||||||
|
}));
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchQueue = async () => {
|
||||||
|
try {
|
||||||
|
const info = await GetDownloadQueue();
|
||||||
|
setQueueInfo(info);
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error("Failed to get download queue:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchQueue();
|
||||||
|
const interval = setInterval(fetchQueue, 200);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
return queueInfo;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
export function useDownloadQueueDialog() {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const openQueue = () => setIsOpen(true);
|
||||||
|
const closeQueue = () => setIsOpen(false);
|
||||||
|
const toggleQueue = () => setIsOpen((prev) => !prev);
|
||||||
|
return {
|
||||||
|
isOpen,
|
||||||
|
openQueue,
|
||||||
|
closeQueue,
|
||||||
|
toggleQueue,
|
||||||
|
};
|
||||||
|
}
|
||||||
+203
-91
@@ -1,99 +1,211 @@
|
|||||||
import { useState } from "react";
|
import { useState, useRef } from "react";
|
||||||
import { downloadLyrics } from "@/lib/api";
|
import { downloadLyrics } from "@/lib/api";
|
||||||
import { getSettings } from "@/lib/settings";
|
import { getSettings, parseTemplate, type TemplateData } from "@/lib/settings";
|
||||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||||
import { joinPath, sanitizePath } from "@/lib/utils";
|
import { joinPath, sanitizePath } from "@/lib/utils";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
|
import type { TrackMetadata } from "@/types/api";
|
||||||
export function useLyrics() {
|
export function useLyrics() {
|
||||||
const [downloadingLyricsTrack, setDownloadingLyricsTrack] = useState<string | null>(null);
|
const [downloadingLyricsTrack, setDownloadingLyricsTrack] = useState<string | null>(null);
|
||||||
const [downloadedLyrics, setDownloadedLyrics] = useState<Set<string>>(new Set());
|
const [downloadedLyrics, setDownloadedLyrics] = useState<Set<string>>(new Set());
|
||||||
const [failedLyrics, setFailedLyrics] = useState<Set<string>>(new Set());
|
const [failedLyrics, setFailedLyrics] = useState<Set<string>>(new Set());
|
||||||
const [skippedLyrics, setSkippedLyrics] = useState<Set<string>>(new Set());
|
const [skippedLyrics, setSkippedLyrics] = useState<Set<string>>(new Set());
|
||||||
|
const [isBulkDownloadingLyrics, setIsBulkDownloadingLyrics] = useState(false);
|
||||||
const handleDownloadLyrics = async (
|
const [lyricsDownloadProgress, setLyricsDownloadProgress] = useState(0);
|
||||||
spotifyId: string,
|
const stopBulkDownloadRef = useRef(false);
|
||||||
trackName: string,
|
const handleDownloadLyrics = async (spotifyId: string, trackName: string, artistName: string, albumName?: string, playlistName?: string, position?: number, albumArtist?: string, releaseDate?: string, discNumber?: number, isAlbum?: boolean) => {
|
||||||
artistName: string,
|
if (!spotifyId) {
|
||||||
albumName?: string,
|
toast.error("No Spotify ID found for this track");
|
||||||
playlistName?: string,
|
return;
|
||||||
isArtistDiscography?: boolean
|
|
||||||
) => {
|
|
||||||
if (!spotifyId) {
|
|
||||||
toast.error("No Spotify ID found for this track");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`downloading lyrics: ${trackName} - ${artistName}`);
|
|
||||||
const settings = getSettings();
|
|
||||||
setDownloadingLyricsTrack(spotifyId);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const os = settings.operatingSystem;
|
|
||||||
let outputDir = settings.downloadPath;
|
|
||||||
|
|
||||||
// Build output path similar to audio download
|
|
||||||
if (playlistName) {
|
|
||||||
outputDir = joinPath(os, outputDir, sanitizePath(playlistName, os));
|
|
||||||
|
|
||||||
if (isArtistDiscography) {
|
|
||||||
if (settings.albumSubfolder && albumName) {
|
|
||||||
outputDir = joinPath(os, outputDir, sanitizePath(albumName, os));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (settings.artistSubfolder && artistName) {
|
|
||||||
outputDir = joinPath(os, outputDir, sanitizePath(artistName, os));
|
|
||||||
}
|
|
||||||
if (settings.albumSubfolder && albumName) {
|
|
||||||
outputDir = joinPath(os, outputDir, sanitizePath(albumName, os));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
logger.info(`downloading lyrics: ${trackName} - ${artistName}`);
|
||||||
|
const settings = getSettings();
|
||||||
const response = await downloadLyrics({
|
setDownloadingLyricsTrack(spotifyId);
|
||||||
spotify_id: spotifyId,
|
try {
|
||||||
track_name: trackName,
|
const os = settings.operatingSystem;
|
||||||
artist_name: artistName,
|
let outputDir = settings.downloadPath;
|
||||||
output_dir: outputDir,
|
const placeholder = "__SLASH_PLACEHOLDER__";
|
||||||
});
|
const templateData: TemplateData = {
|
||||||
|
artist: artistName?.replace(/\//g, placeholder),
|
||||||
if (response.success) {
|
album: albumName?.replace(/\//g, placeholder),
|
||||||
if (response.already_exists) {
|
title: trackName?.replace(/\//g, placeholder),
|
||||||
toast.info("Lyrics file already exists");
|
track: position,
|
||||||
setSkippedLyrics((prev) => new Set(prev).add(spotifyId));
|
playlist: playlistName?.replace(/\//g, placeholder),
|
||||||
} else {
|
};
|
||||||
toast.success("Lyrics downloaded successfully");
|
if (playlistName && !isAlbum) {
|
||||||
setDownloadedLyrics((prev) => new Set(prev).add(spotifyId));
|
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) {
|
||||||
|
const sanitizedPart = part.replace(new RegExp(placeholder, "g"), " ");
|
||||||
|
outputDir = joinPath(os, outputDir, sanitizePath(sanitizedPart, os));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const useAlbumTrackNumber = settings.folderTemplate?.includes("{album}") || false;
|
||||||
|
const response = await downloadLyrics({
|
||||||
|
spotify_id: spotifyId,
|
||||||
|
track_name: trackName,
|
||||||
|
artist_name: artistName,
|
||||||
|
album_name: albumName,
|
||||||
|
album_artist: albumArtist,
|
||||||
|
release_date: releaseDate,
|
||||||
|
output_dir: outputDir,
|
||||||
|
filename_format: settings.filenameTemplate || "{title}",
|
||||||
|
track_number: settings.trackNumber,
|
||||||
|
position: position || 0,
|
||||||
|
use_album_track_number: useAlbumTrackNumber,
|
||||||
|
disc_number: discNumber,
|
||||||
|
});
|
||||||
|
if (response.success) {
|
||||||
|
if (response.already_exists) {
|
||||||
|
toast.info("Lyrics file already exists");
|
||||||
|
setSkippedLyrics((prev) => new Set(prev).add(spotifyId));
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
toast.success("Lyrics downloaded successfully");
|
||||||
|
setDownloadedLyrics((prev) => new Set(prev).add(spotifyId));
|
||||||
|
}
|
||||||
|
setFailedLyrics((prev) => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
newSet.delete(spotifyId);
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
toast.error(response.error || "Failed to download lyrics");
|
||||||
|
setFailedLyrics((prev) => new Set(prev).add(spotifyId));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
setFailedLyrics((prev) => {
|
catch (err) {
|
||||||
const newSet = new Set(prev);
|
toast.error(err instanceof Error ? err.message : "Failed to download lyrics");
|
||||||
newSet.delete(spotifyId);
|
setFailedLyrics((prev) => new Set(prev).add(spotifyId));
|
||||||
return newSet;
|
}
|
||||||
});
|
finally {
|
||||||
} else {
|
setDownloadingLyricsTrack(null);
|
||||||
toast.error(response.error || "Failed to download lyrics");
|
}
|
||||||
setFailedLyrics((prev) => new Set(prev).add(spotifyId));
|
};
|
||||||
}
|
const handleDownloadAllLyrics = async (tracks: TrackMetadata[], playlistName?: string, _isArtistDiscography?: boolean, isAlbum?: boolean) => {
|
||||||
} catch (err) {
|
const tracksWithSpotifyId = tracks.filter((track) => track.spotify_id);
|
||||||
toast.error(err instanceof Error ? err.message : "Failed to download lyrics");
|
if (tracksWithSpotifyId.length === 0) {
|
||||||
setFailedLyrics((prev) => new Set(prev).add(spotifyId));
|
toast.error("No tracks with Spotify ID available for lyrics download");
|
||||||
} finally {
|
return;
|
||||||
setDownloadingLyricsTrack(null);
|
}
|
||||||
}
|
const settings = getSettings();
|
||||||
};
|
setIsBulkDownloadingLyrics(true);
|
||||||
|
setLyricsDownloadProgress(0);
|
||||||
const resetLyricsState = () => {
|
stopBulkDownloadRef.current = false;
|
||||||
setDownloadedLyrics(new Set());
|
let completed = 0;
|
||||||
setFailedLyrics(new Set());
|
let success = 0;
|
||||||
setSkippedLyrics(new Set());
|
let failed = 0;
|
||||||
};
|
let skipped = 0;
|
||||||
|
const total = tracksWithSpotifyId.length;
|
||||||
return {
|
for (let i = 0; i < tracksWithSpotifyId.length; i++) {
|
||||||
downloadingLyricsTrack,
|
const track = tracksWithSpotifyId[i];
|
||||||
downloadedLyrics,
|
if (stopBulkDownloadRef.current) {
|
||||||
failedLyrics,
|
toast.info("Lyrics download stopped by user");
|
||||||
skippedLyrics,
|
break;
|
||||||
handleDownloadLyrics,
|
}
|
||||||
resetLyricsState,
|
const id = track.spotify_id!;
|
||||||
};
|
setDownloadingLyricsTrack(id);
|
||||||
|
setLyricsDownloadProgress(Math.round((completed / total) * 100));
|
||||||
|
try {
|
||||||
|
const os = settings.operatingSystem;
|
||||||
|
let outputDir = settings.downloadPath;
|
||||||
|
const placeholder = "__SLASH_PLACEHOLDER__";
|
||||||
|
const useAlbumTrackNumber = settings.folderTemplate?.includes("{album}") || false;
|
||||||
|
const trackPosition = useAlbumTrackNumber ? (track.track_number || i + 1) : (i + 1);
|
||||||
|
const templateData: TemplateData = {
|
||||||
|
artist: track.artists?.replace(/\//g, placeholder),
|
||||||
|
album: track.album_name?.replace(/\//g, placeholder),
|
||||||
|
title: track.name?.replace(/\//g, placeholder),
|
||||||
|
track: trackPosition,
|
||||||
|
playlist: playlistName?.replace(/\//g, placeholder),
|
||||||
|
};
|
||||||
|
if (playlistName && !isAlbum) {
|
||||||
|
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) {
|
||||||
|
const sanitizedPart = part.replace(new RegExp(placeholder, "g"), " ");
|
||||||
|
outputDir = joinPath(os, outputDir, sanitizePath(sanitizedPart, os));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const response = await downloadLyrics({
|
||||||
|
spotify_id: id,
|
||||||
|
track_name: track.name,
|
||||||
|
artist_name: track.artists,
|
||||||
|
album_name: track.album_name,
|
||||||
|
album_artist: track.album_artist,
|
||||||
|
release_date: track.release_date,
|
||||||
|
output_dir: outputDir,
|
||||||
|
filename_format: settings.filenameTemplate || "{title}",
|
||||||
|
track_number: settings.trackNumber,
|
||||||
|
position: trackPosition,
|
||||||
|
use_album_track_number: useAlbumTrackNumber,
|
||||||
|
disc_number: track.disc_number,
|
||||||
|
});
|
||||||
|
if (response.success) {
|
||||||
|
if (response.already_exists) {
|
||||||
|
skipped++;
|
||||||
|
setSkippedLyrics((prev) => new Set(prev).add(id));
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
success++;
|
||||||
|
setDownloadedLyrics((prev) => new Set(prev).add(id));
|
||||||
|
}
|
||||||
|
setFailedLyrics((prev) => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
newSet.delete(id);
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
failed++;
|
||||||
|
setFailedLyrics((prev) => new Set(prev).add(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
failed++;
|
||||||
|
logger.error(`error downloading lyrics: ${track.name} - ${err}`);
|
||||||
|
setFailedLyrics((prev) => new Set(prev).add(id));
|
||||||
|
}
|
||||||
|
completed++;
|
||||||
|
}
|
||||||
|
setDownloadingLyricsTrack(null);
|
||||||
|
setIsBulkDownloadingLyrics(false);
|
||||||
|
setLyricsDownloadProgress(0);
|
||||||
|
if (!stopBulkDownloadRef.current) {
|
||||||
|
toast.success(`Lyrics: ${success} downloaded, ${skipped} skipped, ${failed} failed`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleStopLyricsDownload = () => {
|
||||||
|
logger.info("lyrics download stopped by user");
|
||||||
|
stopBulkDownloadRef.current = true;
|
||||||
|
toast.info("Stopping lyrics download...");
|
||||||
|
};
|
||||||
|
const resetLyricsState = () => {
|
||||||
|
setDownloadedLyrics(new Set());
|
||||||
|
setFailedLyrics(new Set());
|
||||||
|
setSkippedLyrics(new Set());
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
downloadingLyricsTrack,
|
||||||
|
downloadedLyrics,
|
||||||
|
failedLyrics,
|
||||||
|
skippedLyrics,
|
||||||
|
isBulkDownloadingLyrics,
|
||||||
|
lyricsDownloadProgress,
|
||||||
|
handleDownloadLyrics,
|
||||||
|
handleDownloadAllLyrics,
|
||||||
|
handleStopLyricsDownload,
|
||||||
|
resetLyricsState,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+210
-197
@@ -3,202 +3,215 @@ import { fetchSpotifyMetadata } from "@/lib/api";
|
|||||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
import type { SpotifyMetadataResponse } from "@/types/api";
|
import type { SpotifyMetadataResponse } from "@/types/api";
|
||||||
|
|
||||||
export function useMetadata() {
|
export function useMetadata() {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [metadata, setMetadata] = useState<SpotifyMetadataResponse | null>(null);
|
const [metadata, setMetadata] = useState<SpotifyMetadataResponse | null>(null);
|
||||||
const [showTimeoutDialog, setShowTimeoutDialog] = useState(false);
|
const [showTimeoutDialog, setShowTimeoutDialog] = useState(false);
|
||||||
const [timeoutValue, setTimeoutValue] = useState(60);
|
const [timeoutValue, setTimeoutValue] = useState(60);
|
||||||
const [pendingUrl, setPendingUrl] = useState("");
|
const [pendingUrl, setPendingUrl] = useState("");
|
||||||
const [showAlbumDialog, setShowAlbumDialog] = useState(false);
|
const [showAlbumDialog, setShowAlbumDialog] = useState(false);
|
||||||
const [selectedAlbum, setSelectedAlbum] = useState<{
|
const [selectedAlbum, setSelectedAlbum] = useState<{
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
external_urls: string;
|
external_urls: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [pendingArtistName, setPendingArtistName] = useState<string | null>(null);
|
const [pendingArtistName, setPendingArtistName] = useState<string | null>(null);
|
||||||
|
const getUrlType = (url: string): string => {
|
||||||
const getUrlType = (url: string): string => {
|
if (url.includes("/track/"))
|
||||||
if (url.includes("/track/")) return "track";
|
return "track";
|
||||||
if (url.includes("/album/")) return "album";
|
if (url.includes("/album/"))
|
||||||
if (url.includes("/playlist/")) return "playlist";
|
return "album";
|
||||||
if (url.includes("/artist/")) return "artist";
|
if (url.includes("/playlist/"))
|
||||||
return "unknown";
|
return "playlist";
|
||||||
};
|
if (url.includes("/artist/"))
|
||||||
|
return "artist";
|
||||||
const fetchMetadataDirectly = async (url: string) => {
|
return "unknown";
|
||||||
const urlType = getUrlType(url);
|
};
|
||||||
logger.info(`fetching ${urlType} metadata...`);
|
const fetchMetadataDirectly = async (url: string) => {
|
||||||
logger.debug(`url: ${url}`);
|
const urlType = getUrlType(url);
|
||||||
|
logger.info(`fetching ${urlType} metadata...`);
|
||||||
setLoading(true);
|
logger.debug(`url: ${url}`);
|
||||||
setMetadata(null);
|
setLoading(true);
|
||||||
|
setMetadata(null);
|
||||||
try {
|
try {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
const data = await fetchSpotifyMetadata(url);
|
const data = await fetchSpotifyMetadata(url);
|
||||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(2);
|
const elapsed = ((Date.now() - startTime) / 1000).toFixed(2);
|
||||||
|
if ("playlist_info" in data) {
|
||||||
setMetadata(data);
|
const playlistInfo = data.playlist_info;
|
||||||
|
if (!playlistInfo.owner.name && playlistInfo.tracks.total === 0 && data.track_list.length === 0) {
|
||||||
// Log detailed info based on type
|
logger.warning("playlist appears to be empty or private");
|
||||||
if ("track" in data) {
|
toast.error("Playlist not found or may be private");
|
||||||
logger.success(`fetched track: ${data.track.name} - ${data.track.artists}`);
|
setMetadata(null);
|
||||||
logger.debug(`isrc: ${data.track.isrc}, duration: ${data.track.duration_ms}ms`);
|
return;
|
||||||
} else if ("album_info" in data) {
|
}
|
||||||
logger.success(`fetched album: ${data.album_info.name}`);
|
}
|
||||||
logger.debug(`${data.track_list.length} tracks, released: ${data.album_info.release_date}`);
|
else if ("album_info" in data) {
|
||||||
} else if ("playlist_info" in data) {
|
const albumInfo = data.album_info;
|
||||||
logger.success(`fetched playlist: ${data.track_list.length} tracks`);
|
if (!albumInfo.name && albumInfo.total_tracks === 0 && data.track_list.length === 0) {
|
||||||
logger.debug(`by ${data.playlist_info.owner.display_name || data.playlist_info.owner.name}`);
|
logger.warning("album appears to be empty or not found");
|
||||||
} else if ("artist_info" in data) {
|
toast.error("Album not found or may be private");
|
||||||
logger.success(`fetched artist: ${data.artist_info.name}`);
|
setMetadata(null);
|
||||||
logger.debug(`${data.album_list.length} albums, ${data.track_list.length} tracks`);
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
logger.info(`fetch completed in ${elapsed}s`);
|
setMetadata(data);
|
||||||
toast.success("Metadata fetched successfully");
|
if ("track" in data) {
|
||||||
} catch (err) {
|
logger.success(`fetched track: ${data.track.name} - ${data.track.artists}`);
|
||||||
const errorMsg = err instanceof Error ? err.message : "Failed to fetch metadata";
|
logger.debug(`isrc: ${data.track.isrc}, duration: ${data.track.duration_ms}ms`);
|
||||||
logger.error(`fetch failed: ${errorMsg}`);
|
}
|
||||||
toast.error(errorMsg);
|
else if ("album_info" in data) {
|
||||||
} finally {
|
logger.success(`fetched album: ${data.album_info.name}`);
|
||||||
setLoading(false);
|
logger.debug(`${data.track_list.length} tracks, released: ${data.album_info.release_date}`);
|
||||||
}
|
}
|
||||||
};
|
else if ("playlist_info" in data) {
|
||||||
|
logger.success(`fetched playlist: ${data.track_list.length} tracks`);
|
||||||
const handleFetchMetadata = async (url: string) => {
|
logger.debug(`by ${data.playlist_info.owner.display_name || data.playlist_info.owner.name}`);
|
||||||
if (!url.trim()) {
|
}
|
||||||
logger.warning("empty url provided");
|
else if ("artist_info" in data) {
|
||||||
toast.error("Please enter a Spotify URL");
|
logger.success(`fetched artist: ${data.artist_info.name}`);
|
||||||
return;
|
logger.debug(`${data.album_list.length} albums, ${data.track_list.length} tracks`);
|
||||||
}
|
}
|
||||||
|
logger.info(`fetch completed in ${elapsed}s`);
|
||||||
let urlToFetch = url.trim();
|
toast.success("Metadata fetched successfully");
|
||||||
const isArtistUrl = urlToFetch.includes("/artist/");
|
}
|
||||||
|
catch (err) {
|
||||||
if (isArtistUrl && !urlToFetch.includes("/discography")) {
|
const errorMsg = err instanceof Error ? err.message : "Failed to fetch metadata";
|
||||||
urlToFetch = urlToFetch.replace(/\/$/, "") + "/discography/all";
|
logger.error(`fetch failed: ${errorMsg}`);
|
||||||
logger.debug("converted to discography url");
|
toast.error(errorMsg);
|
||||||
}
|
}
|
||||||
|
finally {
|
||||||
if (isArtistUrl) {
|
setLoading(false);
|
||||||
logger.info("artist url detected, showing timeout dialog");
|
}
|
||||||
setPendingUrl(urlToFetch);
|
};
|
||||||
setPendingArtistName(null); // Clear artist name for URL input
|
const handleFetchMetadata = async (url: string) => {
|
||||||
setShowTimeoutDialog(true);
|
if (!url.trim()) {
|
||||||
} else {
|
logger.warning("empty url provided");
|
||||||
await fetchMetadataDirectly(urlToFetch);
|
toast.error("Please enter a Spotify URL");
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
return urlToFetch;
|
let urlToFetch = url.trim();
|
||||||
};
|
const isArtistUrl = urlToFetch.includes("/artist/");
|
||||||
|
if (isArtistUrl && !urlToFetch.includes("/discography")) {
|
||||||
const handleConfirmFetch = async () => {
|
urlToFetch = urlToFetch.replace(/\/$/, "") + "/discography/all";
|
||||||
setShowTimeoutDialog(false);
|
logger.debug("converted to discography url");
|
||||||
logger.info(`fetching artist discography (timeout: ${timeoutValue}s)...`);
|
}
|
||||||
logger.debug(`url: ${pendingUrl}`);
|
if (isArtistUrl) {
|
||||||
|
logger.info("artist url detected, showing timeout dialog");
|
||||||
setLoading(true);
|
setPendingUrl(urlToFetch);
|
||||||
setMetadata(null);
|
setPendingArtistName(null);
|
||||||
|
setShowTimeoutDialog(true);
|
||||||
try {
|
}
|
||||||
const startTime = Date.now();
|
else {
|
||||||
const data = await fetchSpotifyMetadata(pendingUrl, true, 1.0, timeoutValue);
|
await fetchMetadataDirectly(urlToFetch);
|
||||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(2);
|
}
|
||||||
|
return urlToFetch;
|
||||||
setMetadata(data);
|
};
|
||||||
|
const handleConfirmFetch = async () => {
|
||||||
if ("artist_info" in data) {
|
setShowTimeoutDialog(false);
|
||||||
logger.success(`fetched artist: ${data.artist_info.name}`);
|
logger.info(`fetching artist discography (timeout: ${timeoutValue}s)...`);
|
||||||
logger.debug(`${data.album_list.length} albums, ${data.track_list.length} tracks`);
|
logger.debug(`url: ${pendingUrl}`);
|
||||||
}
|
setLoading(true);
|
||||||
|
setMetadata(null);
|
||||||
logger.info(`fetch completed in ${elapsed}s`);
|
try {
|
||||||
toast.success("Metadata fetched successfully");
|
const startTime = Date.now();
|
||||||
} catch (err) {
|
const data = await fetchSpotifyMetadata(pendingUrl, true, 1.0, timeoutValue);
|
||||||
const errorMsg = err instanceof Error ? err.message : "Failed to fetch metadata";
|
const elapsed = ((Date.now() - startTime) / 1000).toFixed(2);
|
||||||
logger.error(`fetch failed: ${errorMsg}`);
|
setMetadata(data);
|
||||||
toast.error(errorMsg);
|
if ("artist_info" in data) {
|
||||||
} finally {
|
logger.success(`fetched artist: ${data.artist_info.name}`);
|
||||||
setLoading(false);
|
logger.debug(`${data.album_list.length} albums, ${data.track_list.length} tracks`);
|
||||||
}
|
}
|
||||||
};
|
logger.info(`fetch completed in ${elapsed}s`);
|
||||||
|
toast.success("Metadata fetched successfully");
|
||||||
const handleAlbumClick = (album: {
|
}
|
||||||
id: string;
|
catch (err) {
|
||||||
name: string;
|
const errorMsg = err instanceof Error ? err.message : "Failed to fetch metadata";
|
||||||
external_urls: string;
|
logger.error(`fetch failed: ${errorMsg}`);
|
||||||
}) => {
|
toast.error(errorMsg);
|
||||||
logger.debug(`album clicked: ${album.name}`);
|
}
|
||||||
setSelectedAlbum(album);
|
finally {
|
||||||
setShowAlbumDialog(true);
|
setLoading(false);
|
||||||
};
|
}
|
||||||
|
};
|
||||||
const handleArtistClick = async (artist: {
|
const handleAlbumClick = (album: {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
external_urls: string;
|
external_urls: string;
|
||||||
}) => {
|
}) => {
|
||||||
logger.debug(`artist clicked: ${artist.name}`);
|
logger.debug(`album clicked: ${album.name}`);
|
||||||
const artistUrl = artist.external_urls.replace(/\/$/, "") + "/discography/all";
|
setSelectedAlbum(album);
|
||||||
setPendingUrl(artistUrl);
|
setShowAlbumDialog(true);
|
||||||
setPendingArtistName(artist.name);
|
};
|
||||||
setShowTimeoutDialog(true);
|
const handleArtistClick = async (artist: {
|
||||||
return artistUrl;
|
id: string;
|
||||||
};
|
name: string;
|
||||||
|
external_urls: string;
|
||||||
const handleConfirmAlbumFetch = async () => {
|
}) => {
|
||||||
if (!selectedAlbum) return;
|
logger.debug(`artist clicked: ${artist.name}`);
|
||||||
|
const artistUrl = artist.external_urls.replace(/\/$/, "") + "/discography/all";
|
||||||
const albumUrl = selectedAlbum.external_urls;
|
setPendingUrl(artistUrl);
|
||||||
logger.info(`fetching album: ${selectedAlbum.name}...`);
|
setPendingArtistName(artist.name);
|
||||||
logger.debug(`url: ${albumUrl}`);
|
setShowTimeoutDialog(true);
|
||||||
|
return artistUrl;
|
||||||
setShowAlbumDialog(false);
|
};
|
||||||
setLoading(true);
|
const handleConfirmAlbumFetch = async () => {
|
||||||
setMetadata(null);
|
if (!selectedAlbum)
|
||||||
|
return;
|
||||||
try {
|
const albumUrl = selectedAlbum.external_urls;
|
||||||
const startTime = Date.now();
|
logger.info(`fetching album: ${selectedAlbum.name}...`);
|
||||||
const data = await fetchSpotifyMetadata(albumUrl);
|
logger.debug(`url: ${albumUrl}`);
|
||||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(2);
|
setShowAlbumDialog(false);
|
||||||
|
setLoading(true);
|
||||||
setMetadata(data);
|
setMetadata(null);
|
||||||
|
try {
|
||||||
if ("album_info" in data) {
|
const startTime = Date.now();
|
||||||
logger.success(`fetched album: ${data.album_info.name}`);
|
const data = await fetchSpotifyMetadata(albumUrl);
|
||||||
logger.debug(`${data.track_list.length} tracks, released: ${data.album_info.release_date}`);
|
const elapsed = ((Date.now() - startTime) / 1000).toFixed(2);
|
||||||
}
|
if ("album_info" in data) {
|
||||||
|
const albumInfo = data.album_info;
|
||||||
logger.info(`fetch completed in ${elapsed}s`);
|
if (!albumInfo.name && albumInfo.total_tracks === 0 && data.track_list.length === 0) {
|
||||||
toast.success("Album metadata fetched successfully");
|
logger.warning("album appears to be empty or not found");
|
||||||
return albumUrl;
|
toast.error("Album not found or may be private");
|
||||||
} catch (err) {
|
setMetadata(null);
|
||||||
const errorMsg = err instanceof Error ? err.message : "Failed to fetch album metadata";
|
setSelectedAlbum(null);
|
||||||
logger.error(`fetch failed: ${errorMsg}`);
|
return albumUrl;
|
||||||
toast.error(errorMsg);
|
}
|
||||||
} finally {
|
}
|
||||||
setLoading(false);
|
setMetadata(data);
|
||||||
setSelectedAlbum(null);
|
if ("album_info" in data) {
|
||||||
}
|
logger.success(`fetched album: ${data.album_info.name}`);
|
||||||
};
|
logger.debug(`${data.track_list.length} tracks, released: ${data.album_info.release_date}`);
|
||||||
|
}
|
||||||
return {
|
logger.info(`fetch completed in ${elapsed}s`);
|
||||||
loading,
|
toast.success("Album metadata fetched successfully");
|
||||||
metadata,
|
return albumUrl;
|
||||||
showTimeoutDialog,
|
}
|
||||||
setShowTimeoutDialog,
|
catch (err) {
|
||||||
timeoutValue,
|
const errorMsg = err instanceof Error ? err.message : "Failed to fetch album metadata";
|
||||||
setTimeoutValue,
|
logger.error(`fetch failed: ${errorMsg}`);
|
||||||
showAlbumDialog,
|
toast.error(errorMsg);
|
||||||
setShowAlbumDialog,
|
}
|
||||||
selectedAlbum,
|
finally {
|
||||||
pendingArtistName,
|
setLoading(false);
|
||||||
handleFetchMetadata,
|
setSelectedAlbum(null);
|
||||||
handleConfirmFetch,
|
}
|
||||||
handleAlbumClick,
|
};
|
||||||
handleConfirmAlbumFetch,
|
return {
|
||||||
handleArtistClick,
|
loading,
|
||||||
};
|
metadata,
|
||||||
|
showTimeoutDialog,
|
||||||
|
setShowTimeoutDialog,
|
||||||
|
timeoutValue,
|
||||||
|
setTimeoutValue,
|
||||||
|
showAlbumDialog,
|
||||||
|
setShowAlbumDialog,
|
||||||
|
selectedAlbum,
|
||||||
|
pendingArtistName,
|
||||||
|
handleFetchMetadata,
|
||||||
|
handleConfirmFetch,
|
||||||
|
handleAlbumClick,
|
||||||
|
handleConfirmAlbumFetch,
|
||||||
|
handleArtistClick,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+97
-40
@@ -26,19 +26,6 @@
|
|||||||
--color-border: var(--border);
|
--color-border: var(--border);
|
||||||
--color-input: var(--input);
|
--color-input: var(--input);
|
||||||
--color-ring: var(--ring);
|
--color-ring: var(--ring);
|
||||||
--color-chart-1: var(--chart-1);
|
|
||||||
--color-chart-2: var(--chart-2);
|
|
||||||
--color-chart-3: var(--chart-3);
|
|
||||||
--color-chart-4: var(--chart-4);
|
|
||||||
--color-chart-5: var(--chart-5);
|
|
||||||
--color-sidebar: var(--sidebar);
|
|
||||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
|
||||||
--color-sidebar-primary: var(--sidebar-primary);
|
|
||||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
|
||||||
--color-sidebar-accent: var(--sidebar-accent);
|
|
||||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
|
||||||
--color-sidebar-border: var(--sidebar-border);
|
|
||||||
--color-sidebar-ring: var(--sidebar-ring);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
@@ -61,19 +48,6 @@
|
|||||||
--border: oklch(0.922 0 0);
|
--border: oklch(0.922 0 0);
|
||||||
--input: oklch(0.922 0 0);
|
--input: oklch(0.922 0 0);
|
||||||
--ring: oklch(0.708 0 0);
|
--ring: oklch(0.708 0 0);
|
||||||
--chart-1: oklch(0.646 0.222 41.116);
|
|
||||||
--chart-2: oklch(0.6 0.118 184.704);
|
|
||||||
--chart-3: oklch(0.398 0.07 227.392);
|
|
||||||
--chart-4: oklch(0.828 0.189 84.429);
|
|
||||||
--chart-5: oklch(0.769 0.188 70.08);
|
|
||||||
--sidebar: oklch(0.985 0 0);
|
|
||||||
--sidebar-foreground: oklch(0.145 0 0);
|
|
||||||
--sidebar-primary: oklch(0.205 0 0);
|
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
|
||||||
--sidebar-accent: oklch(0.97 0 0);
|
|
||||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
|
||||||
--sidebar-border: oklch(0.922 0 0);
|
|
||||||
--sidebar-ring: oklch(0.708 0 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
@@ -95,19 +69,6 @@
|
|||||||
--border: oklch(1 0 0 / 10%);
|
--border: oklch(1 0 0 / 10%);
|
||||||
--input: oklch(1 0 0 / 15%);
|
--input: oklch(1 0 0 / 15%);
|
||||||
--ring: oklch(0.556 0 0);
|
--ring: oklch(0.556 0 0);
|
||||||
--chart-1: oklch(0.488 0.243 264.376);
|
|
||||||
--chart-2: oklch(0.696 0.17 162.48);
|
|
||||||
--chart-3: oklch(0.769 0.188 70.08);
|
|
||||||
--chart-4: oklch(0.627 0.265 303.9);
|
|
||||||
--chart-5: oklch(0.645 0.246 16.439);
|
|
||||||
--sidebar: oklch(0.205 0 0);
|
|
||||||
--sidebar-foreground: oklch(0.985 0 0);
|
|
||||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
|
||||||
--sidebar-accent: oklch(0.269 0 0);
|
|
||||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
|
||||||
--sidebar-border: oklch(1 0 0 / 10%);
|
|
||||||
--sidebar-ring: oklch(0.556 0 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
@@ -116,7 +77,7 @@
|
|||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
font-family: "Google Sans Flex", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
font-family: "Google Sans", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
}
|
}
|
||||||
code, pre, .font-mono {
|
code, pre, .font-mono {
|
||||||
font-family: "Google Sans Code", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace !important;
|
font-family: "Google Sans Code", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace !important;
|
||||||
@@ -164,6 +125,54 @@
|
|||||||
@apply text-blue-600;
|
@apply text-blue-600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Ensure description text uses same color as title */
|
||||||
|
[data-sonner-toast] [data-description],
|
||||||
|
[data-sonner-toast] [data-description] * {
|
||||||
|
opacity: 1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Specific color for each toast type - match icon color */
|
||||||
|
[data-sonner-toast][data-type="success"] [data-description],
|
||||||
|
[data-sonner-toast][data-type="success"] [data-description] * {
|
||||||
|
color: rgb(22 163 74) !important; /* green-600 - same as icon */
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-sonner-toast][data-type="error"] [data-description],
|
||||||
|
[data-sonner-toast][data-type="error"] [data-description] * {
|
||||||
|
color: rgb(220 38 38) !important; /* red-600 - same as icon */
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-sonner-toast][data-type="warning"] [data-description],
|
||||||
|
[data-sonner-toast][data-type="warning"] [data-description] * {
|
||||||
|
color: rgb(202 138 4) !important; /* yellow-600 - same as icon */
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-sonner-toast][data-type="info"] [data-description],
|
||||||
|
[data-sonner-toast][data-type="info"] [data-description] * {
|
||||||
|
color: rgb(37 99 235) !important; /* blue-600 - same as icon */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode - use same icon colors */
|
||||||
|
.dark [data-sonner-toast][data-type="success"] [data-description],
|
||||||
|
.dark [data-sonner-toast][data-type="success"] [data-description] * {
|
||||||
|
color: rgb(22 163 74) !important; /* green-600 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark [data-sonner-toast][data-type="error"] [data-description],
|
||||||
|
.dark [data-sonner-toast][data-type="error"] [data-description] * {
|
||||||
|
color: rgb(220 38 38) !important; /* red-600 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark [data-sonner-toast][data-type="warning"] [data-description],
|
||||||
|
.dark [data-sonner-toast][data-type="warning"] [data-description] * {
|
||||||
|
color: rgb(202 138 4) !important; /* yellow-600 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark [data-sonner-toast][data-type="info"] [data-description],
|
||||||
|
.dark [data-sonner-toast][data-type="info"] [data-description] * {
|
||||||
|
color: rgb(37 99 235) !important; /* blue-600 */
|
||||||
|
}
|
||||||
|
|
||||||
/* Dark mode toast styling */
|
/* Dark mode toast styling */
|
||||||
.dark [data-sonner-toast][data-type="success"] {
|
.dark [data-sonner-toast][data-type="success"] {
|
||||||
@apply bg-green-950 border-green-800 text-green-100;
|
@apply bg-green-950 border-green-800 text-green-100;
|
||||||
@@ -196,3 +205,51 @@
|
|||||||
.dark [data-sonner-toast][data-type="info"] [data-icon] {
|
.dark [data-sonner-toast][data-type="info"] [data-icon] {
|
||||||
@apply text-blue-400;
|
@apply text-blue-400;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Custom Scrollbar - mengikuti primary color theme */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: var(--secondary);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--primary);
|
||||||
|
border-radius: 4px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--primary);
|
||||||
|
filter: brightness(1.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Firefox scrollbar support */
|
||||||
|
* {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--primary) var(--secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom scrollbar class untuk komponen tertentu */
|
||||||
|
.custom-scrollbar::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-track {
|
||||||
|
background: var(--muted);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--primary);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||||
|
filter: brightness(1.2);
|
||||||
|
}
|
||||||
|
|||||||
+37
-44
@@ -1,50 +1,43 @@
|
|||||||
import type {
|
import type { SpotifyMetadataResponse, DownloadRequest, DownloadResponse, HealthResponse, LyricsDownloadRequest, LyricsDownloadResponse, CoverDownloadRequest, CoverDownloadResponse, HeaderDownloadRequest, HeaderDownloadResponse, GalleryImageDownloadRequest, GalleryImageDownloadResponse, AvatarDownloadRequest, AvatarDownloadResponse, } from "@/types/api";
|
||||||
SpotifyMetadataResponse,
|
import { GetSpotifyMetadata, DownloadTrack, DownloadLyrics, DownloadCover, DownloadHeader, DownloadGalleryImage, DownloadAvatar } from "../../wailsjs/go/main/App";
|
||||||
DownloadRequest,
|
|
||||||
DownloadResponse,
|
|
||||||
HealthResponse,
|
|
||||||
LyricsDownloadRequest,
|
|
||||||
LyricsDownloadResponse,
|
|
||||||
} from "@/types/api";
|
|
||||||
import { GetSpotifyMetadata, DownloadTrack, DownloadLyrics } from "../../wailsjs/go/main/App";
|
|
||||||
import { main } from "../../wailsjs/go/models";
|
import { main } from "../../wailsjs/go/models";
|
||||||
|
export async function fetchSpotifyMetadata(url: string, batch: boolean = true, delay: number = 1.0, timeout: number = 300.0): Promise<SpotifyMetadataResponse> {
|
||||||
export async function fetchSpotifyMetadata(
|
const req = new main.SpotifyMetadataRequest({
|
||||||
url: string,
|
url,
|
||||||
batch: boolean = true,
|
batch,
|
||||||
delay: number = 1.0,
|
delay,
|
||||||
timeout: number = 300.0
|
timeout,
|
||||||
): Promise<SpotifyMetadataResponse> {
|
});
|
||||||
const req = new main.SpotifyMetadataRequest({
|
const jsonString = await GetSpotifyMetadata(req);
|
||||||
url,
|
return JSON.parse(jsonString);
|
||||||
batch,
|
|
||||||
delay,
|
|
||||||
timeout,
|
|
||||||
});
|
|
||||||
|
|
||||||
const jsonString = await GetSpotifyMetadata(req);
|
|
||||||
return JSON.parse(jsonString);
|
|
||||||
}
|
}
|
||||||
|
export async function downloadTrack(request: DownloadRequest): Promise<DownloadResponse> {
|
||||||
export async function downloadTrack(
|
const req = new main.DownloadRequest(request);
|
||||||
request: DownloadRequest
|
return await DownloadTrack(req);
|
||||||
): Promise<DownloadResponse> {
|
|
||||||
const req = new main.DownloadRequest(request);
|
|
||||||
return await DownloadTrack(req);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function checkHealth(): Promise<HealthResponse> {
|
export async function checkHealth(): Promise<HealthResponse> {
|
||||||
// For Wails, we can just return a simple health check
|
return {
|
||||||
// since the app is running locally
|
status: "ok",
|
||||||
return {
|
time: new Date().toISOString(),
|
||||||
status: "ok",
|
};
|
||||||
time: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
export async function downloadLyrics(request: LyricsDownloadRequest): Promise<LyricsDownloadResponse> {
|
||||||
export async function downloadLyrics(
|
const req = new main.LyricsDownloadRequest(request);
|
||||||
request: LyricsDownloadRequest
|
return await DownloadLyrics(req);
|
||||||
): Promise<LyricsDownloadResponse> {
|
}
|
||||||
const req = new main.LyricsDownloadRequest(request);
|
export async function downloadCover(request: CoverDownloadRequest): Promise<CoverDownloadResponse> {
|
||||||
return await DownloadLyrics(req);
|
const req = new main.CoverDownloadRequest(request);
|
||||||
|
return await DownloadCover(req);
|
||||||
|
}
|
||||||
|
export async function downloadHeader(request: HeaderDownloadRequest): Promise<HeaderDownloadResponse> {
|
||||||
|
const req = new main.HeaderDownloadRequest(request);
|
||||||
|
return await DownloadHeader(req);
|
||||||
|
}
|
||||||
|
export async function downloadGalleryImage(request: GalleryImageDownloadRequest): Promise<GalleryImageDownloadResponse> {
|
||||||
|
const req = new main.GalleryImageDownloadRequest(request);
|
||||||
|
return await DownloadGalleryImage(req);
|
||||||
|
}
|
||||||
|
export async function downloadAvatar(request: AvatarDownloadRequest): Promise<AvatarDownloadResponse> {
|
||||||
|
const req = new main.AvatarDownloadRequest(request);
|
||||||
|
return await DownloadAvatar(req);
|
||||||
}
|
}
|
||||||
|
|||||||
+62
-97
@@ -1,106 +1,71 @@
|
|||||||
// Audio utility for toast notifications using Web Audio API
|
|
||||||
|
|
||||||
class AudioManager {
|
class AudioManager {
|
||||||
private audioContext: AudioContext | null = null;
|
private audioContext: AudioContext | null = null;
|
||||||
|
private getAudioContext(): AudioContext {
|
||||||
private getAudioContext(): AudioContext {
|
if (!this.audioContext) {
|
||||||
if (!this.audioContext) {
|
this.audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
|
||||||
this.audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
|
}
|
||||||
|
return this.audioContext;
|
||||||
}
|
}
|
||||||
return this.audioContext;
|
private playTone(frequency: number, duration: number, type: OscillatorType = 'sine', volume: number = 0.3) {
|
||||||
}
|
try {
|
||||||
|
const ctx = this.getAudioContext();
|
||||||
// Generate a simple tone using oscillator
|
const oscillator = ctx.createOscillator();
|
||||||
private playTone(frequency: number, duration: number, type: OscillatorType = 'sine', volume: number = 0.3) {
|
const gainNode = ctx.createGain();
|
||||||
try {
|
oscillator.connect(gainNode);
|
||||||
const ctx = this.getAudioContext();
|
gainNode.connect(ctx.destination);
|
||||||
const oscillator = ctx.createOscillator();
|
oscillator.frequency.value = frequency;
|
||||||
const gainNode = ctx.createGain();
|
oscillator.type = type;
|
||||||
|
gainNode.gain.setValueAtTime(volume, ctx.currentTime);
|
||||||
oscillator.connect(gainNode);
|
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + duration);
|
||||||
gainNode.connect(ctx.destination);
|
oscillator.start(ctx.currentTime);
|
||||||
|
oscillator.stop(ctx.currentTime + duration);
|
||||||
oscillator.frequency.value = frequency;
|
}
|
||||||
oscillator.type = type;
|
catch (error) {
|
||||||
|
console.error('Error playing audio:', error);
|
||||||
gainNode.gain.setValueAtTime(volume, ctx.currentTime);
|
}
|
||||||
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + duration);
|
|
||||||
|
|
||||||
oscillator.start(ctx.currentTime);
|
|
||||||
oscillator.stop(ctx.currentTime + duration);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error playing audio:', error);
|
|
||||||
}
|
}
|
||||||
}
|
playSuccess() {
|
||||||
|
const ctx = this.getAudioContext();
|
||||||
// Success sound - pleasant ascending tones
|
const now = ctx.currentTime;
|
||||||
playSuccess() {
|
this.playToneAt(523.25, 0.08, 'sine', 0.2, now);
|
||||||
const ctx = this.getAudioContext();
|
this.playToneAt(659.25, 0.08, 'sine', 0.2, now + 0.08);
|
||||||
const now = ctx.currentTime;
|
this.playToneAt(783.99, 0.15, 'sine', 0.25, now + 0.16);
|
||||||
|
}
|
||||||
// First tone
|
playError() {
|
||||||
this.playToneAt(523.25, 0.08, 'sine', 0.2, now); // C5
|
const ctx = this.getAudioContext();
|
||||||
// Second tone
|
const now = ctx.currentTime;
|
||||||
this.playToneAt(659.25, 0.08, 'sine', 0.2, now + 0.08); // E5
|
this.playToneAt(392.00, 0.1, 'square', 0.15, now);
|
||||||
// Third tone
|
this.playToneAt(329.63, 0.2, 'square', 0.2, now + 0.1);
|
||||||
this.playToneAt(783.99, 0.15, 'sine', 0.25, now + 0.16); // G5
|
}
|
||||||
}
|
playWarning() {
|
||||||
|
const ctx = this.getAudioContext();
|
||||||
// Error sound - descending tones
|
const now = ctx.currentTime;
|
||||||
playError() {
|
this.playToneAt(440.00, 0.1, 'triangle', 0.2, now);
|
||||||
const ctx = this.getAudioContext();
|
this.playToneAt(493.88, 0.1, 'triangle', 0.2, now + 0.12);
|
||||||
const now = ctx.currentTime;
|
}
|
||||||
|
playInfo() {
|
||||||
// First tone
|
this.playTone(523.25, 0.15, 'sine', 0.15);
|
||||||
this.playToneAt(392.00, 0.1, 'square', 0.15, now); // G4
|
}
|
||||||
// Second tone
|
private playToneAt(frequency: number, duration: number, type: OscillatorType, volume: number, startTime: number) {
|
||||||
this.playToneAt(329.63, 0.2, 'square', 0.2, now + 0.1); // E4
|
try {
|
||||||
}
|
const ctx = this.getAudioContext();
|
||||||
|
const oscillator = ctx.createOscillator();
|
||||||
// Warning sound - alternating tones
|
const gainNode = ctx.createGain();
|
||||||
playWarning() {
|
oscillator.connect(gainNode);
|
||||||
const ctx = this.getAudioContext();
|
gainNode.connect(ctx.destination);
|
||||||
const now = ctx.currentTime;
|
oscillator.frequency.value = frequency;
|
||||||
|
oscillator.type = type;
|
||||||
// First tone
|
gainNode.gain.setValueAtTime(volume, startTime);
|
||||||
this.playToneAt(440.00, 0.1, 'triangle', 0.2, now); // A4
|
gainNode.gain.exponentialRampToValueAtTime(0.01, startTime + duration);
|
||||||
// Second tone
|
oscillator.start(startTime);
|
||||||
this.playToneAt(493.88, 0.1, 'triangle', 0.2, now + 0.12); // B4
|
oscillator.stop(startTime + duration);
|
||||||
}
|
}
|
||||||
|
catch (error) {
|
||||||
// Info sound - single soft tone
|
console.error('Error playing audio:', error);
|
||||||
playInfo() {
|
}
|
||||||
this.playTone(523.25, 0.15, 'sine', 0.15); // C5
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper method to play tone at specific time
|
|
||||||
private playToneAt(frequency: number, duration: number, type: OscillatorType, volume: number, startTime: number) {
|
|
||||||
try {
|
|
||||||
const ctx = this.getAudioContext();
|
|
||||||
const oscillator = ctx.createOscillator();
|
|
||||||
const gainNode = ctx.createGain();
|
|
||||||
|
|
||||||
oscillator.connect(gainNode);
|
|
||||||
gainNode.connect(ctx.destination);
|
|
||||||
|
|
||||||
oscillator.frequency.value = frequency;
|
|
||||||
oscillator.type = type;
|
|
||||||
|
|
||||||
gainNode.gain.setValueAtTime(volume, startTime);
|
|
||||||
gainNode.gain.exponentialRampToValueAtTime(0.01, startTime + duration);
|
|
||||||
|
|
||||||
oscillator.start(startTime);
|
|
||||||
oscillator.stop(startTime + duration);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error playing audio:', error);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export singleton instance
|
|
||||||
export const audioManager = new AudioManager();
|
export const audioManager = new AudioManager();
|
||||||
|
|
||||||
// Helper functions for easy use
|
|
||||||
export const playSuccessSound = () => audioManager.playSuccess();
|
export const playSuccessSound = () => audioManager.playSuccess();
|
||||||
export const playErrorSound = () => audioManager.playError();
|
export const playErrorSound = () => audioManager.playError();
|
||||||
export const playWarningSound = () => audioManager.playWarning();
|
export const playWarningSound = () => audioManager.playWarning();
|
||||||
|
|||||||
+46
-59
@@ -1,66 +1,53 @@
|
|||||||
export type LogLevel = "info" | "success" | "warning" | "error" | "debug";
|
export type LogLevel = "info" | "success" | "warning" | "error" | "debug";
|
||||||
|
|
||||||
export interface LogEntry {
|
export interface LogEntry {
|
||||||
timestamp: Date;
|
timestamp: Date;
|
||||||
level: LogLevel;
|
level: LogLevel;
|
||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
class Logger {
|
class Logger {
|
||||||
private logs: LogEntry[] = [];
|
private logs: LogEntry[] = [];
|
||||||
private maxLogs = 500;
|
private maxLogs = 500;
|
||||||
private listeners: Set<() => void> = new Set();
|
private listeners: Set<() => void> = new Set();
|
||||||
|
private addLog(level: LogLevel, message: string) {
|
||||||
private addLog(level: LogLevel, message: string) {
|
const entry: LogEntry = {
|
||||||
const entry: LogEntry = {
|
timestamp: new Date(),
|
||||||
timestamp: new Date(),
|
level,
|
||||||
level,
|
message: message.toLowerCase(),
|
||||||
message: message.toLowerCase(),
|
};
|
||||||
};
|
this.logs.push(entry);
|
||||||
this.logs.push(entry);
|
if (this.logs.length > this.maxLogs) {
|
||||||
if (this.logs.length > this.maxLogs) {
|
this.logs.shift();
|
||||||
this.logs.shift();
|
}
|
||||||
|
this.notifyListeners();
|
||||||
|
}
|
||||||
|
info(message: string) {
|
||||||
|
this.addLog("info", message);
|
||||||
|
}
|
||||||
|
success(message: string) {
|
||||||
|
this.addLog("success", message);
|
||||||
|
}
|
||||||
|
warning(message: string) {
|
||||||
|
this.addLog("warning", message);
|
||||||
|
}
|
||||||
|
error(message: string) {
|
||||||
|
this.addLog("error", message);
|
||||||
|
}
|
||||||
|
debug(message: string) {
|
||||||
|
this.addLog("debug", message);
|
||||||
|
}
|
||||||
|
getLogs(): LogEntry[] {
|
||||||
|
return [...this.logs];
|
||||||
|
}
|
||||||
|
clear() {
|
||||||
|
this.logs = [];
|
||||||
|
this.notifyListeners();
|
||||||
|
}
|
||||||
|
subscribe(listener: () => void) {
|
||||||
|
this.listeners.add(listener);
|
||||||
|
return () => this.listeners.delete(listener);
|
||||||
|
}
|
||||||
|
private notifyListeners() {
|
||||||
|
this.listeners.forEach((listener) => listener());
|
||||||
}
|
}
|
||||||
this.notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
info(message: string) {
|
|
||||||
this.addLog("info", message);
|
|
||||||
}
|
|
||||||
|
|
||||||
success(message: string) {
|
|
||||||
this.addLog("success", message);
|
|
||||||
}
|
|
||||||
|
|
||||||
warning(message: string) {
|
|
||||||
this.addLog("warning", message);
|
|
||||||
}
|
|
||||||
|
|
||||||
error(message: string) {
|
|
||||||
this.addLog("error", message);
|
|
||||||
}
|
|
||||||
|
|
||||||
debug(message: string) {
|
|
||||||
this.addLog("debug", message);
|
|
||||||
}
|
|
||||||
|
|
||||||
getLogs(): LogEntry[] {
|
|
||||||
return [...this.logs];
|
|
||||||
}
|
|
||||||
|
|
||||||
clear() {
|
|
||||||
this.logs = [];
|
|
||||||
this.notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
subscribe(listener: () => void) {
|
|
||||||
this.listeners.add(listener);
|
|
||||||
return () => this.listeners.delete(listener);
|
|
||||||
}
|
|
||||||
|
|
||||||
private notifyListeners() {
|
|
||||||
this.listeners.forEach((listener) => listener());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const logger = new Logger();
|
export const logger = new Logger();
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
export function formatRelativeTime(date: Date | string | number): string {
|
||||||
|
const now = new Date();
|
||||||
|
const target = new Date(date);
|
||||||
|
const diffMs = now.getTime() - target.getTime();
|
||||||
|
if (diffMs < 0)
|
||||||
|
return "just now";
|
||||||
|
const seconds = Math.floor(diffMs / 1000);
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
const weeks = Math.floor(days / 7);
|
||||||
|
const months = Math.floor(days / 30);
|
||||||
|
const years = Math.floor(days / 365);
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (years > 0) {
|
||||||
|
parts.push(`${years} ${years === 1 ? "year" : "years"}`);
|
||||||
|
const remainingMonths = Math.floor((days % 365) / 30);
|
||||||
|
if (remainingMonths > 0) {
|
||||||
|
parts.push(`${remainingMonths} ${remainingMonths === 1 ? "month" : "months"}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (months > 0) {
|
||||||
|
parts.push(`${months} ${months === 1 ? "month" : "months"}`);
|
||||||
|
const remainingDays = days % 30;
|
||||||
|
if (remainingDays > 0) {
|
||||||
|
parts.push(`${remainingDays} ${remainingDays === 1 ? "day" : "days"}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (weeks > 0) {
|
||||||
|
parts.push(`${weeks} ${weeks === 1 ? "week" : "weeks"}`);
|
||||||
|
const remainingDays = days % 7;
|
||||||
|
if (remainingDays > 0) {
|
||||||
|
parts.push(`${remainingDays} ${remainingDays === 1 ? "day" : "days"}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (days > 0) {
|
||||||
|
parts.push(`${days} ${days === 1 ? "day" : "days"}`);
|
||||||
|
const remainingHours = hours % 24;
|
||||||
|
if (remainingHours > 0) {
|
||||||
|
parts.push(`${remainingHours} ${remainingHours === 1 ? "hour" : "hours"}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (hours > 0) {
|
||||||
|
parts.push(`${hours} ${hours === 1 ? "hour" : "hours"}`);
|
||||||
|
const remainingMinutes = minutes % 60;
|
||||||
|
if (remainingMinutes > 0) {
|
||||||
|
parts.push(`${remainingMinutes} ${remainingMinutes === 1 ? "minute" : "minutes"}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (minutes > 0) {
|
||||||
|
parts.push(`${minutes} ${minutes === 1 ? "minute" : "minutes"}`);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return "just now";
|
||||||
|
}
|
||||||
|
return "Released " + parts.slice(0, 2).join(" ") + " ago";
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user