Compare commits

...

60 Commits

Author SHA1 Message Date
afkarxyz 7f859db173 v7.0.2 2026-01-11 17:34:12 +07:00
afkarxyz 6e66105481 v7.0.1 2026-01-11 10:42:48 +07:00
afkarxyz 85b542983e v7.0.1 2026-01-11 10:39:37 +07:00
afkarxyz ecc6fd961a v7.0.1 2026-01-11 10:18:51 +07:00
afkarxyz 9260adc2d2 v7.0.1 2026-01-11 08:39:14 +07:00
afkarxyz cb6dfc1638 . 2026-01-11 07:52:33 +07:00
afkarxyz 5dacd70287 . 2026-01-10 12:36:51 +07:00
afkarxyz b163355c50 . 2026-01-09 22:54:57 +07:00
afkarxyz 58495dd9fd . 2026-01-09 22:50:24 +07:00
afkarxyz 1eb8a5ac0c . 2026-01-09 17:39:42 +07:00
afkarxyz 452cd9e118 . 2026-01-09 17:37:16 +07:00
Harley Welsh 1345ac25f4 fix: resolve nested download paths for covers and lyrics (#219)
This commit fixes an issue where cover art and lyrics files were being saved in deeply nested directories (e.g. Album/Artist/Album/file) instead of the correct Artist/Album/file path. It adds an isAlbum flag to the frontend hooks to prevent redundant path construction when downloading in an album context.

Co-authored-by: Harley <git@haileywelsh.me>
2026-01-08 12:34:29 +07:00
SjxSubham ae8b610462 Trim whitespace in sanitizePath function (#215)
Add whitespace trimming to sanitizePath function.
2026-01-08 12:34:08 +07:00
Rin 14297171be Security: Enforce strict validation for FFmpeg binary paths (#214) 2026-01-08 12:33:50 +07:00
afkarxyz 6f6c7563a0 .maintenance 2026-01-08 09:07:18 +07:00
afkarxyz a52c2bb658 .maintenance 2026-01-08 08:23:45 +07:00
afkarxyz 2ce400a5f7 Update README.md 2026-01-01 21:43:46 +07:00
Zarz Eleutherius b8fd2d1762 Update project name and description in README (#217) 2026-01-01 21:24:15 +07:00
afkarxyz d2af0d11df .license 2026-01-01 05:48:29 +07:00
afkarxyz 57640d85d2 v7.0 2025-12-24 09:16:25 +07:00
afkarxyz d7b0ca8b3c v7.0 2025-12-24 09:09:39 +07:00
afkarxyz 8e6a1196b5 v7.0 2025-12-24 08:55:23 +07:00
afkarxyz c150124273 v7.0 2025-12-24 08:50:43 +07:00
afkarxyz cb2a41d068 v6.9 2025-12-20 10:57:13 +07:00
afkarxyz 820a4a30ab v6.9 2025-12-20 10:43:34 +07:00
afkarxyz c9e49b4b95 v6.9 2025-12-20 10:36:39 +07:00
afkarxyz 0ba9443ef4 v6.9 2025-12-20 07:13:55 +07:00
afkarxyz 7f8c968d6a v6.9 2025-12-20 04:59:07 +07:00
afkarxyz 4fee88329b v6.9 2025-12-20 04:49:58 +07:00
afkarxyz 66c30de2db v6.9 2025-12-19 21:04:12 +07:00
afkarxyz 436feb7f7c v6.9 2025-12-19 16:50:43 +07:00
afkarxyz 7d0fde3acc v6.9 2025-12-19 13:29:28 +07:00
afkarxyz 939883c9cd v6.9 2025-12-19 13:26:19 +07:00
TheLittleDoctor 99f3d59ff1 Added a toggle to choose between using Artist property or AlbumArtist property for folder name (#169)
* Corrected function call to correctly download albums vs playlists

* Added setting to prefer AlbumArtist as folder name.
  - In practice, this prevents albums with multiple artists, featured artists, collaborations, or collections like soundtracks, from being split up
  - This is occasionally desirable behavior, so I added it as a toggle rather than a default behavior
2025-12-19 08:39:03 +07:00
Kyle Rector 965f044e0c Showing singular "song" if playlist or album only has one song (#168) 2025-12-19 08:38:50 +07:00
afkarxyz 6a3bd37eb6 Ko-fi 2025-12-17 04:57:12 +07:00
afkarxyz b85ed89af3 v6.8 2025-12-14 15:35:11 +07:00
afkarxyz 8e78a882a3 v6.8 2025-12-14 12:36:37 +07:00
afkarxyz 237ee777c3 v6.8 2025-12-14 12:22:08 +07:00
afkarxyz b44a9abdd6 v6.8 2025-12-14 07:47:18 +07:00
afkarxyz 6c3fb13b25 v6.8 2025-12-13 18:32:50 +07:00
afkarxyz 9398e496e8 v6.8 2025-12-13 18:31:11 +07:00
afkarxyz a8d6276d00 v6.8 2025-12-13 16:42:29 +07:00
afkarxyz 4d3aac4990 v6.8 2025-12-13 14:28:08 +07:00
afkarxyz 64d7f82e52 v6.8 2025-12-13 13:59:41 +07:00
afkarxyz 910420634c v6.8 2025-12-13 13:36:36 +07:00
afkarxyz 22742f1ddd v6.8 2025-12-13 13:32:09 +07:00
afkarxyz 5c1d6619b5 v6.8 2025-12-13 11:43:17 +07:00
Sepehr Aroofzade 76669f551e Add FLAC lyrics embedding with LRCLIB fallback (#151)
* Update lyrics code

* Refactor DownloadFile to use default HTTP client
2025-12-13 04:09:22 +07:00
enriqueqs ffd4daf031 fixed path issue (#157)
Co-authored-by: afkarxyz <mzamzamafkarhadiq@gmail.com>
2025-12-12 17:31:13 +07:00
Heggo 64b86b65a1 Fix Linux Download Path (#153)
* Fix Unknown being appended at the beginning of Download Path on Unix/Linux systems

* Remove extra blank line in backend/filename.go
2025-12-12 17:27:09 +07:00
afkarxyz 8f10094e40 v6.7 2025-12-08 19:33:43 +07:00
afkarxyz 2fb544d1f8 v6.6 2025-12-05 05:26:33 +07:00
afkarxyz d16eaa324a v6.6 2025-12-05 05:25:50 +07:00
afkarxyz cc3f7640c6 v6.5 2025-11-30 05:38:44 +07:00
Lukas 2653586eea Download Queue & Progress UI (#123)
* Add download queue tracking and UI integration

Introduces backend support for a download queue with item tracking, status updates, and session statistics. Adds frontend components and hooks for displaying and managing the download queue, including a dialog and toast indicator. Updates download logic to pre-add items to the queue, track progress, and handle completion, skipping, and failure states. Integrates @radix-ui/react-scroll-area for improved UI scrolling.

* Add session stats to DownloadQueue dialog

Introduces session statistics (downloaded amount, speed, and duration) to the DownloadQueue dialog for improved user feedback. Also adjusts dialog sizing for better display and removes the sm:max-w-lg restriction in dialog.tsx for more flexible width.
2025-11-29 17:36:58 +07:00
afkarxyz 0c92385c56 v6.4 2025-11-27 14:14:19 +07:00
afkarxyz 957fb83dbc v6.4 2025-11-27 06:49:33 +07:00
afkarxyz 90f1871488 UPX 2025-11-27 04:58:53 +07:00
afkarxyz 2d0e5055f8 UPX 2025-11-27 04:52:16 +07:00
114 changed files with 17333 additions and 9451 deletions
+29 -11
View File
@@ -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
View File
@@ -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
+21
View File
@@ -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.
+33 -15
View File
@@ -1,37 +1,55 @@
[![GitHub All Releases](https://img.shields.io/github/downloads/afkarxyz/SpotiFLAC/total?style=for-the-badge)](https://github.com/afkarxyz/SpotiFLAC/releases) [![GitHub All Releases](https://img.shields.io/github/downloads/afkarxyz/SpotiFLAC/total?style=for-the-badge)](https://github.com/afkarxyz/SpotiFLAC/releases)
<!-- ![Maintenance](https://maintenance.afkarxyz.fun?v=3) -->
![Image](https://github.com/user-attachments/assets/a6e92fdd-2944-45c1-83e8-e23a26c827af) ![Image](https://github.com/user-attachments/assets/a6e92fdd-2944-45c1-83e8-e23a26c827af)
<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.
![Windows](https://img.shields.io/badge/Windows-10%2B-0078D6?style=for-the-badge&logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI1MTIiIGhlaWdodD0iNTEyIiB2aWV3Qm94PSIwIDAgMjAgMjAiPjxwYXRoIGZpbGw9IiNmZmZmZmYiIGZpbGwtcnVsZT0iZXZlbm9kZCIgZD0iTTIwIDEwLjg3M1YyMEw4LjQ3OSAxOC41MzdsLjAwMS03LjY2NEgyMFptLTEzLjEyIDBsLS4wMDEgNy40NjFMMCAxNy40NjF2LTYuNTg4aDYuODhaTTIwIDkuMjczSDguNDhsLS4wMDEtNy44MUwyMCAwdjkuMjczWk02Ljg3OSAxLjY2NmwuMDAxIDcuNjA3SDBWMi41MzlsNi44NzktLjg3M1oiLz48L3N2Zz4=) ![Windows](https://img.shields.io/badge/Windows-10%2B-0078D6?style=for-the-badge&logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI1MTIiIGhlaWdodD0iNTEyIiB2aWV3Qm94PSIwIDAgMjAgMjAiPjxwYXRoIGZpbGw9IiNmZmZmZmYiIGZpbGwtcnVsZT0iZXZlbm9kZCIgZD0iTTIwIDEwLjg3M1YyMEw4LjQ3OSAxOC41MzdsLjAwMS03LjY2NEgyMFptLTEzLjEyIDBsLS4wMDEgNy40NjFMMCAxNy40NjF2LTYuNTg4aDYuODhaTTIwIDkuMjczSDguNDhsLS4wMDEtNy44MUwyMCAwdjkuMjczWk02Ljg3OSAxLjY2NmwuMDAxIDcuNjA3SDBWMi41MzlsNi44NzktLjg3M1oiLz48L3N2Zz4=)
![macOS](https://img.shields.io/badge/macOS-10.13%2B-000000?style=for-the-badge&logo=apple&logoColor=white) ![macOS](https://img.shields.io/badge/macOS-10.13%2B-000000?style=for-the-badge&logo=apple&logoColor=white)
![Linux](https://img.shields.io/badge/Linux-Any-FCC624?style=for-the-badge&logo=linux&logoColor=white) ![Linux](https://img.shields.io/badge/Linux-Any-FCC624?style=for-the-badge&logo=linux&logoColor=white)
<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
![Image](https://github.com/user-attachments/assets/7aff07fa-abaa-4f88-96eb-c8b6794d206e) ![Image](https://github.com/user-attachments/assets/4bc2d45a-8afc-4c91-9d57-afdbd2b9c225)
## 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
#
![image](https://github.com/user-attachments/assets/d63b422d-0ea3-4307-850f-96c99d7eaa9a)
![image](https://github.com/user-attachments/assets/7649e6e1-d5d1-49b3-b83f-965d44651d05)
## 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)
[![Ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](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 ~
+751 -92
View File
File diff suppressed because it is too large Load Diff
+116 -45
View File
@@ -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
View File
@@ -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
View File
@@ -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")
} }
+515
View File
@@ -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
}
-380
View File
@@ -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)
}
+680
View File
@@ -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
}
+12
View File
@@ -0,0 +1,12 @@
//go:build !windows
// +build !windows
package backend
import (
"os/exec"
)
func setHideWindow(cmd *exec.Cmd) {
}
+15
View File
@@ -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,
}
}
+49
View File
@@ -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
}
+467
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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, &centiseconds)
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
View File
@@ -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, &centiseconds)
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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+1686
View File
File diff suppressed because it is too large Load Diff
+1010 -829
View File
File diff suppressed because it is too large Load Diff
+438 -600
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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.25.0",
"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.6",
"@types/react": "^19.2.6", "@types/react": "^19.2.8",
"@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
View File
@@ -1 +1 @@
2d92c35b92c8ea713ea561773c5b7b7b 6f2a6dc27f7d8d215283f6d07b4eaa54
+928 -945
View File
File diff suppressed because it is too large Load Diff
+436 -530
View File
File diff suppressed because it is too large Load Diff
+105 -168
View File
@@ -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>);
);
} }
+417 -212
View File
@@ -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>
);
} }
+87 -156
View File
@@ -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>);
}
-132
View File
@@ -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>);
}
+14 -14
View File
@@ -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>);
);
} }
+231
View File
@@ -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>);
}
+73 -68
View File
@@ -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>);
);
} }
+793
View File
@@ -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>);
}
+26 -65
View File
@@ -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>
);
} }
+18
View File
@@ -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>);
+114 -161
View File
@@ -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>);
);
} }
+17 -41
View File
@@ -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>);
);
} }
+351 -71
View File
@@ -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>);
} }
-423
View File
@@ -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>
);
}
+361
View File
@@ -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>);
}
+128
View File
@@ -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>);
}
+188 -284
View File
@@ -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>
);
} }
+22 -62
View File
@@ -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>
</> </>);
);
} }
+114 -98
View File
@@ -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>);
);
} }
+253 -315
View File
@@ -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>
);
} }
+63
View File
@@ -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 };
+19 -41
View File
@@ -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 }
+52
View File
@@ -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 };
+30 -55
View File
@@ -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 }
+10 -78
View File
@@ -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, };
+11 -30
View File
@@ -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 }
+66
View File
@@ -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 };
+48 -223
View File
@@ -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, };
+29 -125
View File
@@ -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, };
+64
View File
@@ -0,0 +1,64 @@
'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 };
+63
View File
@@ -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 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 };
+102
View File
@@ -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 };
+62
View File
@@ -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 };
+121 -179
View File
@@ -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 };
+4 -19
View File
@@ -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 }
+7 -23
View File
@@ -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 }
+26 -112
View File
@@ -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, };
+9 -30
View File
@@ -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 }
+39 -161
View File
@@ -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, };
+54
View File
@@ -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 };
+26 -45
View File
@@ -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 }
+4 -13
View File
@@ -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 }
+10
View File
@@ -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 };
-66
View File
@@ -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 }
+59
View File
@@ -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 };
+26
View File
@@ -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 };
+18 -55
View File
@@ -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 }
+141 -54
View File
@@ -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,
};
} }
+60
View File
@@ -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,
};
}
+208
View File
@@ -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,
};
}
File diff suppressed because it is too large Load Diff
+28 -38
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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();

Some files were not shown because too many files have changed in this diff Show More