Compare commits
130 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d7b0ca8b3c | |||
| 8e6a1196b5 | |||
| c150124273 | |||
| cb2a41d068 | |||
| 820a4a30ab | |||
| c9e49b4b95 | |||
| 0ba9443ef4 | |||
| 7f8c968d6a | |||
| 4fee88329b | |||
| 66c30de2db | |||
| 436feb7f7c | |||
| 7d0fde3acc | |||
| 939883c9cd | |||
| 99f3d59ff1 | |||
| 965f044e0c | |||
| 6a3bd37eb6 | |||
| b85ed89af3 | |||
| 8e78a882a3 | |||
| 237ee777c3 | |||
| b44a9abdd6 | |||
| 6c3fb13b25 | |||
| 9398e496e8 | |||
| a8d6276d00 | |||
| 4d3aac4990 | |||
| 64d7f82e52 | |||
| 910420634c | |||
| 22742f1ddd | |||
| 5c1d6619b5 | |||
| 76669f551e | |||
| ffd4daf031 | |||
| 64b86b65a1 | |||
| 8f10094e40 | |||
| 2fb544d1f8 | |||
| d16eaa324a | |||
| cc3f7640c6 | |||
| 2653586eea | |||
| 0c92385c56 | |||
| 957fb83dbc | |||
| 90f1871488 | |||
| 2d0e5055f8 | |||
| 08d02be4f2 | |||
| 5b542dcf29 | |||
| 48f9584027 | |||
| 4241a591aa | |||
| f346fbb6ba | |||
| 2172981110 | |||
| ddf1844237 | |||
| 3a5aea4c91 | |||
| 6b4ad16882 | |||
| 4dbd88e689 | |||
| d0665fdcc5 | |||
| 6ee3c2f653 | |||
| 73d8205f6f | |||
| 17fe37fbb7 | |||
| afe55db107 | |||
| 8a553774c6 | |||
| 869bf50330 | |||
| 1c03aaa92f | |||
| 1423a32528 | |||
| 633812faab | |||
| 355b68c8de | |||
| 884716069c | |||
| 44658f6ba6 | |||
| f9974d4a3e | |||
| 840f26dd6f | |||
| 5831a45839 | |||
| d1bd7da2de | |||
| 033980bbd2 | |||
| 3ae039d7db | |||
| 436a98c606 | |||
| d90221b835 | |||
| 8a2dbe4e32 | |||
| ee2976143a | |||
| 1c9bba0140 | |||
| 10236f00c6 | |||
| a49bb560bd | |||
| bb9e2dcbb6 | |||
| 7aeefc1fe5 | |||
| 78afdbf77f | |||
| 68e2699941 | |||
| cb6515898a | |||
| 5fcd1f2f75 | |||
| ba732f03b0 | |||
| 427fd33a41 | |||
| b4204a3343 | |||
| 9107f9a5fd | |||
| 0c284ba62c | |||
| 50ca20ce0f | |||
| 80888ca5ad | |||
| b8811d9881 | |||
| a7bcc0249c | |||
| 735c7cb117 | |||
| 05c0f50243 | |||
| 711c5a98d3 | |||
| fd949a17f0 | |||
| 1c0de8b3ac | |||
| b653a8ca41 | |||
| 2d0c174c50 | |||
| c63eeccc55 | |||
| a620c16b1c | |||
| cf27ae098d | |||
| a0c60a473a | |||
| 25c5a4d175 | |||
| 33a6137f75 | |||
| b4fcb6bca6 | |||
| 5ab19a6d37 | |||
| 8547e6d410 | |||
| 17666d8027 | |||
| ab208482ca | |||
| 76e02d77e8 | |||
| 75cc4543ad | |||
| 0b468c4b60 | |||
| 87a6a778f7 | |||
| ef893ab9f4 | |||
| 3eda3245ca | |||
| f6f238361c | |||
| 998730bbb3 | |||
| 56a1d29d78 | |||
| 4b7316636e | |||
| 55669ec45f | |||
| 3304b13828 | |||
| 579bb1415a | |||
| f0e71261a5 | |||
| e2ad51da34 | |||
| 9e403ab1ba | |||
| 7058559ddc | |||
| 861f303a4f | |||
| 9a28e8bd94 | |||
| f75385c4e8 | |||
| 2eac274ee0 |
@@ -0,0 +1,393 @@
|
||||
name: Build Multi-Platform
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
GO_VERSION: '1.25.5'
|
||||
NODE_VERSION: '24'
|
||||
|
||||
jobs:
|
||||
build-windows:
|
||||
name: Build Windows
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Get version from tag
|
||||
id: version
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ "${{ github.ref }}" == refs/tags/* ]]; then
|
||||
VERSION=${GITHUB_REF#refs/tags/}
|
||||
else
|
||||
VERSION="dev"
|
||||
fi
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Get pnpm store directory
|
||||
shell: bash
|
||||
run: |
|
||||
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- name: Setup pnpm cache
|
||||
continue-on-error: true
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ env.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('frontend/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install Wails CLI
|
||||
run: go install github.com/wailsapp/wails/v2/cmd/wails@latest
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: frontend
|
||||
run: |
|
||||
pnpm install
|
||||
pnpm run generate-icon
|
||||
|
||||
- name: Install UPX
|
||||
run: |
|
||||
choco install upx -y
|
||||
|
||||
- name: Build application
|
||||
run: wails build -platform windows/amd64
|
||||
|
||||
- name: Compress with UPX
|
||||
run: |
|
||||
upx --best --lzma "build\bin\SpotiFLAC.exe"
|
||||
|
||||
- name: Prepare artifacts
|
||||
run: |
|
||||
mkdir -p dist
|
||||
Copy-Item -Path "build\bin\SpotiFLAC.exe" -Destination "dist\SpotiFLAC.exe"
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: windows-portable
|
||||
path: dist/SpotiFLAC.exe
|
||||
retention-days: 7
|
||||
|
||||
build-macos:
|
||||
name: Build macOS
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Get version from tag
|
||||
id: version
|
||||
run: |
|
||||
if [[ "${{ github.ref }}" == refs/tags/* ]]; then
|
||||
VERSION=${GITHUB_REF#refs/tags/}
|
||||
else
|
||||
VERSION="dev"
|
||||
fi
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Get pnpm store directory
|
||||
run: |
|
||||
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- name: Setup pnpm cache
|
||||
continue-on-error: true
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ env.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('frontend/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install Wails CLI
|
||||
run: go install github.com/wailsapp/wails/v2/cmd/wails@latest
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: frontend
|
||||
run: |
|
||||
pnpm install
|
||||
pnpm run generate-icon
|
||||
|
||||
- name: Build application
|
||||
run: wails build -platform darwin/universal
|
||||
|
||||
- name: Create DMG
|
||||
run: |
|
||||
mkdir -p dist
|
||||
# Install create-dmg if not available
|
||||
brew install create-dmg || true
|
||||
|
||||
# Create DMG
|
||||
create-dmg \
|
||||
--volname "SpotiFLAC" \
|
||||
--window-pos 200 120 \
|
||||
--window-size 600 400 \
|
||||
--icon-size 100 \
|
||||
--icon "SpotiFLAC.app" 175 120 \
|
||||
--hide-extension "SpotiFLAC.app" \
|
||||
--app-drop-link 425 120 \
|
||||
"dist/SpotiFLAC.dmg" \
|
||||
"build/bin/SpotiFLAC.app" || \
|
||||
# Fallback to hdiutil if create-dmg fails
|
||||
hdiutil create -volname SpotiFLAC -srcfolder build/bin/SpotiFLAC.app -ov -format UDZO dist/SpotiFLAC.dmg
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: macos-portable
|
||||
path: dist/SpotiFLAC.dmg
|
||||
retention-days: 7
|
||||
|
||||
build-linux:
|
||||
name: Build Linux
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Get version from tag
|
||||
id: version
|
||||
run: |
|
||||
if [[ "${{ github.ref }}" == refs/tags/* ]]; then
|
||||
VERSION=${GITHUB_REF#refs/tags/}
|
||||
else
|
||||
VERSION="dev"
|
||||
fi
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Get pnpm store directory
|
||||
run: |
|
||||
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- name: Setup pnpm cache
|
||||
continue-on-error: true
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ env.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('frontend/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
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)
|
||||
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
|
||||
|
||||
- name: Install Wails CLI
|
||||
run: go install github.com/wailsapp/wails/v2/cmd/wails@latest
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: frontend
|
||||
run: |
|
||||
pnpm install
|
||||
pnpm run generate-icon
|
||||
|
||||
- name: Build application
|
||||
run: wails build -platform linux/amd64
|
||||
|
||||
- name: Compress with UPX
|
||||
run: |
|
||||
upx --best --lzma build/bin/SpotiFLAC
|
||||
|
||||
- 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
|
||||
run: |
|
||||
mkdir -p AppDir/usr/bin
|
||||
mkdir -p AppDir/usr/share/applications
|
||||
mkdir -p AppDir/usr/share/icons/hicolor/256x256/apps
|
||||
|
||||
# Copy binary
|
||||
cp build/bin/SpotiFLAC AppDir/usr/bin/
|
||||
|
||||
# Create desktop file
|
||||
cat > AppDir/spotiflac.desktop << 'EOF'
|
||||
[Desktop Entry]
|
||||
Name=SpotiFLAC
|
||||
Exec=SpotiFLAC
|
||||
Icon=spotiflac
|
||||
Type=Application
|
||||
Categories=Audio;AudioVideo;
|
||||
Comment=Get Spotify tracks in true FLAC from Tidal/Deezer
|
||||
EOF
|
||||
|
||||
cp AppDir/spotiflac.desktop AppDir/usr/share/applications/
|
||||
|
||||
# Create icon
|
||||
if [ -f "build/appicon.png" ]; then
|
||||
convert build/appicon.png -resize 256x256 AppDir/spotiflac.png
|
||||
elif [ -f "frontend/public/icon.svg" ]; then
|
||||
convert -background none -size 256x256 frontend/public/icon.svg AppDir/spotiflac.png
|
||||
else
|
||||
echo "Warning: No icon found, building without icon"
|
||||
fi
|
||||
|
||||
# Copy icon if exists
|
||||
if [ -f "AppDir/spotiflac.png" ]; then
|
||||
cp AppDir/spotiflac.png AppDir/usr/share/icons/hicolor/256x256/apps/
|
||||
cp AppDir/spotiflac.png AppDir/.DirIcon
|
||||
fi
|
||||
|
||||
# Create AppRun
|
||||
cat > AppDir/AppRun << 'EOF'
|
||||
#!/bin/sh
|
||||
SELF=$(readlink -f "$0")
|
||||
HERE=${SELF%/*}
|
||||
export PATH="${HERE}/usr/bin/:${PATH}"
|
||||
export LD_LIBRARY_PATH="${HERE}/usr/lib/:${LD_LIBRARY_PATH}"
|
||||
exec "${HERE}/usr/bin/SpotiFLAC" "$@"
|
||||
EOF
|
||||
chmod +x AppDir/AppRun
|
||||
|
||||
# Create AppImage
|
||||
mkdir -p dist
|
||||
ARCH=x86_64 ./appimagetool --no-appstream AppDir dist/SpotiFLAC.AppImage
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: linux-portable
|
||||
path: dist/SpotiFLAC.AppImage
|
||||
retention-days: 7
|
||||
|
||||
create-release:
|
||||
name: Create Release
|
||||
needs: [build-windows, build-macos, build-linux]
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Get version from tag
|
||||
id: version
|
||||
run: |
|
||||
VERSION=${GITHUB_REF#refs/tags/}
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
- name: Display structure of downloaded files
|
||||
run: ls -R artifacts
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
draft: true
|
||||
prerelease: false
|
||||
generate_release_notes: false
|
||||
body: |
|
||||
## Changelog
|
||||
|
||||
## Downloads
|
||||
|
||||
- `SpotiFLAC.exe` - Windows
|
||||
- `SpotiFLAC.dmg` - macOS
|
||||
- `SpotiFLAC.AppImage` - Linux
|
||||
|
||||
<details>
|
||||
<summary><b>Linux Requirements</b></summary>
|
||||
|
||||
The AppImage requires `webkit2gtk-4.1` to be installed on your system:
|
||||
|
||||
**Ubuntu/Debian:**
|
||||
```bash
|
||||
sudo apt install libwebkit2gtk-4.1-0
|
||||
```
|
||||
|
||||
**Arch Linux:**
|
||||
```bash
|
||||
sudo pacman -S webkit2gtk-4.1
|
||||
```
|
||||
|
||||
**Fedora:**
|
||||
```bash
|
||||
sudo dnf install webkit2gtk4.1
|
||||
```
|
||||
|
||||
After installing the dependency, make the AppImage executable:
|
||||
```bash
|
||||
chmod +x SpotiFLAC.AppImage
|
||||
./SpotiFLAC.AppImage
|
||||
```
|
||||
|
||||
</details>
|
||||
files: |
|
||||
artifacts/windows-portable/*.exe
|
||||
artifacts/macos-portable/*.dmg
|
||||
artifacts/linux-portable/*.AppImage
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
# Wails Build
|
||||
build/
|
||||
*.exe
|
||||
*.dll
|
||||
*.dylib
|
||||
*.so
|
||||
|
||||
# Wails Generated Files
|
||||
frontend/wailsjs/
|
||||
|
||||
# Go
|
||||
*.test
|
||||
*.out
|
||||
go.work
|
||||
go.work.sum
|
||||
|
||||
# Node / Frontend
|
||||
node_modules/
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
frontend/.vite/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
.pnpm-store/
|
||||
.npm
|
||||
.yarn
|
||||
*.tsbuildinfo
|
||||
|
||||
# IDE / Editors
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.DS_Store
|
||||
|
||||
# OS
|
||||
Thumbs.db
|
||||
desktop.ini
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# Temporary files
|
||||
tmp/
|
||||
temp/
|
||||
*.tmp
|
||||
*.bak
|
||||
*.old
|
||||
|
||||
# Test files
|
||||
test
|
||||
|
||||
# Build notes (optional - uncomment if you don't want to commit)
|
||||
# BUILD_NOTES.md
|
||||
build.txt
|
||||
@@ -1,27 +1,27 @@
|
||||
[](https://github.com/afkarxyz/SpotiFLAC/releases)
|
||||
|
||||

|
||||

|
||||
|
||||
<div align="center">
|
||||
<b>SpotiFLAC</b> allows you to download Spotify tracks in true FLAC format through services like Qobuz, Tidal, Deezer & Amazon Music.
|
||||
|
||||
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account required.
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
</div>
|
||||
|
||||
### [Download](https://github.com/afkarxyz/SpotiFLAC/releases/download/v4.4/SpotiFLAC.exe)
|
||||
### [Download](https://github.com/afkarxyz/SpotiFLAC/releases)
|
||||
|
||||
## Screenshots
|
||||
## Screenshot
|
||||
|
||||

|
||||

|
||||
|
||||

|
||||
## Other project
|
||||
|
||||

|
||||
### [SpotiDownloader](https://github.com/afkarxyz/SpotiDownloader)
|
||||
|
||||

|
||||
Get Spotify tracks in MP3 and FLAC via the spotidownloader.com API
|
||||
|
||||
## Lossless Audio Check
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
#### [Download](https://github.com/afkarxyz/SpotiFLAC/releases/download/v0/FLAC-Checker.zip) FLAC Checker
|
||||
[](https://ko-fi.com/afkarxyz)
|
||||
|
||||
-2027
File diff suppressed because it is too large
Load Diff
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 82 KiB |
-128
@@ -1,128 +0,0 @@
|
||||
import requests
|
||||
import time
|
||||
import os
|
||||
import re
|
||||
import base64
|
||||
import urllib3
|
||||
from urllib.parse import unquote
|
||||
from random import randrange
|
||||
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
|
||||
def get_random_user_agent():
|
||||
return f"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_{randrange(11, 15)}_{randrange(4, 9)}) AppleWebKit/{randrange(530, 537)}.{randrange(30, 37)} (KHTML, like Gecko) Chrome/{randrange(80, 105)}.0.{randrange(3000, 4500)}.{randrange(60, 125)} Safari/{randrange(530, 537)}.{randrange(30, 36)}"
|
||||
|
||||
def extract_data(html, patterns):
|
||||
for pattern in patterns:
|
||||
if match := re.search(pattern, html):
|
||||
return match.group(1)
|
||||
return None
|
||||
|
||||
def download_track(track_id, service="amazon", output_dir="."):
|
||||
client = requests.Session()
|
||||
client.verify = False
|
||||
headers = {'User-Agent': get_random_user_agent()}
|
||||
|
||||
try:
|
||||
spotify_url = f"https://open.spotify.com/track/{track_id}"
|
||||
params = {"url": spotify_url, "country": "auto", "to": service}
|
||||
|
||||
response = client.get("https://lucida.to", params=params, headers=headers, timeout=30)
|
||||
html = response.text
|
||||
|
||||
token = extract_data(html, [r'token:"([^"]+)"', r'"token"\s*:\s*"([^"]+)"'])
|
||||
url = extract_data(html, [r'"url":"([^"]+)"', r'url:"([^"]+)"'])
|
||||
expiry = extract_data(html, [r'tokenExpiry:(\d+)', r'"tokenExpiry"\s*:\s*(\d+)'])
|
||||
|
||||
if not (token and url):
|
||||
raise Exception("Could not extract required data")
|
||||
|
||||
try:
|
||||
decoded_token = base64.b64decode(base64.b64decode(token).decode('latin1')).decode('latin1')
|
||||
except:
|
||||
decoded_token = token
|
||||
|
||||
clean_url = url.replace('\\/', '/')
|
||||
print(f"Fetching: {clean_url}")
|
||||
|
||||
request_data = {
|
||||
"account": {"id": "auto", "type": "country"},
|
||||
"compat": "false", "downscale": "original", "handoff": True,
|
||||
"metadata": True, "private": True,
|
||||
"token": {"primary": decoded_token, "expiry": int(expiry) if expiry else None},
|
||||
"upload": {"enabled": False, "service": "pixeldrain"},
|
||||
"url": clean_url
|
||||
}
|
||||
|
||||
response = client.post("https://lucida.to/api/load?url=/api/fetch/stream/v2",
|
||||
json=request_data, headers=headers)
|
||||
|
||||
if csrf_token := response.cookies.get('csrf_token'):
|
||||
headers['X-CSRF-Token'] = csrf_token
|
||||
|
||||
data = response.json()
|
||||
if not data.get("success"):
|
||||
raise Exception(f"Request failed: {data.get('error', 'Unknown error')}")
|
||||
|
||||
completion_url = f"https://{data['server']}.lucida.to/api/fetch/request/{data['handoff']}"
|
||||
print("Fetching URL...")
|
||||
|
||||
while True:
|
||||
resp = client.get(completion_url, headers=headers).json()
|
||||
if resp["status"] == "completed":
|
||||
print("URL found")
|
||||
break
|
||||
elif resp["status"] == "error":
|
||||
raise Exception(f"Processing failed: {resp.get('message', 'Unknown error')}")
|
||||
elif progress := resp.get("progress"):
|
||||
percent = int((progress.get("current", 0) / progress.get("total", 100)) * 100)
|
||||
print(f"\r{percent}%", end="")
|
||||
time.sleep(1)
|
||||
|
||||
download_url = f"https://{data['server']}.lucida.to/api/fetch/request/{data['handoff']}/download"
|
||||
response = client.get(download_url, stream=True, headers=headers)
|
||||
|
||||
file_name = "track.flac"
|
||||
if content_disp := response.headers.get('content-disposition'):
|
||||
if match := re.search(r'filename[*]?=([^;]+)', content_disp):
|
||||
raw_name = match.group(1).strip('"\'')
|
||||
file_name = unquote(raw_name[7:] if raw_name.startswith("UTF-8''") else raw_name)
|
||||
for char in '<>:"/\\|?*':
|
||||
file_name = file_name.replace(char, '')
|
||||
file_name = file_name.strip()
|
||||
|
||||
file_path = os.path.join(output_dir, file_name)
|
||||
print(f"Downloading...")
|
||||
|
||||
with open(file_path, 'wb') as f:
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
if chunk:
|
||||
f.write(chunk)
|
||||
|
||||
print("Download complete")
|
||||
print("Done")
|
||||
return file_path
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {str(e)}")
|
||||
return None
|
||||
|
||||
class LucidaDownloader:
|
||||
def __init__(self):
|
||||
self.progress_callback = None
|
||||
|
||||
def set_progress_callback(self, callback):
|
||||
self.progress_callback = callback
|
||||
|
||||
def download(self, track_id, output_dir, is_paused_callback=None, is_stopped_callback=None):
|
||||
try:
|
||||
return download_track(track_id, service="amazon", output_dir=output_dir)
|
||||
except Exception as e:
|
||||
raise Exception(f"Amazon Music download failed: {str(e)}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("=== AmazonDL - Amazon Music Downloader ===")
|
||||
track_id = "2plbrEY59IikOBgBGLjaoe"
|
||||
service = "amazon"
|
||||
|
||||
download_track(track_id, service)
|
||||
@@ -0,0 +1,861 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"spotiflac/backend"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// App struct
|
||||
type App struct {
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
// NewApp creates a new App application struct
|
||||
func NewApp() *App {
|
||||
return &App{}
|
||||
}
|
||||
|
||||
// startup is called when the app starts. The context is saved
|
||||
// so we can call the runtime methods
|
||||
func (a *App) startup(ctx context.Context) {
|
||||
a.ctx = ctx
|
||||
}
|
||||
|
||||
// SpotifyMetadataRequest represents the request structure for fetching Spotify metadata
|
||||
type SpotifyMetadataRequest struct {
|
||||
URL string `json:"url"`
|
||||
Batch bool `json:"batch"`
|
||||
Delay float64 `json:"delay"`
|
||||
Timeout float64 `json:"timeout"`
|
||||
}
|
||||
|
||||
// DownloadRequest represents the request structure for downloading tracks
|
||||
type DownloadRequest struct {
|
||||
ISRC string `json:"isrc"`
|
||||
Service string `json:"service"`
|
||||
Query string `json:"query,omitempty"`
|
||||
TrackName string `json:"track_name,omitempty"`
|
||||
ArtistName string `json:"artist_name,omitempty"`
|
||||
AlbumName string `json:"album_name,omitempty"`
|
||||
AlbumArtist string `json:"album_artist,omitempty"`
|
||||
ReleaseDate string `json:"release_date,omitempty"`
|
||||
CoverURL string `json:"cover_url,omitempty"` // Spotify cover URL for embedding
|
||||
ApiURL string `json:"api_url,omitempty"`
|
||||
OutputDir string `json:"output_dir,omitempty"`
|
||||
AudioFormat string `json:"audio_format,omitempty"`
|
||||
FilenameFormat string `json:"filename_format,omitempty"`
|
||||
TrackNumber bool `json:"track_number,omitempty"`
|
||||
Position int `json:"position,omitempty"` // Position in playlist/album (1-based)
|
||||
UseAlbumTrackNumber bool `json:"use_album_track_number,omitempty"` // Use album track number instead of playlist position
|
||||
SpotifyID string `json:"spotify_id,omitempty"` // Spotify track ID
|
||||
EmbedLyrics bool `json:"embed_lyrics,omitempty"` // Whether to embed lyrics into the audio file
|
||||
EmbedMaxQualityCover bool `json:"embed_max_quality_cover,omitempty"` // Whether to embed max quality cover art
|
||||
ServiceURL string `json:"service_url,omitempty"` // Direct service URL (Tidal/Deezer/Amazon) to skip song.link API call
|
||||
Duration int `json:"duration,omitempty"` // Track duration in seconds for better matching
|
||||
ItemID string `json:"item_id,omitempty"` // Optional queue item ID for multi-service fallback tracking
|
||||
SpotifyTrackNumber int `json:"spotify_track_number,omitempty"` // Track number from Spotify album
|
||||
SpotifyDiscNumber int `json:"spotify_disc_number,omitempty"` // Disc number from Spotify album
|
||||
SpotifyTotalTracks int `json:"spotify_total_tracks,omitempty"` // Total tracks in album from Spotify
|
||||
}
|
||||
|
||||
// DownloadResponse represents the response structure for download operations
|
||||
type DownloadResponse 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"`
|
||||
ItemID string `json:"item_id,omitempty"` // Queue item ID for tracking
|
||||
}
|
||||
|
||||
// GetStreamingURLs fetches all streaming URLs from song.link API
|
||||
func (a *App) GetStreamingURLs(spotifyTrackID string) (string, error) {
|
||||
if spotifyTrackID == "" {
|
||||
return "", fmt.Errorf("spotify track ID is required")
|
||||
}
|
||||
|
||||
fmt.Printf("[GetStreamingURLs] Called for track ID: %s\n", spotifyTrackID)
|
||||
client := backend.NewSongLinkClient()
|
||||
urls, err := client.GetAllURLsFromSpotify(spotifyTrackID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(urls)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to encode response: %v", err)
|
||||
}
|
||||
|
||||
return string(jsonData), nil
|
||||
}
|
||||
|
||||
// GetSpotifyMetadata fetches metadata from Spotify
|
||||
func (a *App) GetSpotifyMetadata(req SpotifyMetadataRequest) (string, error) {
|
||||
if req.URL == "" {
|
||||
return "", fmt.Errorf("URL parameter is required")
|
||||
}
|
||||
|
||||
if req.Delay == 0 {
|
||||
req.Delay = 1.0
|
||||
}
|
||||
if req.Timeout == 0 {
|
||||
req.Timeout = 300.0
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(req.Timeout*float64(time.Second)))
|
||||
defer cancel()
|
||||
|
||||
data, err := backend.GetFilteredSpotifyData(ctx, req.URL, req.Batch, time.Duration(req.Delay*float64(time.Second)))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to fetch metadata: %v", err)
|
||||
}
|
||||
|
||||
jsonData, err := json.MarshalIndent(data, "", " ")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to encode response: %v", err)
|
||||
}
|
||||
|
||||
return string(jsonData), nil
|
||||
}
|
||||
|
||||
// SpotifySearchRequest represents the request structure for searching Spotify
|
||||
type SpotifySearchRequest struct {
|
||||
Query string `json:"query"`
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
|
||||
// SearchSpotify searches for tracks, albums, artists, and playlists on Spotify
|
||||
func (a *App) SearchSpotify(req SpotifySearchRequest) (*backend.SearchResponse, error) {
|
||||
if req.Query == "" {
|
||||
return nil, fmt.Errorf("search query is required")
|
||||
}
|
||||
|
||||
if req.Limit <= 0 {
|
||||
req.Limit = 10
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
return backend.SearchSpotify(ctx, req.Query, req.Limit)
|
||||
}
|
||||
|
||||
// SpotifySearchByTypeRequest represents the request for searching by specific type with offset
|
||||
type SpotifySearchByTypeRequest struct {
|
||||
Query string `json:"query"`
|
||||
SearchType string `json:"search_type"` // track, album, artist, playlist
|
||||
Limit int `json:"limit"`
|
||||
Offset int `json:"offset"`
|
||||
}
|
||||
|
||||
// SearchSpotifyByType searches for a specific type with offset support for pagination
|
||||
func (a *App) SearchSpotifyByType(req SpotifySearchByTypeRequest) ([]backend.SearchResult, error) {
|
||||
if req.Query == "" {
|
||||
return nil, fmt.Errorf("search query is required")
|
||||
}
|
||||
|
||||
if req.SearchType == "" {
|
||||
return nil, fmt.Errorf("search type is required")
|
||||
}
|
||||
|
||||
if req.Limit <= 0 {
|
||||
req.Limit = 50
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
return backend.SearchSpotifyByType(ctx, req.Query, req.SearchType, req.Limit, req.Offset)
|
||||
}
|
||||
|
||||
// DownloadTrack downloads a track by ISRC
|
||||
func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
||||
if req.ISRC == "" {
|
||||
return DownloadResponse{
|
||||
Success: false,
|
||||
Error: "ISRC is required",
|
||||
}, fmt.Errorf("ISRC is required")
|
||||
}
|
||||
|
||||
if req.Service == "" {
|
||||
req.Service = "tidal"
|
||||
}
|
||||
|
||||
if req.OutputDir == "" {
|
||||
req.OutputDir = "."
|
||||
} else {
|
||||
// Only normalize path separators, don't sanitize user's existing folder names
|
||||
req.OutputDir = backend.NormalizePath(req.OutputDir)
|
||||
}
|
||||
|
||||
if req.AudioFormat == "" {
|
||||
req.AudioFormat = "LOSSLESS"
|
||||
}
|
||||
|
||||
var err error
|
||||
var filename string
|
||||
|
||||
// Set default filename format if not provided
|
||||
if req.FilenameFormat == "" {
|
||||
req.FilenameFormat = "title-artist"
|
||||
}
|
||||
|
||||
// ItemID should always be provided by frontend (created via AddToDownloadQueue)
|
||||
// If not provided, generate one for backwards compatibility
|
||||
itemID := req.ItemID
|
||||
if itemID == "" {
|
||||
itemID = fmt.Sprintf("%s-%d", req.ISRC, time.Now().UnixNano())
|
||||
// Add to queue if no ItemID was provided (legacy support)
|
||||
backend.AddToQueue(itemID, req.TrackName, req.ArtistName, req.AlbumName, req.ISRC)
|
||||
}
|
||||
|
||||
// Mark item as downloading immediately
|
||||
backend.SetDownloading(true)
|
||||
backend.StartDownloadItem(itemID)
|
||||
defer backend.SetDownloading(false)
|
||||
|
||||
// Early check: Check if file with same ISRC already exists
|
||||
if existingFile, exists := backend.CheckISRCExists(req.OutputDir, req.ISRC); exists {
|
||||
fmt.Printf("File with ISRC %s already exists: %s\n", req.ISRC, existingFile)
|
||||
backend.SkipDownloadItem(itemID, existingFile)
|
||||
return DownloadResponse{
|
||||
Success: true,
|
||||
Message: "File with same ISRC already exists",
|
||||
File: existingFile,
|
||||
AlreadyExists: true,
|
||||
ItemID: itemID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Fallback: if we have track metadata, check if file already exists by filename
|
||||
if req.TrackName != "" && req.ArtistName != "" {
|
||||
expectedFilename := backend.BuildExpectedFilename(req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.FilenameFormat, req.TrackNumber, req.Position, req.SpotifyDiscNumber, req.UseAlbumTrackNumber)
|
||||
expectedPath := filepath.Join(req.OutputDir, expectedFilename)
|
||||
|
||||
if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 100*1024 {
|
||||
// Validate the file by checking if it has valid ISRC metadata
|
||||
if fileISRC, readErr := backend.ReadISRCFromFile(expectedPath); readErr == nil && fileISRC != "" {
|
||||
// File exists and has valid metadata - skip download
|
||||
backend.SkipDownloadItem(itemID, expectedPath)
|
||||
return DownloadResponse{
|
||||
Success: true,
|
||||
Message: "File already exists",
|
||||
File: expectedPath,
|
||||
AlreadyExists: true,
|
||||
ItemID: itemID,
|
||||
}, nil
|
||||
} else {
|
||||
// File exists but has no valid ISRC metadata - it's corrupted, delete it
|
||||
fmt.Printf("Removing corrupted file (no valid ISRC metadata): %s\n", expectedPath)
|
||||
if removeErr := os.Remove(expectedPath); removeErr != nil {
|
||||
fmt.Printf("Warning: Failed to remove corrupted file %s: %v\n", expectedPath, removeErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch req.Service {
|
||||
case "amazon":
|
||||
downloader := backend.NewAmazonDownloader()
|
||||
if req.ServiceURL != "" {
|
||||
// Use provided URL directly
|
||||
filename, err = downloader.DownloadByURL(req.ServiceURL, req.OutputDir, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.CoverURL, req.ISRC, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.EmbedMaxQualityCover)
|
||||
} else {
|
||||
if req.SpotifyID == "" {
|
||||
return DownloadResponse{
|
||||
Success: false,
|
||||
Error: "Spotify ID is required for Amazon Music",
|
||||
}, fmt.Errorf("spotify ID is required for Amazon Music")
|
||||
}
|
||||
filename, err = downloader.DownloadBySpotifyID(req.SpotifyID, req.OutputDir, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.CoverURL, req.ISRC, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.EmbedMaxQualityCover)
|
||||
}
|
||||
|
||||
case "tidal":
|
||||
if req.ApiURL == "" || req.ApiURL == "auto" {
|
||||
downloader := backend.NewTidalDownloader("")
|
||||
if req.ServiceURL != "" {
|
||||
// Use provided URL directly with fallback to multiple APIs
|
||||
filename, err = downloader.DownloadByURLWithFallback(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.ISRC)
|
||||
} else {
|
||||
if req.SpotifyID == "" {
|
||||
return DownloadResponse{
|
||||
Success: false,
|
||||
Error: "Spotify ID is required for Tidal",
|
||||
}, fmt.Errorf("spotify ID is required for Tidal")
|
||||
}
|
||||
// Use ISRC matching for search fallback
|
||||
filename, err = downloader.DownloadWithFallbackAndISRC(req.SpotifyID, req.ISRC, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.Duration, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks)
|
||||
}
|
||||
} else {
|
||||
downloader := backend.NewTidalDownloader(req.ApiURL)
|
||||
if req.ServiceURL != "" {
|
||||
// Use provided URL directly with specific API
|
||||
filename, err = downloader.DownloadByURL(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.ISRC)
|
||||
} else {
|
||||
if req.SpotifyID == "" {
|
||||
return DownloadResponse{
|
||||
Success: false,
|
||||
Error: "Spotify ID is required for Tidal",
|
||||
}, fmt.Errorf("spotify ID is required for Tidal")
|
||||
}
|
||||
// Use ISRC matching for search fallback
|
||||
filename, err = downloader.DownloadWithISRC(req.SpotifyID, req.ISRC, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.Duration, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks)
|
||||
}
|
||||
}
|
||||
|
||||
case "qobuz":
|
||||
downloader := backend.NewQobuzDownloader()
|
||||
// Default to "6" (FLAC 16-bit) for Qobuz if not specified
|
||||
quality := req.AudioFormat
|
||||
if quality == "" {
|
||||
quality = "6"
|
||||
}
|
||||
filename, err = downloader.DownloadByISRC(req.ISRC, req.OutputDir, quality, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks)
|
||||
|
||||
default:
|
||||
return DownloadResponse{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("Unknown service: %s", req.Service),
|
||||
}, fmt.Errorf("unknown service: %s", req.Service)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
// Clean up any partial/corrupted file that was created during failed download
|
||||
if filename != "" && !strings.HasPrefix(filename, "EXISTS:") {
|
||||
// Check if file exists and delete it
|
||||
if _, statErr := os.Stat(filename); statErr == nil {
|
||||
fmt.Printf("Removing corrupted/partial file after failed download: %s\n", filename)
|
||||
if removeErr := os.Remove(filename); removeErr != nil {
|
||||
fmt.Printf("Warning: Failed to remove corrupted file %s: %v\n", filename, removeErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Don't mark as failed in backend - let the frontend handle it
|
||||
// Frontend will call MarkDownloadItemFailed after all services are tried
|
||||
return DownloadResponse{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("Download failed: %v", err),
|
||||
ItemID: itemID,
|
||||
}, err
|
||||
}
|
||||
|
||||
// Check if file already existed
|
||||
alreadyExists := false
|
||||
if strings.HasPrefix(filename, "EXISTS:") {
|
||||
alreadyExists = true
|
||||
filename = strings.TrimPrefix(filename, "EXISTS:")
|
||||
}
|
||||
|
||||
// Embed lyrics after successful download (only for new downloads with Spotify ID and if embedLyrics is enabled)
|
||||
if !alreadyExists && req.SpotifyID != "" && req.EmbedLyrics && strings.HasSuffix(filename, ".flac") {
|
||||
go func(filePath, spotifyID, trackName, artistName string) {
|
||||
fmt.Printf("\n========== LYRICS FETCH START ==========\n")
|
||||
fmt.Printf("Spotify ID: %s\n", spotifyID)
|
||||
fmt.Printf("Track: %s\n", trackName)
|
||||
fmt.Printf("Artist: %s\n", artistName)
|
||||
fmt.Println("Searching all sources...")
|
||||
|
||||
lyricsClient := backend.NewLyricsClient()
|
||||
|
||||
// Try all sources with fallbacks
|
||||
lyricsResp, source, err := lyricsClient.FetchLyricsAllSources(spotifyID, trackName, artistName)
|
||||
if err != nil {
|
||||
fmt.Printf("All sources failed: %v\n", err)
|
||||
fmt.Printf("========== LYRICS FETCH END (FAILED) ==========\n\n")
|
||||
return
|
||||
}
|
||||
|
||||
if lyricsResp == nil || len(lyricsResp.Lines) == 0 {
|
||||
fmt.Println("No lyrics content found")
|
||||
fmt.Printf("========== LYRICS FETCH END (FAILED) ==========\n\n")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("Lyrics found from: %s\n", source)
|
||||
fmt.Printf("Sync type: %s\n", lyricsResp.SyncType)
|
||||
fmt.Printf("Total lines: %d\n", len(lyricsResp.Lines))
|
||||
|
||||
lyrics := lyricsClient.ConvertToLRC(lyricsResp, trackName, artistName)
|
||||
if lyrics == "" {
|
||||
fmt.Println("No lyrics content to embed")
|
||||
fmt.Printf("========== LYRICS FETCH END (FAILED) ==========\n\n")
|
||||
return
|
||||
}
|
||||
|
||||
// Show full lyrics in console for debugging
|
||||
fmt.Printf("\n--- Full LRC Content ---\n")
|
||||
fmt.Println(lyrics)
|
||||
fmt.Printf("--- End LRC Content ---\n\n")
|
||||
|
||||
fmt.Printf("Embedding into: %s\n", filePath)
|
||||
if err := backend.EmbedLyricsOnly(filePath, lyrics); err != nil {
|
||||
fmt.Printf("Failed to embed lyrics: %v\n", err)
|
||||
fmt.Printf("========== LYRICS FETCH END (FAILED) ==========\n\n")
|
||||
} else {
|
||||
fmt.Printf("Lyrics embedded successfully!\n")
|
||||
fmt.Printf("========== LYRICS FETCH END (SUCCESS) ==========\n\n")
|
||||
}
|
||||
}(filename, req.SpotifyID, req.TrackName, req.ArtistName)
|
||||
}
|
||||
|
||||
message := "Download completed successfully"
|
||||
if alreadyExists {
|
||||
message = "File already exists"
|
||||
backend.SkipDownloadItem(itemID, filename)
|
||||
} else {
|
||||
// Get file size for completed download
|
||||
if fileInfo, statErr := os.Stat(filename); statErr == nil {
|
||||
finalSize := float64(fileInfo.Size()) / (1024 * 1024) // Convert to MB
|
||||
backend.CompleteDownloadItem(itemID, filename, finalSize)
|
||||
} else {
|
||||
// Fallback: mark as completed without size
|
||||
backend.CompleteDownloadItem(itemID, filename, 0)
|
||||
}
|
||||
}
|
||||
|
||||
return DownloadResponse{
|
||||
Success: true,
|
||||
Message: message,
|
||||
File: filename,
|
||||
AlreadyExists: alreadyExists,
|
||||
ItemID: itemID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// OpenFolder opens a folder in the file explorer
|
||||
func (a *App) OpenFolder(path string) error {
|
||||
if path == "" {
|
||||
return fmt.Errorf("path is required")
|
||||
}
|
||||
|
||||
err := backend.OpenFolderInExplorer(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open folder: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SelectFolder opens a folder selection dialog and returns the selected path
|
||||
func (a *App) SelectFolder(defaultPath string) (string, error) {
|
||||
return backend.SelectFolderDialog(a.ctx, defaultPath)
|
||||
}
|
||||
|
||||
// SelectFile opens a file selection dialog and returns the selected file path
|
||||
func (a *App) SelectFile() (string, error) {
|
||||
return backend.SelectFileDialog(a.ctx)
|
||||
}
|
||||
|
||||
// GetDefaults returns the default configuration
|
||||
func (a *App) GetDefaults() map[string]string {
|
||||
return map[string]string{
|
||||
"downloadPath": backend.GetDefaultMusicPath(),
|
||||
}
|
||||
}
|
||||
|
||||
// GetDownloadProgress returns current download progress
|
||||
func (a *App) GetDownloadProgress() backend.ProgressInfo {
|
||||
return backend.GetDownloadProgress()
|
||||
}
|
||||
|
||||
// GetDownloadQueue returns the complete download queue state
|
||||
func (a *App) GetDownloadQueue() backend.DownloadQueueInfo {
|
||||
return backend.GetDownloadQueue()
|
||||
}
|
||||
|
||||
// ClearCompletedDownloads clears completed, failed, and skipped items from the queue
|
||||
func (a *App) ClearCompletedDownloads() {
|
||||
backend.ClearDownloadQueue()
|
||||
}
|
||||
|
||||
// ClearAllDownloads clears the entire queue and resets session stats
|
||||
func (a *App) ClearAllDownloads() {
|
||||
backend.ClearAllDownloads()
|
||||
}
|
||||
|
||||
// AddToDownloadQueue adds a new item to the download queue and returns its ID
|
||||
func (a *App) AddToDownloadQueue(isrc, trackName, artistName, albumName string) string {
|
||||
itemID := fmt.Sprintf("%s-%d", isrc, time.Now().UnixNano())
|
||||
backend.AddToQueue(itemID, trackName, artistName, albumName, isrc)
|
||||
return itemID
|
||||
}
|
||||
|
||||
// MarkDownloadItemFailed marks a download item as failed
|
||||
func (a *App) MarkDownloadItemFailed(itemID, errorMsg string) {
|
||||
backend.FailDownloadItem(itemID, errorMsg)
|
||||
}
|
||||
|
||||
// CancelAllQueuedItems marks all queued items as cancelled/skipped
|
||||
func (a *App) CancelAllQueuedItems() {
|
||||
backend.CancelAllQueuedItems()
|
||||
}
|
||||
|
||||
// Quit closes the application
|
||||
func (a *App) Quit() {
|
||||
// You can add cleanup logic here if needed
|
||||
panic("quit") // This will trigger Wails to close the app
|
||||
}
|
||||
|
||||
// AnalyzeTrack analyzes audio quality of a FLAC file
|
||||
func (a *App) AnalyzeTrack(filePath string) (string, error) {
|
||||
if filePath == "" {
|
||||
return "", fmt.Errorf("file path is required")
|
||||
}
|
||||
|
||||
result, err := backend.AnalyzeTrack(filePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to analyze track: %v", err)
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to encode response: %v", err)
|
||||
}
|
||||
|
||||
return string(jsonData), nil
|
||||
}
|
||||
|
||||
// AnalyzeMultipleTracks analyzes multiple FLAC files
|
||||
func (a *App) AnalyzeMultipleTracks(filePaths []string) (string, error) {
|
||||
if len(filePaths) == 0 {
|
||||
return "", fmt.Errorf("at least one file path is required")
|
||||
}
|
||||
|
||||
results := make([]*backend.AnalysisResult, 0, len(filePaths))
|
||||
|
||||
for _, filePath := range filePaths {
|
||||
result, err := backend.AnalyzeTrack(filePath)
|
||||
if err != nil {
|
||||
// Skip failed analyses
|
||||
continue
|
||||
}
|
||||
results = append(results, result)
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(results)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to encode response: %v", err)
|
||||
}
|
||||
|
||||
return string(jsonData), nil
|
||||
}
|
||||
|
||||
// LyricsDownloadRequest represents the request structure for downloading lyrics
|
||||
type LyricsDownloadRequest struct {
|
||||
SpotifyID string `json:"spotify_id"`
|
||||
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"`
|
||||
UseAlbumTrackNumber bool `json:"use_album_track_number"`
|
||||
DiscNumber int `json:"disc_number"`
|
||||
}
|
||||
|
||||
// DownloadLyrics downloads lyrics for a single track
|
||||
func (a *App) DownloadLyrics(req LyricsDownloadRequest) (backend.LyricsDownloadResponse, error) {
|
||||
if req.SpotifyID == "" {
|
||||
return backend.LyricsDownloadResponse{
|
||||
Success: false,
|
||||
Error: "Spotify ID is required",
|
||||
}, fmt.Errorf("spotify ID is required")
|
||||
}
|
||||
|
||||
client := backend.NewLyricsClient()
|
||||
backendReq := backend.LyricsDownloadRequest{
|
||||
SpotifyID: req.SpotifyID,
|
||||
TrackName: req.TrackName,
|
||||
ArtistName: req.ArtistName,
|
||||
AlbumName: req.AlbumName,
|
||||
AlbumArtist: req.AlbumArtist,
|
||||
ReleaseDate: req.ReleaseDate,
|
||||
OutputDir: req.OutputDir,
|
||||
FilenameFormat: req.FilenameFormat,
|
||||
TrackNumber: req.TrackNumber,
|
||||
Position: req.Position,
|
||||
UseAlbumTrackNumber: req.UseAlbumTrackNumber,
|
||||
DiscNumber: req.DiscNumber,
|
||||
}
|
||||
|
||||
resp, err := client.DownloadLyrics(backendReq)
|
||||
if err != nil {
|
||||
return backend.LyricsDownloadResponse{
|
||||
Success: false,
|
||||
Error: err.Error(),
|
||||
}, err
|
||||
}
|
||||
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
// CoverDownloadRequest represents the request structure for downloading cover art
|
||||
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"`
|
||||
}
|
||||
|
||||
// DownloadCover downloads cover art for a single track
|
||||
func (a *App) DownloadCover(req CoverDownloadRequest) (backend.CoverDownloadResponse, error) {
|
||||
if req.CoverURL == "" {
|
||||
return backend.CoverDownloadResponse{
|
||||
Success: false,
|
||||
Error: "Cover URL is required",
|
||||
}, fmt.Errorf("cover URL is required")
|
||||
}
|
||||
|
||||
client := backend.NewCoverClient()
|
||||
backendReq := backend.CoverDownloadRequest{
|
||||
CoverURL: req.CoverURL,
|
||||
TrackName: req.TrackName,
|
||||
ArtistName: req.ArtistName,
|
||||
AlbumName: req.AlbumName,
|
||||
AlbumArtist: req.AlbumArtist,
|
||||
ReleaseDate: req.ReleaseDate,
|
||||
OutputDir: req.OutputDir,
|
||||
FilenameFormat: req.FilenameFormat,
|
||||
TrackNumber: req.TrackNumber,
|
||||
Position: req.Position,
|
||||
DiscNumber: req.DiscNumber,
|
||||
}
|
||||
|
||||
resp, err := client.DownloadCover(backendReq)
|
||||
if err != nil {
|
||||
return backend.CoverDownloadResponse{
|
||||
Success: false,
|
||||
Error: err.Error(),
|
||||
}, err
|
||||
}
|
||||
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
// CheckTrackAvailability checks the availability of a track on different streaming platforms
|
||||
func (a *App) CheckTrackAvailability(spotifyTrackID string, isrc string) (string, error) {
|
||||
if spotifyTrackID == "" {
|
||||
return "", fmt.Errorf("spotify track ID is required")
|
||||
}
|
||||
|
||||
client := backend.NewSongLinkClient()
|
||||
availability, err := client.CheckTrackAvailability(spotifyTrackID, isrc)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(availability)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to encode response: %v", err)
|
||||
}
|
||||
|
||||
return string(jsonData), nil
|
||||
}
|
||||
|
||||
// IsFFmpegInstalled checks if ffmpeg is installed
|
||||
func (a *App) IsFFmpegInstalled() (bool, error) {
|
||||
return backend.IsFFmpegInstalled()
|
||||
}
|
||||
|
||||
// IsFFprobeInstalled checks if ffprobe is installed
|
||||
func (a *App) IsFFprobeInstalled() (bool, error) {
|
||||
return backend.IsFFprobeInstalled()
|
||||
}
|
||||
|
||||
// GetFFmpegPath returns the path to ffmpeg
|
||||
func (a *App) GetFFmpegPath() (string, error) {
|
||||
return backend.GetFFmpegPath()
|
||||
}
|
||||
|
||||
// DownloadFFmpegRequest represents a request to download ffmpeg
|
||||
type DownloadFFmpegRequest struct{}
|
||||
|
||||
// DownloadFFmpegResponse represents the response from downloading ffmpeg
|
||||
type DownloadFFmpegResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// DownloadFFmpeg downloads and installs ffmpeg
|
||||
func (a *App) DownloadFFmpeg() DownloadFFmpegResponse {
|
||||
err := backend.DownloadFFmpeg(func(progress int) {
|
||||
fmt.Printf("[FFmpeg] Download progress: %d%%\n", progress)
|
||||
})
|
||||
if err != nil {
|
||||
return DownloadFFmpegResponse{
|
||||
Success: false,
|
||||
Error: err.Error(),
|
||||
}
|
||||
}
|
||||
|
||||
return DownloadFFmpegResponse{
|
||||
Success: true,
|
||||
Message: "FFmpeg installed successfully",
|
||||
}
|
||||
}
|
||||
|
||||
// ConvertAudioRequest represents a request to convert audio files
|
||||
type ConvertAudioRequest struct {
|
||||
InputFiles []string `json:"input_files"`
|
||||
OutputFormat string `json:"output_format"`
|
||||
Bitrate string `json:"bitrate"`
|
||||
Codec string `json:"codec"` // For m4a: "aac" (lossy) or "alac" (lossless)
|
||||
}
|
||||
|
||||
// ConvertAudio converts audio files using ffmpeg
|
||||
func (a *App) ConvertAudio(req ConvertAudioRequest) ([]backend.ConvertAudioResult, error) {
|
||||
backendReq := backend.ConvertAudioRequest{
|
||||
InputFiles: req.InputFiles,
|
||||
OutputFormat: req.OutputFormat,
|
||||
Bitrate: req.Bitrate,
|
||||
Codec: req.Codec,
|
||||
}
|
||||
return backend.ConvertAudio(backendReq)
|
||||
}
|
||||
|
||||
// SelectAudioFiles opens a file dialog to select audio files for conversion
|
||||
func (a *App) SelectAudioFiles() ([]string, error) {
|
||||
files, err := backend.SelectMultipleFiles(a.ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
// GetFileSizes returns file sizes for a list of file paths
|
||||
func (a *App) GetFileSizes(files []string) map[string]int64 {
|
||||
return backend.GetFileSizes(files)
|
||||
}
|
||||
|
||||
// ListDirectoryFiles lists files and folders in a directory
|
||||
func (a *App) ListDirectoryFiles(dirPath string) ([]backend.FileInfo, error) {
|
||||
if dirPath == "" {
|
||||
return nil, fmt.Errorf("directory path is required")
|
||||
}
|
||||
return backend.ListDirectory(dirPath)
|
||||
}
|
||||
|
||||
// ListAudioFilesInDir lists only audio files in a directory recursively
|
||||
func (a *App) ListAudioFilesInDir(dirPath string) ([]backend.FileInfo, error) {
|
||||
if dirPath == "" {
|
||||
return nil, fmt.Errorf("directory path is required")
|
||||
}
|
||||
return backend.ListAudioFiles(dirPath)
|
||||
}
|
||||
|
||||
// ReadFileMetadata reads metadata from an audio file
|
||||
func (a *App) ReadFileMetadata(filePath string) (*backend.AudioMetadata, error) {
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("file path is required")
|
||||
}
|
||||
return backend.ReadAudioMetadata(filePath)
|
||||
}
|
||||
|
||||
// PreviewRenameFiles generates a preview of rename operations
|
||||
func (a *App) PreviewRenameFiles(files []string, format string) []backend.RenamePreview {
|
||||
return backend.PreviewRename(files, format)
|
||||
}
|
||||
|
||||
// RenameFilesByMetadata renames files based on their metadata
|
||||
func (a *App) RenameFilesByMetadata(files []string, format string) []backend.RenameResult {
|
||||
return backend.RenameFiles(files, format)
|
||||
}
|
||||
|
||||
// ReadTextFile reads a text file and returns its content
|
||||
func (a *App) ReadTextFile(filePath string) (string, error) {
|
||||
content, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(content), nil
|
||||
}
|
||||
|
||||
// RenameFileTo renames a file to a new name (keeping same directory)
|
||||
func (a *App) RenameFileTo(oldPath, newName string) error {
|
||||
dir := filepath.Dir(oldPath)
|
||||
ext := filepath.Ext(oldPath)
|
||||
newPath := filepath.Join(dir, newName+ext)
|
||||
return os.Rename(oldPath, newPath)
|
||||
}
|
||||
|
||||
// ReadImageAsBase64 reads an image file and returns it as base64 data URL
|
||||
func (a *App) ReadImageAsBase64(filePath string) (string, error) {
|
||||
content, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(filePath))
|
||||
var mimeType string
|
||||
switch ext {
|
||||
case ".jpg", ".jpeg":
|
||||
mimeType = "image/jpeg"
|
||||
case ".png":
|
||||
mimeType = "image/png"
|
||||
case ".gif":
|
||||
mimeType = "image/gif"
|
||||
case ".webp":
|
||||
mimeType = "image/webp"
|
||||
default:
|
||||
mimeType = "image/jpeg"
|
||||
}
|
||||
|
||||
encoded := base64.StdEncoding.EncodeToString(content)
|
||||
return fmt.Sprintf("data:%s;base64,%s", mimeType, encoded), nil
|
||||
}
|
||||
|
||||
// CheckFileExistenceRequest represents a track to check for existence
|
||||
type CheckFileExistenceRequest struct {
|
||||
ISRC string `json:"isrc"`
|
||||
TrackName string `json:"track_name"`
|
||||
ArtistName string `json:"artist_name"`
|
||||
}
|
||||
|
||||
// CheckFilesExistence checks if multiple files already exist in the output directory
|
||||
// This is done in parallel for better performance
|
||||
func (a *App) CheckFilesExistence(outputDir string, tracks []CheckFileExistenceRequest) []backend.FileExistenceResult {
|
||||
// Convert to backend struct format
|
||||
backendTracks := make([]struct {
|
||||
ISRC string
|
||||
TrackName string
|
||||
ArtistName string
|
||||
}, len(tracks))
|
||||
|
||||
for i, t := range tracks {
|
||||
backendTracks[i] = struct {
|
||||
ISRC string
|
||||
TrackName string
|
||||
ArtistName string
|
||||
}{
|
||||
ISRC: t.ISRC,
|
||||
TrackName: t.TrackName,
|
||||
ArtistName: t.ArtistName,
|
||||
}
|
||||
}
|
||||
|
||||
return backend.CheckFilesExistParallel(outputDir, backendTracks)
|
||||
}
|
||||
|
||||
// SkipDownloadItem marks a download item as skipped (file already exists)
|
||||
func (a *App) SkipDownloadItem(itemID, filePath string) {
|
||||
backend.SkipDownloadItem(itemID, filePath)
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-au" viewBox="0 0 640 480">
|
||||
<path fill="#00008B" d="M0 0h640v480H0z"/>
|
||||
<path fill="#fff" d="m37.5 0 122 90.5L281 0h39v31l-120 89.5 120 89V240h-40l-120-89.5L40.5 240H0v-30l119.5-89L0 32V0z"/>
|
||||
<path fill="red" d="M212 140.5 320 220v20l-135.5-99.5zm-92 10 3 17.5-96 72H0zM320 0v1.5l-124.5 94 1-22L295 0zM0 0l119.5 88h-30L0 21z"/>
|
||||
<path fill="#fff" d="M120.5 0v240h80V0zM0 80v80h320V80z"/>
|
||||
<path fill="red" d="M0 96.5v48h320v-48zM136.5 0v240h48V0z"/>
|
||||
<path fill="#fff" d="m527 396.7-20.5 2.6 2.2 20.5-14.8-14.4-14.7 14.5 2-20.5-20.5-2.4 17.3-11.2-10.9-17.5 19.6 6.5 6.9-19.5 7.1 19.4 19.5-6.7-10.7 17.6zm-3.7-117.2 2.7-13-9.8-9 13.2-1.5 5.5-12.1 5.5 12.1 13.2 1.5-9.8 9 2.7 13-11.6-6.6zm-104.1-60-20.3 2.2 1.8 20.3-14.4-14.5-14.8 14.1 2.4-20.3-20.2-2.7 17.3-10.8-10.5-17.5 19.3 6.8L387 178l6.7 19.3 19.4-6.3-10.9 17.3 17.1 11.2ZM623 186.7l-20.9 2.7 2.3 20.9-15.1-14.7-15 14.8 2.1-21-20.9-2.4 17.7-11.5-11.1-17.9 20 6.7 7-19.8 7.2 19.8 19.9-6.9-11 18zm-96.1-83.5-20.7 2.3 1.9 20.8-14.7-14.8-15.1 14.4 2.4-20.7-20.7-2.8 17.7-11L467 73.5l19.7 6.9 7.3-19.5 6.8 19.7 19.8-6.5-11.1 17.6zM234 385.7l-45.8 5.4 4.6 45.9-32.8-32.4-33 32.2 4.9-45.9-45.8-5.8 38.9-24.8-24-39.4 43.6 15 15.8-43.4 15.5 43.5 43.7-14.7-24.3 39.2 38.8 25.1Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,532 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type AmazonDownloader struct {
|
||||
client *http.Client
|
||||
regions []string
|
||||
lastAPICallTime time.Time
|
||||
apiCallCount int
|
||||
apiCallResetTime time.Time
|
||||
}
|
||||
|
||||
type SongLinkResponse struct {
|
||||
LinksByPlatform map[string]struct {
|
||||
URL string `json:"url"`
|
||||
} `json:"linksByPlatform"`
|
||||
}
|
||||
|
||||
type DoubleDoubleSubmitResponse struct {
|
||||
Success bool `json:"success"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
type DoubleDoubleStatusResponse struct {
|
||||
Status string `json:"status"`
|
||||
FriendlyStatus string `json:"friendlyStatus"`
|
||||
URL string `json:"url"`
|
||||
Current struct {
|
||||
Name string `json:"name"`
|
||||
Artist string `json:"artist"`
|
||||
} `json:"current"`
|
||||
}
|
||||
|
||||
func NewAmazonDownloader() *AmazonDownloader {
|
||||
return &AmazonDownloader{
|
||||
client: &http.Client{
|
||||
Timeout: 120 * time.Second,
|
||||
},
|
||||
regions: []string{"us", "eu"},
|
||||
apiCallResetTime: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
func (a *AmazonDownloader) getRandomUserAgent() string {
|
||||
return fmt.Sprintf("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_%d_%d) AppleWebKit/%d.%d (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/%d.%d",
|
||||
rand.Intn(4)+11, rand.Intn(5)+4,
|
||||
rand.Intn(7)+530, rand.Intn(7)+30,
|
||||
rand.Intn(25)+80, rand.Intn(1500)+3000, rand.Intn(65)+60,
|
||||
rand.Intn(7)+530, rand.Intn(6)+30)
|
||||
}
|
||||
|
||||
func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (string, error) {
|
||||
// Rate limiting: max 10 requests per minute (song.link API limit)
|
||||
// Reset counter every minute
|
||||
now := time.Now()
|
||||
if now.Sub(a.apiCallResetTime) >= time.Minute {
|
||||
a.apiCallCount = 0
|
||||
a.apiCallResetTime = now
|
||||
}
|
||||
|
||||
// If we've hit the limit, wait until the next minute
|
||||
if a.apiCallCount >= 9 { // Use 9 to be safe (limit is 10)
|
||||
waitTime := time.Minute - now.Sub(a.apiCallResetTime)
|
||||
if waitTime > 0 {
|
||||
fmt.Printf("Rate limit reached, waiting %v...\n", waitTime.Round(time.Second))
|
||||
time.Sleep(waitTime)
|
||||
a.apiCallCount = 0
|
||||
a.apiCallResetTime = time.Now()
|
||||
}
|
||||
}
|
||||
|
||||
// Add delay between requests (6 seconds = 10 requests per minute)
|
||||
if !a.lastAPICallTime.IsZero() {
|
||||
timeSinceLastCall := now.Sub(a.lastAPICallTime)
|
||||
minDelay := 7 * time.Second // 7 seconds to be safe
|
||||
if timeSinceLastCall < minDelay {
|
||||
waitTime := minDelay - timeSinceLastCall
|
||||
fmt.Printf("Rate limiting: waiting %v...\n", waitTime.Round(time.Second))
|
||||
time.Sleep(waitTime)
|
||||
}
|
||||
}
|
||||
|
||||
// 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), url.QueryEscape(spotifyURL))
|
||||
|
||||
req, err := http.NewRequest("GET", apiURL, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", a.getRandomUserAgent())
|
||||
|
||||
fmt.Println("Getting Amazon URL...")
|
||||
|
||||
// Retry logic for rate limit errors
|
||||
maxRetries := 3
|
||||
var resp *http.Response
|
||||
for i := 0; i < maxRetries; i++ {
|
||||
resp, err = a.client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get Amazon URL: %w", err)
|
||||
}
|
||||
|
||||
// Update rate limit tracking
|
||||
a.lastAPICallTime = time.Now()
|
||||
a.apiCallCount++
|
||||
|
||||
if resp.StatusCode == 429 { // Too Many Requests
|
||||
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()
|
||||
|
||||
// Read body first to handle encoding issues and provide better error messages
|
||||
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
|
||||
if err := json.Unmarshal(body, &songLinkResp); err != nil {
|
||||
// Truncate body for error message (max 200 chars)
|
||||
bodyStr := string(body)
|
||||
if len(bodyStr) > 200 {
|
||||
bodyStr = bodyStr[:200] + "..."
|
||||
}
|
||||
return "", fmt.Errorf("failed to decode response: %w (response: %s)", err, bodyStr)
|
||||
}
|
||||
|
||||
amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]
|
||||
if !ok || amazonLink.URL == "" {
|
||||
return "", fmt.Errorf("amazon Music link not found")
|
||||
}
|
||||
|
||||
amazonURL := amazonLink.URL
|
||||
|
||||
// Convert album URL to track URL if needed
|
||||
if strings.Contains(amazonURL, "trackAsin=") {
|
||||
parts := strings.Split(amazonURL, "trackAsin=")
|
||||
if len(parts) > 1 {
|
||||
trackAsin := strings.Split(parts[1], "&")[0]
|
||||
musicBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9tdXNpYy5hbWF6b24uY29tL3RyYWNrcy8=")
|
||||
amazonURL = fmt.Sprintf("%s%s?musicTerritory=US", string(musicBase), trackAsin)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("Found Amazon URL: %s\n", amazonURL)
|
||||
return amazonURL, nil
|
||||
}
|
||||
|
||||
func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir string) (string, error) {
|
||||
var lastError error
|
||||
|
||||
for _, region := range a.regions {
|
||||
fmt.Printf("\nTrying region: %s...\n", region)
|
||||
// Decode base64 service URL
|
||||
serviceBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly8=")
|
||||
serviceDomain, _ := base64.StdEncoding.DecodeString("LmRvdWJsZWRvdWJsZS50b3A=")
|
||||
baseURL := fmt.Sprintf("%s%s%s", string(serviceBase), region, string(serviceDomain))
|
||||
|
||||
// Step 1: Submit download request
|
||||
encodedURL := url.QueryEscape(amazonURL)
|
||||
submitURL := fmt.Sprintf("%s/dl?url=%s", baseURL, encodedURL)
|
||||
|
||||
req, err := http.NewRequest("GET", submitURL, nil)
|
||||
if err != nil {
|
||||
lastError = fmt.Errorf("failed to create request: %w", err)
|
||||
continue
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", a.getRandomUserAgent())
|
||||
|
||||
fmt.Println("Submitting download request...")
|
||||
resp, err := a.client.Do(req)
|
||||
if err != nil {
|
||||
lastError = fmt.Errorf("failed to submit request: %w", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
resp.Body.Close()
|
||||
lastError = fmt.Errorf("submit failed with status %d", resp.StatusCode)
|
||||
continue
|
||||
}
|
||||
|
||||
var submitResp DoubleDoubleSubmitResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&submitResp); err != nil {
|
||||
resp.Body.Close()
|
||||
lastError = fmt.Errorf("failed to decode submit response: %w", err)
|
||||
continue
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if !submitResp.Success || submitResp.ID == "" {
|
||||
lastError = fmt.Errorf("submit request failed")
|
||||
continue
|
||||
}
|
||||
|
||||
downloadID := submitResp.ID
|
||||
fmt.Printf("Download ID: %s\n", downloadID)
|
||||
|
||||
// Step 2: Poll for completion
|
||||
statusURL := fmt.Sprintf("%s/dl/%s", baseURL, downloadID)
|
||||
fmt.Println("Waiting for download to complete...")
|
||||
|
||||
maxWait := 300 * time.Second
|
||||
elapsed := time.Duration(0)
|
||||
pollInterval := 3 * time.Second
|
||||
|
||||
for elapsed < maxWait {
|
||||
time.Sleep(pollInterval)
|
||||
elapsed += pollInterval
|
||||
|
||||
statusReq, err := http.NewRequest("GET", statusURL, nil)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
statusReq.Header.Set("User-Agent", a.getRandomUserAgent())
|
||||
|
||||
statusResp, err := a.client.Do(statusReq)
|
||||
if err != nil {
|
||||
fmt.Printf("\rStatus check failed, retrying...")
|
||||
continue
|
||||
}
|
||||
|
||||
if statusResp.StatusCode != 200 {
|
||||
statusResp.Body.Close()
|
||||
fmt.Printf("\rStatus check failed (status %d), retrying...", statusResp.StatusCode)
|
||||
continue
|
||||
}
|
||||
|
||||
var status DoubleDoubleStatusResponse
|
||||
if err := json.NewDecoder(statusResp.Body).Decode(&status); err != nil {
|
||||
statusResp.Body.Close()
|
||||
fmt.Printf("\rInvalid JSON response, retrying...")
|
||||
continue
|
||||
}
|
||||
statusResp.Body.Close()
|
||||
|
||||
if status.Status == "done" {
|
||||
fmt.Println("\nDownload ready!")
|
||||
|
||||
// Build download URL
|
||||
fileURL := status.URL
|
||||
if strings.HasPrefix(fileURL, "./") {
|
||||
fileURL = fmt.Sprintf("%s/%s", baseURL, fileURL[2:])
|
||||
} else if strings.HasPrefix(fileURL, "/") {
|
||||
fileURL = fmt.Sprintf("%s%s", baseURL, fileURL)
|
||||
}
|
||||
|
||||
trackName := status.Current.Name
|
||||
artist := status.Current.Artist
|
||||
|
||||
fmt.Printf("Downloading: %s - %s\n", artist, trackName)
|
||||
|
||||
// Download file
|
||||
downloadReq, err := http.NewRequest("GET", fileURL, nil)
|
||||
if err != nil {
|
||||
lastError = fmt.Errorf("failed to create download request: %w", err)
|
||||
break
|
||||
}
|
||||
|
||||
downloadReq.Header.Set("User-Agent", a.getRandomUserAgent())
|
||||
|
||||
fileResp, err := a.client.Do(downloadReq)
|
||||
if err != nil {
|
||||
lastError = fmt.Errorf("failed to download file: %w", err)
|
||||
break
|
||||
}
|
||||
defer fileResp.Body.Close()
|
||||
|
||||
if fileResp.StatusCode != 200 {
|
||||
lastError = fmt.Errorf("download failed with status %d", fileResp.StatusCode)
|
||||
break
|
||||
}
|
||||
|
||||
// Generate filename
|
||||
fileName := fmt.Sprintf("%s - %s.flac", artist, trackName)
|
||||
for _, char := range `<>:"/\|?*` {
|
||||
fileName = strings.ReplaceAll(fileName, string(char), "")
|
||||
}
|
||||
fileName = strings.TrimSpace(fileName)
|
||||
|
||||
filePath := filepath.Join(outputDir, fileName)
|
||||
|
||||
// Save file
|
||||
out, err := os.Create(filePath)
|
||||
if err != nil {
|
||||
lastError = fmt.Errorf("failed to create file: %w", err)
|
||||
break
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
fmt.Println("Downloading...")
|
||||
// Use progress writer to track download
|
||||
pw := NewProgressWriter(out)
|
||||
_, err = io.Copy(pw, fileResp.Body)
|
||||
if err != nil {
|
||||
out.Close()
|
||||
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.Println("Download complete!")
|
||||
return filePath, nil
|
||||
|
||||
} else if status.Status == "error" {
|
||||
errorMsg := status.FriendlyStatus
|
||||
if errorMsg == "" {
|
||||
errorMsg = "Unknown error"
|
||||
}
|
||||
lastError = fmt.Errorf("processing failed: %s", errorMsg)
|
||||
break
|
||||
} else {
|
||||
// Still processing
|
||||
friendlyStatus := status.FriendlyStatus
|
||||
if friendlyStatus == "" {
|
||||
friendlyStatus = status.Status
|
||||
}
|
||||
fmt.Printf("\r%s...", friendlyStatus)
|
||||
}
|
||||
}
|
||||
|
||||
if elapsed >= maxWait {
|
||||
lastError = fmt.Errorf("download timeout")
|
||||
fmt.Printf("\nError with %s region: %v\n", region, lastError)
|
||||
continue
|
||||
}
|
||||
|
||||
if lastError != nil {
|
||||
fmt.Printf("\nError with %s region: %v\n", region, lastError)
|
||||
}
|
||||
}
|
||||
|
||||
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, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, spotifyISRC string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool) (string, error) {
|
||||
// Create output directory if needed
|
||||
if outputDir != "." {
|
||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||
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 != "" {
|
||||
expectedFilename := BuildExpectedFilename(spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, filenameFormat, includeTrackNumber, position, spotifyDiscNumber, false)
|
||||
expectedPath := filepath.Join(outputDir, expectedFilename)
|
||||
|
||||
if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 0 {
|
||||
fmt.Printf("File already exists: %s (%.2f MB)\n", expectedPath, float64(fileInfo.Size())/(1024*1024))
|
||||
return "EXISTS:" + expectedPath, nil
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("Using Amazon URL: %s\n", amazonURL)
|
||||
|
||||
// Download from service
|
||||
filePath, err := a.DownloadFromService(amazonURL, outputDir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Rename file based on Spotify metadata
|
||||
if spotifyTrackName != "" && spotifyArtistName != "" {
|
||||
safeArtist := sanitizeFilename(spotifyArtistName)
|
||||
safeTitle := sanitizeFilename(spotifyTrackName)
|
||||
safeAlbum := sanitizeFilename(spotifyAlbumName)
|
||||
safeAlbumArtist := sanitizeFilename(spotifyAlbumArtist)
|
||||
|
||||
// Extract year from release date
|
||||
year := ""
|
||||
if len(spotifyReleaseDate) >= 4 {
|
||||
year = spotifyReleaseDate[:4]
|
||||
}
|
||||
|
||||
// Build filename based on format settings
|
||||
var newFilename string
|
||||
|
||||
// Check if format is a template (contains {})
|
||||
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)
|
||||
|
||||
// Handle disc number
|
||||
if spotifyDiscNumber > 0 {
|
||||
newFilename = strings.ReplaceAll(newFilename, "{disc}", fmt.Sprintf("%d", spotifyDiscNumber))
|
||||
} else {
|
||||
newFilename = strings.ReplaceAll(newFilename, "{disc}", "")
|
||||
}
|
||||
|
||||
// Handle track number - if position is 0, remove {track} and surrounding separators
|
||||
if position > 0 {
|
||||
newFilename = strings.ReplaceAll(newFilename, "{track}", fmt.Sprintf("%02d", position))
|
||||
} else {
|
||||
// Remove {track} with common separators
|
||||
newFilename = regexp.MustCompile(`\{track\}\.\s*`).ReplaceAllString(newFilename, "")
|
||||
newFilename = regexp.MustCompile(`\{track\}\s*-\s*`).ReplaceAllString(newFilename, "")
|
||||
newFilename = regexp.MustCompile(`\{track\}\s*`).ReplaceAllString(newFilename, "")
|
||||
}
|
||||
} else {
|
||||
// Legacy format support
|
||||
switch filenameFormat {
|
||||
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 (legacy behavior)
|
||||
if includeTrackNumber && position > 0 {
|
||||
newFilename = fmt.Sprintf("%02d. %s", position, newFilename)
|
||||
}
|
||||
}
|
||||
|
||||
newFilename = newFilename + ".flac"
|
||||
newFilePath := filepath.Join(outputDir, newFilename)
|
||||
|
||||
// Rename file
|
||||
if err := os.Rename(filePath, newFilePath); err != nil {
|
||||
fmt.Printf("Warning: Failed to rename file: %v\n", err)
|
||||
} else {
|
||||
filePath = newFilePath
|
||||
fmt.Printf("Renamed to: %s\n", newFilename)
|
||||
}
|
||||
}
|
||||
|
||||
// Embed Spotify metadata (replace Amazon's embedded metadata)
|
||||
fmt.Println("Embedding Spotify metadata...")
|
||||
|
||||
coverPath := ""
|
||||
// Download Spotify cover (with max resolution if enabled)
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
// Determine track number to embed
|
||||
// Use Spotify track number (album track number) if available, otherwise use position
|
||||
trackNumberToEmbed := spotifyTrackNumber
|
||||
if trackNumberToEmbed == 0 {
|
||||
trackNumberToEmbed = position // Fallback to playlist position
|
||||
}
|
||||
if trackNumberToEmbed == 0 {
|
||||
trackNumberToEmbed = 1 // Default to track 1 for single track downloads without track number
|
||||
}
|
||||
|
||||
// Build metadata from Spotify
|
||||
metadata := Metadata{
|
||||
Title: spotifyTrackName,
|
||||
Artist: spotifyArtistName,
|
||||
Album: spotifyAlbumName,
|
||||
AlbumArtist: spotifyAlbumArtist,
|
||||
Date: spotifyReleaseDate, // Recorded date (full date YYYY-MM-DD)
|
||||
TrackNumber: trackNumberToEmbed,
|
||||
TotalTracks: spotifyTotalTracks, // Total tracks in album from Spotify
|
||||
DiscNumber: spotifyDiscNumber, // Disc number from Spotify
|
||||
ISRC: spotifyISRC, // Use ISRC from Spotify
|
||||
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("✓ Downloaded successfully from Amazon Music")
|
||||
return filePath, nil
|
||||
}
|
||||
|
||||
func (a *AmazonDownloader) DownloadBySpotifyID(spotifyTrackID, outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, spotifyISRC string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool) (string, error) {
|
||||
// Get Amazon URL from Spotify track ID
|
||||
amazonURL, err := a.GetAmazonURLFromSpotify(spotifyTrackID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return a.DownloadByURL(amazonURL, outputDir, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, spotifyISRC, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, embedMaxQualityCover)
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
|
||||
"github.com/go-flac/go-flac"
|
||||
mewflac "github.com/mewkiz/flac"
|
||||
)
|
||||
|
||||
// AnalysisResult contains the audio analysis data
|
||||
type AnalysisResult struct {
|
||||
FilePath string `json:"file_path"`
|
||||
FileSize int64 `json:"file_size"`
|
||||
SampleRate uint32 `json:"sample_rate"`
|
||||
Channels uint8 `json:"channels"`
|
||||
BitsPerSample uint8 `json:"bits_per_sample"`
|
||||
TotalSamples uint64 `json:"total_samples"`
|
||||
Duration float64 `json:"duration"`
|
||||
BitDepth string `json:"bit_depth"`
|
||||
DynamicRange float64 `json:"dynamic_range"`
|
||||
PeakAmplitude float64 `json:"peak_amplitude"`
|
||||
RMSLevel float64 `json:"rms_level"`
|
||||
Spectrum *SpectrumData `json:"spectrum,omitempty"`
|
||||
}
|
||||
|
||||
// AnalyzeTrack performs audio analysis on a FLAC file
|
||||
func AnalyzeTrack(filepath string) (*AnalysisResult, error) {
|
||||
if !fileExists(filepath) {
|
||||
return nil, fmt.Errorf("file does not exist: %s", filepath)
|
||||
}
|
||||
|
||||
// Get file size
|
||||
fileInfo, err := os.Stat(filepath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get file info: %w", err)
|
||||
}
|
||||
|
||||
// Parse FLAC file
|
||||
f, err := flac.ParseFile(filepath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse FLAC file: %w", err)
|
||||
}
|
||||
|
||||
result := &AnalysisResult{
|
||||
FilePath: filepath,
|
||||
FileSize: fileInfo.Size(),
|
||||
}
|
||||
|
||||
// Extract basic audio properties from STREAMINFO block
|
||||
if len(f.Meta) > 0 {
|
||||
streamInfo := f.Meta[0]
|
||||
if streamInfo.Type == flac.StreamInfo {
|
||||
// Read STREAMINFO data
|
||||
data := streamInfo.Data
|
||||
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
|
||||
|
||||
// Channels (bits 30-32 of byte 12)
|
||||
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
|
||||
|
||||
// Total samples (bits 38-73 of bytes 13-17)
|
||||
result.TotalSamples = uint64(data[13]&0x0F)<<32 |
|
||||
uint64(data[14])<<24 |
|
||||
uint64(data[15])<<16 |
|
||||
uint64(data[16])<<8 |
|
||||
uint64(data[17])
|
||||
|
||||
// Calculate duration
|
||||
if result.SampleRate > 0 {
|
||||
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)
|
||||
if err != nil {
|
||||
// Log error but continue
|
||||
fmt.Printf("Warning: failed to analyze spectrum: %v\n", err)
|
||||
} else {
|
||||
result.Spectrum = spectrum
|
||||
// Calculate dynamic range, peak, and RMS from decoded samples
|
||||
calculateRealAudioMetrics(result, filepath)
|
||||
}
|
||||
|
||||
// Set bit depth
|
||||
result.BitDepth = fmt.Sprintf("%d-bit", result.BitsPerSample)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// calculateRealAudioMetrics calculates actual dynamic range, peak, and RMS from decoded audio
|
||||
func calculateRealAudioMetrics(result *AnalysisResult, filepath string) {
|
||||
// Decode FLAC to get actual samples
|
||||
samples, err := decodeFLACForMetrics(filepath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate peak amplitude
|
||||
var peak float64
|
||||
var sumSquares float64
|
||||
|
||||
for _, sample := range samples {
|
||||
absVal := sample
|
||||
if absVal < 0 {
|
||||
absVal = -absVal
|
||||
}
|
||||
if absVal > peak {
|
||||
peak = absVal
|
||||
}
|
||||
sumSquares += sample * sample
|
||||
}
|
||||
|
||||
// Convert peak to dB (reference: 1.0 = 0 dBFS)
|
||||
peakDB := 20.0 * math.Log10(peak)
|
||||
result.PeakAmplitude = peakDB
|
||||
|
||||
// Calculate RMS (Root Mean Square)
|
||||
rms := math.Sqrt(sumSquares / float64(len(samples)))
|
||||
rmsDB := 20.0 * math.Log10(rms)
|
||||
result.RMSLevel = rmsDB
|
||||
|
||||
// Dynamic range is the difference between peak and RMS
|
||||
result.DynamicRange = peakDB - rmsDB
|
||||
}
|
||||
|
||||
// decodeFLACForMetrics decodes FLAC file and returns normalized samples for metric calculation
|
||||
func decodeFLACForMetrics(filepath string) ([]float64, error) {
|
||||
stream, err := mewflac.ParseFile(filepath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer stream.Close()
|
||||
|
||||
// Limit samples to prevent memory issues (10 million samples = ~3.8 minutes at 44.1kHz)
|
||||
maxSamples := 10000000
|
||||
samples := make([]float64, 0, maxSamples)
|
||||
|
||||
// Read all audio frames
|
||||
for {
|
||||
frame, err := stream.ParseNext()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
// Get samples from first channel (mono or left channel)
|
||||
var channelSamples []int32
|
||||
if len(frame.Subframes) > 0 {
|
||||
channelSamples = frame.Subframes[0].Samples
|
||||
}
|
||||
|
||||
// Normalize samples to -1.0 to 1.0 range
|
||||
maxVal := float64(int64(1) << (stream.Info.BitsPerSample - 1))
|
||||
for _, sample := range channelSamples {
|
||||
if len(samples) >= maxSamples {
|
||||
return samples, nil
|
||||
}
|
||||
normalized := float64(sample) / maxVal
|
||||
samples = append(samples, normalized)
|
||||
}
|
||||
|
||||
if len(samples) >= maxSamples {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return samples, nil
|
||||
}
|
||||
|
||||
func GetFileSize(filepath string) (int64, error) {
|
||||
info, err := os.Stat(filepath)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return info.Size(), nil
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func GetDefaultMusicPath() string {
|
||||
// Get user's home directory
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
// Fallback to Public Music if can't get home dir
|
||||
return "C:\\Users\\Public\\Music"
|
||||
}
|
||||
|
||||
// Return path to user's Music folder
|
||||
return filepath.Join(homeDir, "Music")
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// Spotify image size codes
|
||||
spotifySize640 = "ab67616d0000b273" // 640x640
|
||||
spotifySizeMax = "ab67616d000082c1" // Max resolution
|
||||
)
|
||||
|
||||
// CoverDownloadRequest represents a request to download cover art
|
||||
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"`
|
||||
}
|
||||
|
||||
// CoverDownloadResponse represents the response from cover download
|
||||
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"`
|
||||
}
|
||||
|
||||
// CoverClient handles cover art downloading
|
||||
type CoverClient struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewCoverClient creates a new cover client
|
||||
func NewCoverClient() *CoverClient {
|
||||
return &CoverClient{
|
||||
httpClient: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// buildCoverFilename builds the cover filename based on settings (same as track filename)
|
||||
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)
|
||||
|
||||
// Extract year from release date (format: YYYY-MM-DD or YYYY)
|
||||
year := ""
|
||||
if len(releaseDate) >= 4 {
|
||||
year = releaseDate[:4]
|
||||
}
|
||||
|
||||
var filename string
|
||||
|
||||
// Check if format is a template (contains {})
|
||||
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)
|
||||
|
||||
// Handle disc number
|
||||
if discNumber > 0 {
|
||||
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
|
||||
} else {
|
||||
filename = strings.ReplaceAll(filename, "{disc}", "")
|
||||
}
|
||||
|
||||
// Handle track number - if position is 0, remove {track} and surrounding separators
|
||||
if position > 0 {
|
||||
filename = strings.ReplaceAll(filename, "{track}", fmt.Sprintf("%02d", position))
|
||||
} else {
|
||||
// Remove {track} with common separators
|
||||
filename = regexp.MustCompile(`\{track\}\.\s*`).ReplaceAllString(filename, "")
|
||||
filename = regexp.MustCompile(`\{track\}\s*-\s*`).ReplaceAllString(filename, "")
|
||||
filename = regexp.MustCompile(`\{track\}\s*`).ReplaceAllString(filename, "")
|
||||
}
|
||||
} else {
|
||||
// Legacy format support
|
||||
switch filenameFormat {
|
||||
case "artist-title":
|
||||
filename = fmt.Sprintf("%s - %s", safeArtist, safeTitle)
|
||||
case "title":
|
||||
filename = safeTitle
|
||||
default: // "title-artist"
|
||||
filename = fmt.Sprintf("%s - %s", safeTitle, safeArtist)
|
||||
}
|
||||
|
||||
// Add track number prefix if enabled (legacy behavior)
|
||||
if includeTrackNumber && position > 0 {
|
||||
filename = fmt.Sprintf("%02d. %s", position, filename)
|
||||
}
|
||||
}
|
||||
|
||||
return filename + ".jpg"
|
||||
}
|
||||
|
||||
// getMaxResolutionURL converts a Spotify cover URL to max resolution
|
||||
// Falls back to original URL if max resolution is not available
|
||||
func (c *CoverClient) getMaxResolutionURL(coverURL string) string {
|
||||
// Try to convert to max resolution
|
||||
if strings.Contains(coverURL, spotifySize640) {
|
||||
maxURL := strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1)
|
||||
// Check if max resolution URL is available
|
||||
resp, err := c.httpClient.Head(maxURL)
|
||||
if err == nil && resp.StatusCode == http.StatusOK {
|
||||
return maxURL
|
||||
}
|
||||
}
|
||||
// Return original URL as fallback
|
||||
return coverURL
|
||||
}
|
||||
|
||||
// DownloadCoverToPath downloads cover art from URL to a specific path
|
||||
// If embedMaxQualityCover is true, it will try to get max resolution
|
||||
func (c *CoverClient) DownloadCoverToPath(coverURL, outputPath string, embedMaxQualityCover bool) error {
|
||||
if coverURL == "" {
|
||||
return fmt.Errorf("cover URL is required")
|
||||
}
|
||||
|
||||
// Use max quality URL if setting is enabled
|
||||
downloadURL := coverURL
|
||||
if embedMaxQualityCover {
|
||||
downloadURL = c.getMaxResolutionURL(coverURL)
|
||||
}
|
||||
|
||||
// Download cover image
|
||||
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)
|
||||
}
|
||||
|
||||
// Create file
|
||||
file, err := os.Create(outputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create file: %v", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Write content to file
|
||||
_, err = io.Copy(file, resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write cover file: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DownloadCover downloads cover art for a single track
|
||||
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")
|
||||
}
|
||||
|
||||
// Create output directory if it doesn't exist
|
||||
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
|
||||
}
|
||||
|
||||
// Generate filename using same format as track
|
||||
filenameFormat := req.FilenameFormat
|
||||
if filenameFormat == "" {
|
||||
filenameFormat = "title-artist" // default
|
||||
}
|
||||
filename := buildCoverFilename(req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, filenameFormat, req.TrackNumber, req.Position, req.DiscNumber)
|
||||
filePath := filepath.Join(outputDir, filename)
|
||||
|
||||
// Check if file already exists
|
||||
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
|
||||
}
|
||||
|
||||
// Try to get max resolution URL, fallback to original
|
||||
downloadURL := c.getMaxResolutionURL(req.CoverURL)
|
||||
|
||||
// Download cover image
|
||||
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)
|
||||
}
|
||||
|
||||
// Create file
|
||||
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()
|
||||
|
||||
// Write content to file
|
||||
_, 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
|
||||
}
|
||||
@@ -0,0 +1,615 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"archive/zip"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/ulikunitz/xz"
|
||||
)
|
||||
|
||||
// decodeBase64 decodes a base64 encoded string
|
||||
func decodeBase64(encoded string) (string, error) {
|
||||
decoded, err := base64.StdEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(decoded), nil
|
||||
}
|
||||
|
||||
const (
|
||||
ffmpegWindowsURL = "aHR0cHM6Ly9naXRodWIuY29tL0J0Yk4vRkZtcGVnLUJ1aWxkcy9yZWxlYXNlcy9kb3dubG9hZC9sYXRlc3QvZmZtcGVnLW1hc3Rlci1sYXRlc3Qtd2luNjQtZ3BsLnppcA=="
|
||||
ffmpegLinuxURL = "aHR0cHM6Ly9naXRodWIuY29tL0J0Yk4vRkZtcGVnLUJ1aWxkcy9yZWxlYXNlcy9kb3dubG9hZC9sYXRlc3QvZmZtcGVnLW1hc3Rlci1sYXRlc3QtbGludXg2NC1ncGwudGFyLnh6"
|
||||
ffmpegMacOSURL = "aHR0cHM6Ly9ldmVybWVldC5jeC9mZm1wZWcvZ2V0cmVsZWFzZS96aXA="
|
||||
ffprobeMacOSURL = "aHR0cHM6Ly9ldmVybWVldC5jeC9mZm1wZWcvZ2V0cmVsZWFzZS9mZnByb2JlL3ppcA=="
|
||||
)
|
||||
|
||||
// GetFFmpegDir returns the directory where ffmpeg should be stored
|
||||
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
|
||||
}
|
||||
|
||||
// GetFFmpegPath returns the full path to the ffmpeg executable
|
||||
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
|
||||
}
|
||||
|
||||
// GetFFprobePath returns the full path to the ffprobe executable in app directory
|
||||
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")
|
||||
}
|
||||
|
||||
// IsFFprobeInstalled checks if ffprobe is installed in the app directory
|
||||
func IsFFprobeInstalled() (bool, error) {
|
||||
ffprobePath, err := GetFFprobePath()
|
||||
if err != nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Verify it's executable
|
||||
cmd := exec.Command(ffprobePath, "-version")
|
||||
setHideWindow(cmd)
|
||||
err = cmd.Run()
|
||||
return err == nil, nil
|
||||
}
|
||||
|
||||
// IsFFmpegInstalled checks if ffmpeg is installed in the app directory
|
||||
func IsFFmpegInstalled() (bool, error) {
|
||||
ffmpegPath, err := GetFFmpegPath()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
_, err = os.Stat(ffmpegPath)
|
||||
if os.IsNotExist(err) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
// Verify it's executable
|
||||
cmd := exec.Command(ffmpegPath, "-version")
|
||||
// Hide console window on Windows
|
||||
setHideWindow(cmd)
|
||||
err = cmd.Run()
|
||||
return err == nil, nil
|
||||
}
|
||||
|
||||
// DownloadFFmpeg downloads and extracts ffmpeg to the app directory
|
||||
func DownloadFFmpeg(progressCallback func(int)) error {
|
||||
ffmpegDir, err := GetFFmpegDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create directory if it doesn't exist
|
||||
if err := os.MkdirAll(ffmpegDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create ffmpeg directory: %w", err)
|
||||
}
|
||||
|
||||
// For macOS, download ffmpeg and ffprobe separately (only if not already installed)
|
||||
if runtime.GOOS == "darwin" {
|
||||
ffmpegInstalled, _ := IsFFmpegInstalled()
|
||||
ffprobeInstalled, _ := IsFFprobeInstalled()
|
||||
|
||||
if !ffmpegInstalled && !ffprobeInstalled {
|
||||
// Download both
|
||||
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 {
|
||||
// Only download ffmpeg
|
||||
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 {
|
||||
// Only download ffprobe
|
||||
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
|
||||
}
|
||||
|
||||
// For Windows/Linux: single archive contains both ffmpeg and ffprobe
|
||||
var encodedURL string
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
encodedURL = ffmpegWindowsURL
|
||||
case "linux":
|
||||
encodedURL = ffmpegLinuxURL
|
||||
default:
|
||||
return fmt.Errorf("unsupported operating system: %s", runtime.GOOS)
|
||||
}
|
||||
|
||||
// Decode URL
|
||||
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
|
||||
}
|
||||
|
||||
// downloadAndExtract downloads a file and extracts it
|
||||
func downloadAndExtract(url, destDir string, progressCallback func(int), progressStart, progressEnd int) error {
|
||||
// Create temporary file for download
|
||||
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()
|
||||
|
||||
// Download the file
|
||||
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
|
||||
|
||||
// Create a progress reader
|
||||
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)
|
||||
if totalSize > 0 && progressCallback != nil {
|
||||
// Scale progress between progressStart and progressEnd
|
||||
rawProgress := float64(downloaded) / float64(totalSize)
|
||||
scaledProgress := progressStart + int(rawProgress*float64(progressEnd-progressStart))
|
||||
progressCallback(scaledProgress)
|
||||
}
|
||||
}
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
tmpFile.Close()
|
||||
|
||||
fmt.Printf("[FFmpeg] Download complete, extracting...\n")
|
||||
|
||||
// Extract the archive based on file type
|
||||
if strings.HasSuffix(url, ".tar.xz") || runtime.GOOS == "linux" {
|
||||
return extractTarXz(tmpFile.Name(), destDir)
|
||||
}
|
||||
return extractZip(tmpFile.Name(), destDir)
|
||||
}
|
||||
|
||||
// extractZip extracts ffmpeg and ffprobe from a zip archive (skips ffplay)
|
||||
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 {
|
||||
// Skip ffplay and other files
|
||||
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)
|
||||
}
|
||||
|
||||
// At least one of ffmpeg or ffprobe should be found
|
||||
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
|
||||
}
|
||||
|
||||
// extractTarXz extracts ffmpeg and ffprobe from a tar.xz archive (skips ffplay)
|
||||
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 {
|
||||
// Skip ffplay and other files
|
||||
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)
|
||||
}
|
||||
|
||||
// At least one of ffmpeg or ffprobe should be found
|
||||
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
|
||||
}
|
||||
|
||||
// ConvertAudioRequest represents a request to convert audio files
|
||||
type ConvertAudioRequest struct {
|
||||
InputFiles []string `json:"input_files"`
|
||||
OutputFormat string `json:"output_format"` // mp3, m4a
|
||||
Bitrate string `json:"bitrate"` // e.g., "320k", "256k", "192k", "128k" (ignored for ALAC)
|
||||
Codec string `json:"codec"` // For m4a: "aac" (lossy) or "alac" (lossless). Default: "aac"
|
||||
}
|
||||
|
||||
// ConvertAudioResult represents the result of a single file conversion
|
||||
type ConvertAudioResult struct {
|
||||
InputFile string `json:"input_file"`
|
||||
OutputFile string `json:"output_file"`
|
||||
Success bool `json:"success"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// ConvertAudio converts audio files using ffmpeg while preserving metadata
|
||||
func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) {
|
||||
ffmpegPath, err := GetFFmpegPath()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get ffmpeg path: %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
|
||||
|
||||
// Convert files in parallel
|
||||
for i, inputFile := range req.InputFiles {
|
||||
wg.Add(1)
|
||||
go func(idx int, inputFile string) {
|
||||
defer wg.Done()
|
||||
|
||||
result := ConvertAudioResult{
|
||||
InputFile: inputFile,
|
||||
}
|
||||
|
||||
// Get input file info
|
||||
inputExt := strings.ToLower(filepath.Ext(inputFile))
|
||||
baseName := strings.TrimSuffix(filepath.Base(inputFile), inputExt)
|
||||
inputDir := filepath.Dir(inputFile)
|
||||
|
||||
// Determine output directory: same as input file location + subfolder (MP3 or M4A)
|
||||
outputFormatUpper := strings.ToUpper(req.OutputFormat)
|
||||
outputDir := filepath.Join(inputDir, outputFormatUpper)
|
||||
|
||||
// Create output directory if it doesn't exist
|
||||
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
|
||||
}
|
||||
|
||||
// Determine output path
|
||||
outputExt := "." + strings.ToLower(req.OutputFormat)
|
||||
outputFile := filepath.Join(outputDir, baseName+outputExt)
|
||||
|
||||
// Skip if same format
|
||||
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
|
||||
|
||||
// Extract cover art and lyrics from input file before conversion
|
||||
var coverArtPath string
|
||||
var lyrics string
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// Build ffmpeg command
|
||||
args := []string{
|
||||
"-i", inputFile,
|
||||
"-y", // Overwrite output
|
||||
}
|
||||
|
||||
// Add codec and bitrate based on output format
|
||||
switch req.OutputFormat {
|
||||
case "mp3":
|
||||
args = append(args,
|
||||
"-codec:a", "libmp3lame",
|
||||
"-b:a", req.Bitrate,
|
||||
"-map", "0:a", // Map audio stream
|
||||
"-map_metadata", "0", // Copy all metadata
|
||||
"-id3v2_version", "3", // Use ID3v2.3 for better compatibility
|
||||
)
|
||||
// Map video stream if exists (for cover art)
|
||||
args = append(args, "-map", "0:v?", "-c:v", "copy")
|
||||
case "m4a":
|
||||
// Determine codec: ALAC (lossless) or AAC (lossy)
|
||||
codec := req.Codec
|
||||
if codec == "" {
|
||||
codec = "aac" // Default to AAC for backward compatibility
|
||||
}
|
||||
|
||||
if codec == "alac" {
|
||||
// ALAC - Apple Lossless (no bitrate needed)
|
||||
args = append(args,
|
||||
"-codec:a", "alac",
|
||||
"-map", "0:a", // Map audio stream
|
||||
"-map_metadata", "0", // Copy all metadata
|
||||
)
|
||||
} else {
|
||||
// AAC - lossy with bitrate
|
||||
args = append(args,
|
||||
"-codec:a", "aac",
|
||||
"-b:a", req.Bitrate,
|
||||
"-map", "0:a", // Map audio stream
|
||||
"-map_metadata", "0", // Copy all metadata
|
||||
)
|
||||
}
|
||||
// Map video stream for cover art in M4A
|
||||
args = append(args, "-map", "0:v?", "-c:v", "copy", "-disposition:v:0", "attached_pic")
|
||||
}
|
||||
|
||||
args = append(args, outputFile)
|
||||
|
||||
fmt.Printf("[FFmpeg] Converting: %s -> %s\n", inputFile, outputFile)
|
||||
|
||||
cmd := exec.Command(ffmpegPath, args...)
|
||||
// Hide console window on Windows
|
||||
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()
|
||||
// Clean up temp cover art file if exists
|
||||
if coverArtPath != "" {
|
||||
os.Remove(coverArtPath)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Embed cover art and lyrics after conversion if they were extracted
|
||||
if coverArtPath != "" {
|
||||
if err := EmbedCoverArtOnly(outputFile, coverArtPath); err != nil {
|
||||
fmt.Printf("[FFmpeg] Warning: Failed to embed cover art: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf("[FFmpeg] Cover art embedded successfully\n")
|
||||
}
|
||||
os.Remove(coverArtPath) // Clean up temp file
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// GetAudioInfo returns information about an audio file
|
||||
type AudioFileInfo struct {
|
||||
Path string `json:"path"`
|
||||
Filename string `json:"filename"`
|
||||
Format string `json:"format"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
// GetAudioFileInfo gets information about an audio file
|
||||
func GetAudioFileInfo(filePath string) (*AudioFileInfo, error) {
|
||||
info, err := os.Stat(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(filePath), "."))
|
||||
return &AudioFileInfo{
|
||||
Path: filePath,
|
||||
Filename: filepath.Base(filePath),
|
||||
Format: ext,
|
||||
Size: info.Size(),
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
//go:build !windows
|
||||
// +build !windows
|
||||
|
||||
package backend
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
// setHideWindow is a no-op on non-Windows platforms
|
||||
func setHideWindow(cmd *exec.Cmd) {
|
||||
// No-op on Unix-like systems
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
//go:build windows
|
||||
// +build windows
|
||||
|
||||
package backend
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// setHideWindow sets HideWindow attribute for Windows processes
|
||||
func setHideWindow(cmd *exec.Cmd) {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
HideWindow: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
)
|
||||
|
||||
// SelectMultipleFiles opens a file dialog to select multiple audio files
|
||||
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
|
||||
}
|
||||
|
||||
// SelectOutputDirectory opens a directory dialog to select output folder
|
||||
func SelectOutputDirectory(ctx context.Context) (string, error) {
|
||||
dir, err := runtime.OpenDirectoryDialog(ctx, runtime.OpenDialogOptions{
|
||||
Title: "Select Output Directory",
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,498 @@
|
||||
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"
|
||||
)
|
||||
|
||||
// FileInfo represents information about a file or folder
|
||||
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"`
|
||||
}
|
||||
|
||||
// AudioMetadata represents metadata read from an audio file
|
||||
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"`
|
||||
}
|
||||
|
||||
// RenamePreview represents a preview of file rename operation
|
||||
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"`
|
||||
}
|
||||
|
||||
// RenameResult represents the result of a rename operation
|
||||
type RenameResult struct {
|
||||
OldPath string `json:"old_path"`
|
||||
NewPath string `json:"new_path"`
|
||||
Success bool `json:"success"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// ListDirectory lists files and folders in a directory
|
||||
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 it's a directory, recursively list its contents
|
||||
if entry.IsDir() {
|
||||
children, err := ListDirectory(fileInfo.Path)
|
||||
if err == nil {
|
||||
fileInfo.Children = children
|
||||
}
|
||||
}
|
||||
|
||||
result = append(result, fileInfo)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ListAudioFiles lists only audio files (flac, mp3, m4a) in a directory recursively
|
||||
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 // Skip files with errors
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// ReadAudioMetadata reads metadata from an audio file
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// readFlacMetadata reads metadata from a FLAC file
|
||||
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
|
||||
}
|
||||
|
||||
// readMp3Metadata reads metadata from an MP3 file
|
||||
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(),
|
||||
}
|
||||
|
||||
// Get Album Artist (TPE2)
|
||||
if frames := tag.GetFrames("TPE2"); len(frames) > 0 {
|
||||
if textFrame, ok := frames[0].(id3v2.TextFrame); ok {
|
||||
metadata.AlbumArtist = textFrame.Text
|
||||
}
|
||||
}
|
||||
|
||||
// Get Track Number
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get Disc Number
|
||||
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
|
||||
}
|
||||
|
||||
// readMetadataWithFFprobe reads metadata from any audio file using ffprobe
|
||||
func readMetadataWithFFprobe(filePath string) (*AudioMetadata, error) {
|
||||
ffprobePath, err := GetFFprobePath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Use ffprobe to get metadata in JSON format (both format and stream tags)
|
||||
cmd := exec.Command(ffprobePath,
|
||||
"-v", "quiet",
|
||||
"-print_format", "json",
|
||||
"-show_format",
|
||||
"-show_streams",
|
||||
filePath,
|
||||
)
|
||||
|
||||
// Hide console window on Windows
|
||||
setHideWindow(cmd)
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parse JSON output
|
||||
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{}
|
||||
|
||||
// Merge tags from format and streams (format tags take priority)
|
||||
allTags := make(map[string]string)
|
||||
|
||||
// First add stream tags
|
||||
for _, stream := range result.Streams {
|
||||
for key, value := range stream.Tags {
|
||||
allTags[strings.ToLower(key)] = value
|
||||
}
|
||||
}
|
||||
|
||||
// Then add format tags (overwrite stream tags)
|
||||
for key, value := range result.Format.Tags {
|
||||
allTags[strings.ToLower(key)] = value
|
||||
}
|
||||
|
||||
// Parse tags
|
||||
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":
|
||||
// Format might be "4" or "4/12"
|
||||
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
|
||||
}
|
||||
|
||||
// readM4aMetadata reads metadata from an M4A file using ffprobe
|
||||
func readM4aMetadata(filePath string) (*AudioMetadata, error) {
|
||||
metadata, err := readMetadataWithFFprobe(filePath)
|
||||
if err != nil {
|
||||
return &AudioMetadata{}, nil
|
||||
}
|
||||
return metadata, nil
|
||||
}
|
||||
|
||||
// GenerateFilename generates a new filename based on metadata and format template
|
||||
func GenerateFilename(metadata *AudioMetadata, format string, ext string) string {
|
||||
if metadata == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
result := format
|
||||
|
||||
// Extract year (first 4 characters only)
|
||||
year := metadata.Year
|
||||
if len(year) >= 4 {
|
||||
year = year[:4]
|
||||
}
|
||||
|
||||
// Replace placeholders
|
||||
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))
|
||||
|
||||
// Track number with padding
|
||||
if metadata.TrackNumber > 0 {
|
||||
result = strings.ReplaceAll(result, "{track}", fmt.Sprintf("%02d", metadata.TrackNumber))
|
||||
} else {
|
||||
result = strings.ReplaceAll(result, "{track}", "")
|
||||
}
|
||||
|
||||
// Disc number
|
||||
if metadata.DiscNumber > 0 {
|
||||
result = strings.ReplaceAll(result, "{disc}", fmt.Sprintf("%d", metadata.DiscNumber))
|
||||
} else {
|
||||
result = strings.ReplaceAll(result, "{disc}", "")
|
||||
}
|
||||
|
||||
// Clean up multiple spaces and trim
|
||||
result = strings.TrimSpace(result)
|
||||
result = strings.Join(strings.Fields(result), " ")
|
||||
|
||||
// Remove leading/trailing separators
|
||||
result = strings.Trim(result, " -._")
|
||||
|
||||
if result == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
return result + ext
|
||||
}
|
||||
|
||||
// sanitizeFilenameForRename removes invalid characters from filename (for rename operations)
|
||||
func sanitizeFilenameForRename(name string) string {
|
||||
// Remove characters that are invalid in filenames
|
||||
invalid := []string{"<", ">", ":", "\"", "/", "\\", "|", "?", "*"}
|
||||
result := name
|
||||
for _, char := range invalid {
|
||||
result = strings.ReplaceAll(result, char, "")
|
||||
}
|
||||
return strings.TrimSpace(result)
|
||||
}
|
||||
|
||||
// PreviewRename generates a preview of rename operations
|
||||
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
|
||||
}
|
||||
|
||||
// GetFileSizes returns file sizes for a list of file paths
|
||||
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
|
||||
}
|
||||
|
||||
// RenameFiles renames files based on their metadata
|
||||
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
|
||||
|
||||
// Check if new path already exists (and is different from old path)
|
||||
if newPath != filePath {
|
||||
if _, err := os.Stat(newPath); err == nil {
|
||||
result.Error = "File already exists"
|
||||
result.Success = false
|
||||
results = append(results, result)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Rename the file
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"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 {
|
||||
// Sanitize track name and artist name
|
||||
safeTitle := sanitizeFilename(trackName)
|
||||
safeArtist := sanitizeFilename(artistName)
|
||||
safeAlbum := sanitizeFilename(albumName)
|
||||
safeAlbumArtist := sanitizeFilename(albumArtist)
|
||||
|
||||
// Extract year from release date (format: YYYY-MM-DD or YYYY)
|
||||
year := ""
|
||||
if len(releaseDate) >= 4 {
|
||||
year = releaseDate[:4]
|
||||
}
|
||||
|
||||
var filename string
|
||||
|
||||
// Check if format is a template (contains {})
|
||||
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)
|
||||
|
||||
// Handle disc number
|
||||
if discNumber > 0 {
|
||||
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
|
||||
} else {
|
||||
filename = strings.ReplaceAll(filename, "{disc}", "")
|
||||
}
|
||||
|
||||
// Handle track number - if position is 0, remove {track} and surrounding separators
|
||||
if position > 0 {
|
||||
filename = strings.ReplaceAll(filename, "{track}", fmt.Sprintf("%02d", position))
|
||||
} else {
|
||||
// Remove {track} with common separators like ". " or " - " or ". "
|
||||
filename = regexp.MustCompile(`\{track\}\.\s*`).ReplaceAllString(filename, "")
|
||||
filename = regexp.MustCompile(`\{track\}\s*-\s*`).ReplaceAllString(filename, "")
|
||||
filename = regexp.MustCompile(`\{track\}\s*`).ReplaceAllString(filename, "")
|
||||
}
|
||||
} else {
|
||||
// Legacy format support
|
||||
switch filenameFormat {
|
||||
case "artist-title":
|
||||
filename = fmt.Sprintf("%s - %s", safeArtist, safeTitle)
|
||||
case "title":
|
||||
filename = safeTitle
|
||||
default: // "title-artist"
|
||||
filename = fmt.Sprintf("%s - %s", safeTitle, safeArtist)
|
||||
}
|
||||
|
||||
// Add track number prefix if enabled (legacy behavior)
|
||||
if includeTrackNumber && position > 0 {
|
||||
filename = fmt.Sprintf("%02d. %s", position, filename)
|
||||
}
|
||||
}
|
||||
|
||||
return filename + ".flac"
|
||||
}
|
||||
|
||||
// sanitizeFilename removes invalid characters from filename
|
||||
func sanitizeFilename(name string) string {
|
||||
// Replace forward slash with space (more natural than underscore)
|
||||
sanitized := strings.ReplaceAll(name, "/", " ")
|
||||
|
||||
// Remove other invalid filesystem characters (replace with space)
|
||||
re := regexp.MustCompile(`[<>:"\\|?*]`)
|
||||
sanitized = re.ReplaceAllString(sanitized, " ")
|
||||
|
||||
// Remove control characters (0x00-0x1F, 0x7F)
|
||||
var result strings.Builder
|
||||
for _, r := range sanitized {
|
||||
// Keep printable characters and valid Unicode characters
|
||||
// Remove control characters, but keep spaces, tabs, newlines for now
|
||||
if r < 0x20 && r != 0x09 && r != 0x0A && r != 0x0D {
|
||||
continue
|
||||
}
|
||||
if r == 0x7F {
|
||||
continue
|
||||
}
|
||||
// Remove emoji and other symbols that might cause issues
|
||||
// Keep letters, numbers, and common punctuation
|
||||
if unicode.IsControl(r) && r != 0x09 && r != 0x0A && r != 0x0D {
|
||||
continue
|
||||
}
|
||||
// Remove emoji ranges (most emoji are in these ranges)
|
||||
if (r >= 0x1F300 && r <= 0x1F9FF) || // Miscellaneous Symbols and Pictographs, Emoticons
|
||||
(r >= 0x2600 && r <= 0x26FF) || // Miscellaneous Symbols
|
||||
(r >= 0x2700 && r <= 0x27BF) || // Dingbats
|
||||
(r >= 0xFE00 && r <= 0xFE0F) || // Variation Selectors
|
||||
(r >= 0x1F900 && r <= 0x1F9FF) || // Supplemental Symbols and Pictographs
|
||||
(r >= 0x1F600 && r <= 0x1F64F) || // Emoticons
|
||||
(r >= 0x1F680 && r <= 0x1F6FF) || // Transport and Map Symbols
|
||||
(r >= 0x1F1E0 && r <= 0x1F1FF) { // Regional Indicator Symbols (flags)
|
||||
continue
|
||||
}
|
||||
result.WriteRune(r)
|
||||
}
|
||||
|
||||
sanitized = result.String()
|
||||
sanitized = strings.TrimSpace(sanitized)
|
||||
|
||||
// Remove leading/trailing dots and spaces (Windows doesn't allow these)
|
||||
sanitized = strings.Trim(sanitized, ". ")
|
||||
|
||||
// Normalize consecutive spaces to single space
|
||||
re = regexp.MustCompile(`\s+`)
|
||||
sanitized = re.ReplaceAllString(sanitized, " ")
|
||||
|
||||
// Normalize consecutive underscores to single underscore
|
||||
re = regexp.MustCompile(`_+`)
|
||||
sanitized = re.ReplaceAllString(sanitized, "_")
|
||||
|
||||
// Remove leading/trailing underscores and spaces
|
||||
sanitized = strings.Trim(sanitized, "_ ")
|
||||
|
||||
if sanitized == "" {
|
||||
return "Unknown"
|
||||
}
|
||||
|
||||
// Ensure the result is valid UTF-8
|
||||
if !utf8.ValidString(sanitized) {
|
||||
// If invalid UTF-8, try to fix it
|
||||
sanitized = strings.ToValidUTF8(sanitized, "_")
|
||||
}
|
||||
|
||||
return sanitized
|
||||
}
|
||||
|
||||
// NormalizePath only normalizes path separators without modifying folder names
|
||||
// Use this for user-provided paths that already exist on the filesystem
|
||||
func NormalizePath(folderPath string) string {
|
||||
// Normalize all forward slashes to backslashes on Windows
|
||||
return strings.ReplaceAll(folderPath, "/", string(filepath.Separator))
|
||||
}
|
||||
|
||||
// SanitizeFolderPath sanitizes each component of a folder path and normalizes separators
|
||||
// Use this only for NEW folders being created (artist names, album names, etc.)
|
||||
func SanitizeFolderPath(folderPath string) string {
|
||||
// Normalize all forward slashes to backslashes on Windows
|
||||
normalizedPath := strings.ReplaceAll(folderPath, "/", string(filepath.Separator))
|
||||
|
||||
// Detect separator
|
||||
sep := string(filepath.Separator)
|
||||
|
||||
// Split path into components
|
||||
parts := strings.Split(normalizedPath, sep)
|
||||
sanitizedParts := make([]string, 0, len(parts))
|
||||
|
||||
for i, part := range parts {
|
||||
// Keep drive letter intact on Windows (e.g., "C:")
|
||||
if i == 0 && len(part) == 2 && part[1] == ':' {
|
||||
sanitizedParts = append(sanitizedParts, part)
|
||||
continue
|
||||
}
|
||||
|
||||
// Keep empty first part for absolute paths on Unix (e.g., "/Users/...")
|
||||
if i == 0 && part == "" {
|
||||
sanitizedParts = append(sanitizedParts, part)
|
||||
continue
|
||||
}
|
||||
|
||||
// Sanitize each folder name (but don't replace / or \ since we already normalized)
|
||||
sanitized := sanitizeFolderName(part)
|
||||
if sanitized != "" {
|
||||
sanitizedParts = append(sanitizedParts, sanitized)
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(sanitizedParts, sep)
|
||||
}
|
||||
|
||||
// sanitizeFolderName removes invalid characters from a single folder name
|
||||
func sanitizeFolderName(name string) string {
|
||||
// Use the same sanitization as filename
|
||||
return sanitizeFilename(name)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
|
||||
wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
)
|
||||
|
||||
func OpenFolderInExplorer(path string) error {
|
||||
var cmd *exec.Cmd
|
||||
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
cmd = exec.Command("explorer", path)
|
||||
case "darwin": // macOS
|
||||
cmd = exec.Command("open", path)
|
||||
case "linux":
|
||||
cmd = exec.Command("xdg-open", path)
|
||||
default:
|
||||
cmd = exec.Command("xdg-open", path)
|
||||
}
|
||||
|
||||
return cmd.Start()
|
||||
}
|
||||
|
||||
func SelectFolderDialog(ctx context.Context, defaultPath string) (string, error) {
|
||||
// If defaultPath is empty, use default music path
|
||||
if defaultPath == "" {
|
||||
defaultPath = GetDefaultMusicPath()
|
||||
}
|
||||
|
||||
options := wailsRuntime.OpenDialogOptions{
|
||||
Title: "Select Download Folder",
|
||||
DefaultDirectory: defaultPath,
|
||||
}
|
||||
|
||||
selectedPath, err := wailsRuntime.OpenDirectoryDialog(ctx, options)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// If user cancelled, selectedPath will be empty
|
||||
if selectedPath == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
return selectedPath, nil
|
||||
}
|
||||
|
||||
func SelectFileDialog(ctx context.Context) (string, error) {
|
||||
options := wailsRuntime.OpenDialogOptions{
|
||||
Title: "Select FLAC File for Analysis",
|
||||
Filters: []wailsRuntime.FileFilter{
|
||||
{
|
||||
DisplayName: "FLAC Audio Files (*.flac)",
|
||||
Pattern: "*.flac",
|
||||
},
|
||||
{
|
||||
DisplayName: "All Files (*.*)",
|
||||
Pattern: "*.*",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
selectedFile, err := wailsRuntime.OpenFileDialog(ctx, options)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// If user cancelled, selectedFile will be empty
|
||||
if selectedFile == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
return selectedFile, nil
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LRCLibResponse represents the LRCLIB API response
|
||||
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"`
|
||||
}
|
||||
|
||||
// LyricsLine represents a single line of lyrics
|
||||
type LyricsLine struct {
|
||||
StartTimeMs string `json:"startTimeMs"`
|
||||
Words string `json:"words"`
|
||||
EndTimeMs string `json:"endTimeMs"`
|
||||
}
|
||||
|
||||
// LyricsResponse represents the API response
|
||||
type LyricsResponse struct {
|
||||
Error bool `json:"error"`
|
||||
SyncType string `json:"syncType"`
|
||||
Lines []LyricsLine `json:"lines"`
|
||||
}
|
||||
|
||||
// LyricsDownloadRequest represents a request to download lyrics
|
||||
type LyricsDownloadRequest struct {
|
||||
SpotifyID string `json:"spotify_id"`
|
||||
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"`
|
||||
UseAlbumTrackNumber bool `json:"use_album_track_number"`
|
||||
DiscNumber int `json:"disc_number"`
|
||||
}
|
||||
|
||||
// LyricsDownloadResponse represents the response from lyrics download
|
||||
type LyricsDownloadResponse 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"`
|
||||
}
|
||||
|
||||
// LyricsClient handles lyrics fetching
|
||||
type LyricsClient struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewLyricsClient creates a new lyrics client
|
||||
func NewLyricsClient() *LyricsClient {
|
||||
return &LyricsClient{
|
||||
httpClient: &http.Client{Timeout: 15 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// FetchLyricsWithMetadata fetches lyrics using track name and artist from LRCLIB
|
||||
func (c *LyricsClient) FetchLyricsWithMetadata(trackName, artistName string) (*LyricsResponse, error) {
|
||||
// Try LRCLIB API
|
||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9scmNsaWIubmV0L2FwaS9nZXQ/YXJ0aXN0X25hbWU9")
|
||||
apiURL := fmt.Sprintf("%s%s&track_name=%s",
|
||||
string(apiBase),
|
||||
url.QueryEscape(artistName),
|
||||
url.QueryEscape(trackName))
|
||||
|
||||
resp, err := c.httpClient.Get(apiURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch from LRCLIB: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("LRCLIB returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read LRCLIB response: %v", err)
|
||||
}
|
||||
|
||||
var lrcLibResp LRCLibResponse
|
||||
if err := json.Unmarshal(body, &lrcLibResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse LRCLIB response: %v", err)
|
||||
}
|
||||
|
||||
// Convert LRCLIB response to our LyricsResponse format
|
||||
return c.convertLRCLibToLyricsResponse(&lrcLibResp), nil
|
||||
}
|
||||
|
||||
// convertLRCLibToLyricsResponse converts LRCLIB response to our standard format
|
||||
func (c *LyricsClient) convertLRCLibToLyricsResponse(lrcLib *LRCLibResponse) *LyricsResponse {
|
||||
resp := &LyricsResponse{
|
||||
Error: false,
|
||||
SyncType: "LINE_SYNCED",
|
||||
Lines: []LyricsLine{},
|
||||
}
|
||||
|
||||
// Prefer synced lyrics, fall back to plain
|
||||
lyricsText := lrcLib.SyncedLyrics
|
||||
if lyricsText == "" {
|
||||
lyricsText = lrcLib.PlainLyrics
|
||||
resp.SyncType = "UNSYNCED"
|
||||
}
|
||||
|
||||
if lyricsText == "" {
|
||||
resp.Error = true
|
||||
return resp
|
||||
}
|
||||
|
||||
// Parse synced lyrics format [mm:ss.xx] text
|
||||
lines := strings.Split(lyricsText, "\n")
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if line has timestamp [mm:ss.xx]
|
||||
if strings.HasPrefix(line, "[") && len(line) > 10 {
|
||||
closeBracket := strings.Index(line, "]")
|
||||
if closeBracket > 0 {
|
||||
timestamp := line[1:closeBracket]
|
||||
words := strings.TrimSpace(line[closeBracket+1:])
|
||||
|
||||
// Convert [mm:ss.xx] to milliseconds
|
||||
ms := lrcTimestampToMs(timestamp)
|
||||
resp.Lines = append(resp.Lines, LyricsLine{
|
||||
StartTimeMs: fmt.Sprintf("%d", ms),
|
||||
Words: words,
|
||||
})
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Plain lyrics line (no timestamp)
|
||||
resp.Lines = append(resp.Lines, LyricsLine{
|
||||
StartTimeMs: "0",
|
||||
Words: line,
|
||||
})
|
||||
}
|
||||
|
||||
return resp
|
||||
}
|
||||
|
||||
// lrcTimestampToMs converts LRC timestamp [mm:ss.xx] to milliseconds
|
||||
func lrcTimestampToMs(timestamp string) int64 {
|
||||
var minutes, seconds, centiseconds int64
|
||||
// Try parsing mm:ss.xx format
|
||||
n, _ := fmt.Sscanf(timestamp, "%d:%d.%d", &minutes, &seconds, ¢iseconds)
|
||||
if n >= 2 {
|
||||
return minutes*60*1000 + seconds*1000 + centiseconds*10
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// FetchLyricsFromLRCLibSearch fetches lyrics using LRCLIB search API
|
||||
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")
|
||||
}
|
||||
|
||||
// Find best match - prefer one with synced lyrics
|
||||
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
|
||||
}
|
||||
|
||||
// simplifyTrackName removes common suffixes like "(feat. X)", "(Remastered)", etc.
|
||||
func simplifyTrackName(name string) string {
|
||||
// Remove content in parentheses
|
||||
if idx := strings.Index(name, "("); idx > 0 {
|
||||
name = strings.TrimSpace(name[:idx])
|
||||
}
|
||||
// Remove content after " - " (like "From the Motion Picture")
|
||||
if idx := strings.Index(name, " - "); idx > 0 {
|
||||
name = strings.TrimSpace(name[:idx])
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// FetchLyricsAllSources tries all LRCLIB sources to get lyrics
|
||||
func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName string) (*LyricsResponse, string, error) {
|
||||
// 1. Try LRCLIB exact match
|
||||
resp, err := c.FetchLyricsWithMetadata(trackName, artistName)
|
||||
if err == nil && resp != nil && !resp.Error && len(resp.Lines) > 0 {
|
||||
return resp, "LRCLIB", nil
|
||||
}
|
||||
fmt.Printf(" LRCLIB exact: %v\n", err)
|
||||
|
||||
// 2. Try LRCLIB search
|
||||
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)
|
||||
|
||||
// 3. Try with simplified track name (remove parentheses, subtitles)
|
||||
simplifiedTrack := simplifyTrackName(trackName)
|
||||
if simplifiedTrack != trackName {
|
||||
fmt.Printf(" Trying simplified name: %s\n", simplifiedTrack)
|
||||
|
||||
resp, err = c.FetchLyricsWithMetadata(simplifiedTrack, artistName)
|
||||
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 {
|
||||
var sb strings.Builder
|
||||
|
||||
// Add metadata
|
||||
sb.WriteString(fmt.Sprintf("[ti:%s]\n", trackName))
|
||||
sb.WriteString(fmt.Sprintf("[ar:%s]\n", artistName))
|
||||
sb.WriteString("[by:SpotiFlac]\n")
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Add lyrics lines
|
||||
for _, line := range lyrics.Lines {
|
||||
if line.Words == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Convert milliseconds to LRC timestamp format [mm:ss.xx]
|
||||
timestamp := msToLRCTimestamp(line.StartTimeMs)
|
||||
sb.WriteString(fmt.Sprintf("%s%s\n", timestamp, line.Words))
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// msToLRCTimestamp converts milliseconds string to LRC timestamp format [mm:ss.xx]
|
||||
func msToLRCTimestamp(msStr string) string {
|
||||
var ms int64
|
||||
fmt.Sscanf(msStr, "%d", &ms)
|
||||
|
||||
totalSeconds := ms / 1000
|
||||
minutes := totalSeconds / 60
|
||||
seconds := totalSeconds % 60
|
||||
centiseconds := (ms % 1000) / 10
|
||||
|
||||
return fmt.Sprintf("[%02d:%02d.%02d]", minutes, seconds, centiseconds)
|
||||
}
|
||||
|
||||
// buildLyricsFilename builds the lyrics filename based on settings (same as track filename)
|
||||
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)
|
||||
|
||||
// Extract year from release date (format: YYYY-MM-DD or YYYY)
|
||||
year := ""
|
||||
if len(releaseDate) >= 4 {
|
||||
year = releaseDate[:4]
|
||||
}
|
||||
|
||||
var filename string
|
||||
|
||||
// Check if format is a template (contains {})
|
||||
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)
|
||||
|
||||
// Handle disc number
|
||||
if discNumber > 0 {
|
||||
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
|
||||
} else {
|
||||
filename = strings.ReplaceAll(filename, "{disc}", "")
|
||||
}
|
||||
|
||||
// Handle track number - if position is 0, remove {track} and surrounding separators
|
||||
if position > 0 {
|
||||
filename = strings.ReplaceAll(filename, "{track}", fmt.Sprintf("%02d", position))
|
||||
} else {
|
||||
// Remove {track} with common separators
|
||||
filename = regexp.MustCompile(`\{track\}\.\s*`).ReplaceAllString(filename, "")
|
||||
filename = regexp.MustCompile(`\{track\}\s*-\s*`).ReplaceAllString(filename, "")
|
||||
filename = regexp.MustCompile(`\{track\}\s*`).ReplaceAllString(filename, "")
|
||||
}
|
||||
} else {
|
||||
// Legacy format support
|
||||
switch filenameFormat {
|
||||
case "artist-title":
|
||||
filename = fmt.Sprintf("%s - %s", safeArtist, safeTitle)
|
||||
case "title":
|
||||
filename = safeTitle
|
||||
default: // "title-artist"
|
||||
filename = fmt.Sprintf("%s - %s", safeTitle, safeArtist)
|
||||
}
|
||||
|
||||
// Add track number prefix if enabled (legacy behavior)
|
||||
if includeTrackNumber && position > 0 {
|
||||
filename = fmt.Sprintf("%02d. %s", position, filename)
|
||||
}
|
||||
}
|
||||
|
||||
return filename + ".lrc"
|
||||
}
|
||||
|
||||
// DownloadLyrics downloads lyrics for a single track
|
||||
func (c *LyricsClient) DownloadLyrics(req LyricsDownloadRequest) (*LyricsDownloadResponse, error) {
|
||||
if req.SpotifyID == "" {
|
||||
return &LyricsDownloadResponse{
|
||||
Success: false,
|
||||
Error: "Spotify ID is required",
|
||||
}, fmt.Errorf("spotify ID is required")
|
||||
}
|
||||
|
||||
// Create output directory if it doesn't exist
|
||||
outputDir := req.OutputDir
|
||||
if outputDir == "" {
|
||||
outputDir = GetDefaultMusicPath()
|
||||
} else {
|
||||
outputDir = NormalizePath(outputDir)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||
return &LyricsDownloadResponse{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to create output directory: %v", err),
|
||||
}, err
|
||||
}
|
||||
|
||||
// Generate filename using same format as track
|
||||
filenameFormat := req.FilenameFormat
|
||||
if filenameFormat == "" {
|
||||
filenameFormat = "title-artist" // default
|
||||
}
|
||||
filename := buildLyricsFilename(req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, filenameFormat, req.TrackNumber, req.Position, req.DiscNumber)
|
||||
filePath := filepath.Join(outputDir, filename)
|
||||
|
||||
// Check if file already exists
|
||||
if fileInfo, err := os.Stat(filePath); err == nil && fileInfo.Size() > 0 {
|
||||
return &LyricsDownloadResponse{
|
||||
Success: true,
|
||||
Message: "Lyrics file already exists",
|
||||
File: filePath,
|
||||
AlreadyExists: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Fetch lyrics from LRCLIB
|
||||
lyrics, _, err := c.FetchLyricsAllSources(req.SpotifyID, req.TrackName, req.ArtistName)
|
||||
if err != nil {
|
||||
return &LyricsDownloadResponse{
|
||||
Success: false,
|
||||
Error: err.Error(),
|
||||
}, err
|
||||
}
|
||||
|
||||
// Convert to LRC format
|
||||
lrcContent := c.ConvertToLRC(lyrics, req.TrackName, req.ArtistName)
|
||||
|
||||
// Write LRC file
|
||||
if err := os.WriteFile(filePath, []byte(lrcContent), 0644); err != nil {
|
||||
return &LyricsDownloadResponse{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to write LRC file: %v", err),
|
||||
}, err
|
||||
}
|
||||
|
||||
return &LyricsDownloadResponse{
|
||||
Success: true,
|
||||
Message: "Lyrics downloaded successfully",
|
||||
File: filePath,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,686 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
pathfilepath "path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
id3v2 "github.com/bogem/id3v2/v2"
|
||||
"github.com/go-flac/flacpicture"
|
||||
"github.com/go-flac/flacvorbis"
|
||||
"github.com/go-flac/go-flac"
|
||||
)
|
||||
|
||||
type Metadata struct {
|
||||
Title string
|
||||
Artist string
|
||||
Album string
|
||||
AlbumArtist string
|
||||
Date string // Recorded date (full date YYYY-MM-DD)
|
||||
ReleaseDate string // Release date (full date) - kept for compatibility
|
||||
TrackNumber int
|
||||
TotalTracks int // Total tracks in album
|
||||
DiscNumber int
|
||||
ISRC string
|
||||
Lyrics string
|
||||
Description string
|
||||
}
|
||||
|
||||
func EmbedMetadata(filepath string, metadata Metadata, coverPath string) error {
|
||||
f, err := flac.ParseFile(filepath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse FLAC file: %w", err)
|
||||
}
|
||||
|
||||
var cmtIdx = -1
|
||||
for idx, block := range f.Meta {
|
||||
if block.Type == flac.VorbisComment {
|
||||
cmtIdx = idx
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
cmt := flacvorbis.New()
|
||||
|
||||
if metadata.Title != "" {
|
||||
_ = cmt.Add(flacvorbis.FIELD_TITLE, metadata.Title)
|
||||
}
|
||||
if metadata.Artist != "" {
|
||||
_ = cmt.Add(flacvorbis.FIELD_ARTIST, metadata.Artist)
|
||||
}
|
||||
if metadata.Album != "" {
|
||||
_ = cmt.Add(flacvorbis.FIELD_ALBUM, metadata.Album)
|
||||
}
|
||||
if metadata.AlbumArtist != "" {
|
||||
_ = cmt.Add("ALBUMARTIST", metadata.AlbumArtist)
|
||||
}
|
||||
if metadata.Date != "" {
|
||||
_ = cmt.Add(flacvorbis.FIELD_DATE, metadata.Date)
|
||||
}
|
||||
if metadata.TrackNumber > 0 {
|
||||
_ = cmt.Add(flacvorbis.FIELD_TRACKNUMBER, strconv.Itoa(metadata.TrackNumber))
|
||||
}
|
||||
if metadata.TotalTracks > 0 {
|
||||
_ = cmt.Add("TOTALTRACKS", strconv.Itoa(metadata.TotalTracks))
|
||||
}
|
||||
if metadata.DiscNumber > 0 {
|
||||
_ = cmt.Add("DISCNUMBER", strconv.Itoa(metadata.DiscNumber))
|
||||
}
|
||||
if metadata.ISRC != "" {
|
||||
_ = cmt.Add(flacvorbis.FIELD_ISRC, metadata.ISRC)
|
||||
}
|
||||
if metadata.Description != "" {
|
||||
_ = cmt.Add("DESCRIPTION", metadata.Description)
|
||||
}
|
||||
// Lyrics is added last to keep it at the bottom
|
||||
if metadata.Lyrics != "" {
|
||||
_ = cmt.Add("LYRICS", metadata.Lyrics) // Or "UNSYNCEDLYRICS" for unsynced
|
||||
}
|
||||
|
||||
cmtBlock := cmt.Marshal()
|
||||
if cmtIdx < 0 {
|
||||
f.Meta = append(f.Meta, &cmtBlock)
|
||||
} else {
|
||||
f.Meta[cmtIdx] = &cmtBlock
|
||||
}
|
||||
|
||||
if coverPath != "" && fileExists(coverPath) {
|
||||
if err := embedCoverArt(f, coverPath); err != nil {
|
||||
fmt.Printf("Warning: Failed to embed cover art: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := f.Save(filepath); err != nil {
|
||||
return fmt.Errorf("failed to save FLAC file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func embedCoverArt(f *flac.File, coverPath string) error {
|
||||
imgData, err := os.ReadFile(coverPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read cover image: %w", err)
|
||||
}
|
||||
|
||||
picture, err := flacpicture.NewFromImageData(
|
||||
flacpicture.PictureTypeFrontCover,
|
||||
"Cover",
|
||||
imgData,
|
||||
"image/jpeg",
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create picture block: %w", err)
|
||||
}
|
||||
|
||||
pictureBlock := picture.Marshal()
|
||||
|
||||
for i := len(f.Meta) - 1; i >= 0; i-- {
|
||||
if f.Meta[i].Type == flac.Picture {
|
||||
f.Meta = append(f.Meta[:i], f.Meta[i+1:]...)
|
||||
}
|
||||
}
|
||||
|
||||
f.Meta = append(f.Meta, &pictureBlock)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// extractYear extracts the year from a release date string
|
||||
// Handles formats: "YYYY-MM-DD", "YYYY-MM", "YYYY"
|
||||
func extractYear(releaseDate string) string {
|
||||
if releaseDate == "" {
|
||||
return ""
|
||||
}
|
||||
// Try to extract year (first 4 digits)
|
||||
if len(releaseDate) >= 4 {
|
||||
return releaseDate[:4]
|
||||
}
|
||||
return releaseDate
|
||||
}
|
||||
|
||||
// EmbedLyricsOnly adds lyrics to a FLAC file while preserving existing metadata
|
||||
func EmbedLyricsOnly(filepath string, lyrics string) error {
|
||||
if lyrics == "" {
|
||||
return nil
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// Create new comment block, preserving existing comments
|
||||
cmt := flacvorbis.New()
|
||||
|
||||
// Copy existing comments except LYRICS
|
||||
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])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add lyrics
|
||||
_ = 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
|
||||
}
|
||||
|
||||
// ReadISRCFromFile reads ISRC metadata from a FLAC file
|
||||
func ReadISRCFromFile(filepath string) (string, error) {
|
||||
if !fileExists(filepath) {
|
||||
return "", fmt.Errorf("file does not exist")
|
||||
}
|
||||
|
||||
f, err := flac.ParseFile(filepath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to parse FLAC file: %w", err)
|
||||
}
|
||||
|
||||
// Find VorbisComment block
|
||||
for _, block := range f.Meta {
|
||||
if block.Type == flac.VorbisComment {
|
||||
cmt, err := flacvorbis.ParseFromMetaDataBlock(*block)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Get ISRC field
|
||||
isrcValues, err := cmt.Get(flacvorbis.FIELD_ISRC)
|
||||
if err == nil && len(isrcValues) > 0 {
|
||||
return isrcValues[0], nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "", nil // No ISRC found
|
||||
}
|
||||
|
||||
// CheckISRCExists checks if a file with the given ISRC already exists in the directory
|
||||
func CheckISRCExists(outputDir string, targetISRC string) (string, bool) {
|
||||
if targetISRC == "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
// Read all .flac files in directory
|
||||
entries, err := os.ReadDir(outputDir)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check only .flac files
|
||||
filename := entry.Name()
|
||||
if len(filename) < 5 || filename[len(filename)-5:] != ".flac" {
|
||||
continue
|
||||
}
|
||||
|
||||
filepath := fmt.Sprintf("%s/%s", outputDir, filename)
|
||||
|
||||
// Read ISRC from file (this will fail for corrupted files)
|
||||
isrc, err := ReadISRCFromFile(filepath)
|
||||
if err != nil {
|
||||
// File is corrupted or unreadable, delete it
|
||||
fmt.Printf("Removing corrupted/unreadable file: %s (error: %v)\n", filepath, err)
|
||||
if removeErr := os.Remove(filepath); removeErr != nil {
|
||||
fmt.Printf("Warning: Failed to remove corrupted file %s: %v\n", filepath, removeErr)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Compare ISRC (case-insensitive)
|
||||
if isrc != "" && strings.EqualFold(isrc, targetISRC) {
|
||||
return filepath, true
|
||||
}
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
|
||||
// ExtractCoverArt extracts cover art from an audio file and saves it to a temporary file
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// extractCoverFromMp3 extracts cover art from MP3 file
|
||||
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")
|
||||
}
|
||||
|
||||
// Create temporary file
|
||||
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
|
||||
}
|
||||
|
||||
// extractCoverFromM4AOrFlac extracts cover art from M4A or FLAC file
|
||||
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
|
||||
}
|
||||
|
||||
// Create temporary file
|
||||
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")
|
||||
}
|
||||
|
||||
// For M4A, try to extract using ffmpeg or return empty
|
||||
// M4A cover art should be preserved by ffmpeg during conversion
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// ExtractLyrics extracts lyrics from an audio file
|
||||
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":
|
||||
// M4A lyrics extraction would need different approach
|
||||
return "", nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported file format: %s", ext)
|
||||
}
|
||||
}
|
||||
|
||||
// extractLyricsFromMp3 extracts lyrics from MP3 file
|
||||
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
|
||||
}
|
||||
|
||||
// extractLyricsFromFlac extracts lyrics from FLAC file
|
||||
func extractLyricsFromFlac(filePath string) (string, error) {
|
||||
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.VorbisComment {
|
||||
cmt, err := flacvorbis.ParseFromMetaDataBlock(*block)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Search through comments for lyrics
|
||||
for _, comment := range cmt.Comments {
|
||||
parts := strings.SplitN(comment, "=", 2)
|
||||
if len(parts) == 2 {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("[ExtractLyrics] No lyrics found in FLAC: %s\n", filePath)
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// EmbedCoverArtOnly embeds cover art into an audio file
|
||||
func EmbedCoverArtOnly(filePath string, coverPath string) error {
|
||||
if coverPath == "" || !fileExists(coverPath) {
|
||||
return nil
|
||||
}
|
||||
|
||||
ext := strings.ToLower(pathfilepath.Ext(filePath))
|
||||
|
||||
switch ext {
|
||||
case ".mp3":
|
||||
return embedCoverToMp3(filePath, coverPath)
|
||||
case ".m4a":
|
||||
// M4A cover art should be handled by ffmpeg during conversion
|
||||
// If not, we can try to embed using atomicparsley or similar tool
|
||||
// For now, return nil as ffmpeg should handle it
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("unsupported file format: %s", ext)
|
||||
}
|
||||
}
|
||||
|
||||
// embedCoverToMp3 embeds cover art into MP3 file
|
||||
func embedCoverToMp3(filePath string, 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()
|
||||
|
||||
// Remove existing cover art
|
||||
tag.DeleteFrames(tag.CommonID("Attached picture"))
|
||||
|
||||
// Read cover art
|
||||
artwork, err := os.ReadFile(coverPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read cover art: %w", err)
|
||||
}
|
||||
|
||||
// Add new cover art
|
||||
pic := id3v2.PictureFrame{
|
||||
Encoding: id3v2.EncodingUTF8,
|
||||
MimeType: "image/jpeg",
|
||||
PictureType: id3v2.PTFrontCover,
|
||||
Description: "Front cover",
|
||||
Picture: artwork,
|
||||
}
|
||||
tag.AddAttachedPicture(pic)
|
||||
|
||||
if err := tag.Save(); err != nil {
|
||||
return fmt.Errorf("failed to save MP3 tags: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// EmbedLyricsOnlyMP3 adds lyrics to an MP3 file using ID3v2 USLT frame
|
||||
func EmbedLyricsOnlyMP3(filepath string, lyrics string) error {
|
||||
if lyrics == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
// Remove existing USLT frames
|
||||
tag.DeleteFrames(tag.CommonID("Unsynchronised lyrics/text transcription"))
|
||||
|
||||
// Add new USLT frame with lyrics
|
||||
// Use UTF-8 encoding for better compatibility with AIMP and other players
|
||||
usltFrame := id3v2.UnsynchronisedLyricsFrame{
|
||||
Encoding: id3v2.EncodingUTF8, // Use UTF-8 instead of default encoding
|
||||
Language: "eng",
|
||||
ContentDescriptor: "", // Empty descriptor for better compatibility
|
||||
Lyrics: lyrics,
|
||||
}
|
||||
tag.AddUnsynchronisedLyricsFrame(usltFrame)
|
||||
|
||||
if err := tag.Save(); err != nil {
|
||||
return fmt.Errorf("failed to save MP3 tags: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// embedLyricsToM4A adds lyrics to an M4A file using ffmpeg
|
||||
func embedLyricsToM4A(filepath string, lyrics string) error {
|
||||
// Use ffmpeg to embed lyrics into M4A file
|
||||
// M4A uses iTunes metadata format with atom '©lyr' for lyrics
|
||||
ffmpegPath, err := GetFFmpegPath()
|
||||
if err != nil {
|
||||
return fmt.Errorf("ffmpeg not found: %w", err)
|
||||
}
|
||||
|
||||
// Create temporary output file with proper extension so ffmpeg can detect format
|
||||
tmpOutputFile := strings.TrimSuffix(filepath, pathfilepath.Ext(filepath)) + ".tmp" + pathfilepath.Ext(filepath)
|
||||
defer func() {
|
||||
// Only remove if file still exists (rename might have failed)
|
||||
if _, err := os.Stat(tmpOutputFile); err == nil {
|
||||
os.Remove(tmpOutputFile)
|
||||
}
|
||||
}()
|
||||
|
||||
// Use ffmpeg to copy file and add lyrics metadata
|
||||
// For M4A, we need to use the correct metadata tag format and specify output format
|
||||
// Use -f ipod for M4A format (iPod format is compatible with M4A)
|
||||
cmd := exec.Command(ffmpegPath,
|
||||
"-i", filepath,
|
||||
"-map", "0",
|
||||
"-map_metadata", "0",
|
||||
"-metadata", "lyrics-eng="+lyrics,
|
||||
"-metadata", "lyrics="+lyrics,
|
||||
"-codec", "copy",
|
||||
"-f", "ipod", // Explicitly specify M4A/iPod format
|
||||
"-y", // Overwrite
|
||||
tmpOutputFile,
|
||||
)
|
||||
|
||||
// Hide console window on Windows
|
||||
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)
|
||||
}
|
||||
|
||||
// Replace original file with new file
|
||||
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
|
||||
}
|
||||
|
||||
// EmbedLyricsOnlyUniversal embeds lyrics to MP3, FLAC, or M4A file
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// FileExistenceResult represents the result of checking if a file exists
|
||||
type FileExistenceResult struct {
|
||||
ISRC string `json:"isrc"`
|
||||
Exists bool `json:"exists"`
|
||||
FilePath string `json:"file_path,omitempty"`
|
||||
TrackName string `json:"track_name,omitempty"`
|
||||
ArtistName string `json:"artist_name,omitempty"`
|
||||
}
|
||||
|
||||
// CheckFilesExistParallel checks if multiple files exist in parallel
|
||||
// It builds an ISRC index from the output directory once, then checks all tracks against it
|
||||
func CheckFilesExistParallel(outputDir string, tracks []struct {
|
||||
ISRC string
|
||||
TrackName string
|
||||
ArtistName string
|
||||
}) []FileExistenceResult {
|
||||
results := make([]FileExistenceResult, len(tracks))
|
||||
|
||||
// Build ISRC index from output directory (scan once)
|
||||
isrcIndex := buildISRCIndex(outputDir)
|
||||
|
||||
// Check each track against the index (parallel)
|
||||
var wg sync.WaitGroup
|
||||
for i, track := range tracks {
|
||||
wg.Add(1)
|
||||
go func(idx int, t struct {
|
||||
ISRC string
|
||||
TrackName string
|
||||
ArtistName string
|
||||
}) {
|
||||
defer wg.Done()
|
||||
|
||||
result := FileExistenceResult{
|
||||
ISRC: t.ISRC,
|
||||
TrackName: t.TrackName,
|
||||
ArtistName: t.ArtistName,
|
||||
Exists: false,
|
||||
}
|
||||
|
||||
if t.ISRC != "" {
|
||||
if filePath, exists := isrcIndex[strings.ToUpper(t.ISRC)]; exists {
|
||||
result.Exists = true
|
||||
result.FilePath = filePath
|
||||
}
|
||||
}
|
||||
|
||||
results[idx] = result
|
||||
}(i, track)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
return results
|
||||
}
|
||||
|
||||
// buildISRCIndex scans a directory and builds a map of ISRC -> file path
|
||||
func buildISRCIndex(outputDir string) map[string]string {
|
||||
index := make(map[string]string)
|
||||
|
||||
// Walk directory recursively - only check .flac files for SpotiFLAC
|
||||
pathfilepath.Walk(outputDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
ext := strings.ToLower(pathfilepath.Ext(path))
|
||||
if ext != ".flac" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Read ISRC from file
|
||||
isrc, err := ReadISRCFromFile(path)
|
||||
if err != nil || isrc == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Store in index (uppercase for case-insensitive matching)
|
||||
index[strings.ToUpper(isrc)] = path
|
||||
return nil
|
||||
})
|
||||
|
||||
return index
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DownloadStatus represents the status of a download item
|
||||
type DownloadStatus string
|
||||
|
||||
const (
|
||||
StatusQueued DownloadStatus = "queued"
|
||||
StatusDownloading DownloadStatus = "downloading"
|
||||
StatusCompleted DownloadStatus = "completed"
|
||||
StatusFailed DownloadStatus = "failed"
|
||||
StatusSkipped DownloadStatus = "skipped"
|
||||
)
|
||||
|
||||
// DownloadItem represents a single item in the download queue
|
||||
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"` // MB downloaded
|
||||
TotalSize float64 `json:"total_size"` // MB total (if known)
|
||||
Speed float64 `json:"speed"` // MB/s
|
||||
StartTime int64 `json:"start_time"` // Unix timestamp
|
||||
EndTime int64 `json:"end_time"` // Unix timestamp
|
||||
ErrorMessage string `json:"error_message"` // If failed
|
||||
FilePath string `json:"file_path"` // Final file path
|
||||
}
|
||||
|
||||
// Global progress tracker
|
||||
var (
|
||||
currentProgress float64
|
||||
currentProgressLock sync.RWMutex
|
||||
isDownloading bool
|
||||
downloadingLock sync.RWMutex
|
||||
currentSpeed float64
|
||||
speedLock sync.RWMutex
|
||||
|
||||
// Download queue tracking
|
||||
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 {
|
||||
IsDownloading bool `json:"is_downloading"`
|
||||
MBDownloaded float64 `json:"mb_downloaded"`
|
||||
SpeedMBps float64 `json:"speed_mbps"`
|
||||
}
|
||||
|
||||
// DownloadQueueInfo represents the complete download queue state
|
||||
type DownloadQueueInfo struct {
|
||||
IsDownloading bool `json:"is_downloading"`
|
||||
Queue []DownloadItem `json:"queue"`
|
||||
CurrentSpeed float64 `json:"current_speed"` // MB/s
|
||||
TotalDownloaded float64 `json:"total_downloaded"` // MB this session
|
||||
SessionStartTime int64 `json:"session_start_time"` // Unix timestamp
|
||||
QueuedCount int `json:"queued_count"`
|
||||
CompletedCount int `json:"completed_count"`
|
||||
FailedCount int `json:"failed_count"`
|
||||
SkippedCount int `json:"skipped_count"`
|
||||
}
|
||||
|
||||
// GetDownloadProgress returns current download progress
|
||||
func GetDownloadProgress() ProgressInfo {
|
||||
downloadingLock.RLock()
|
||||
downloading := isDownloading
|
||||
downloadingLock.RUnlock()
|
||||
|
||||
currentProgressLock.RLock()
|
||||
progress := currentProgress
|
||||
currentProgressLock.RUnlock()
|
||||
|
||||
speedLock.RLock()
|
||||
speed := currentSpeed
|
||||
speedLock.RUnlock()
|
||||
|
||||
return ProgressInfo{
|
||||
IsDownloading: downloading,
|
||||
MBDownloaded: progress,
|
||||
SpeedMBps: speed,
|
||||
}
|
||||
}
|
||||
|
||||
// SetDownloadSpeed updates the current download speed
|
||||
func SetDownloadSpeed(mbps float64) {
|
||||
speedLock.Lock()
|
||||
currentSpeed = mbps
|
||||
speedLock.Unlock()
|
||||
}
|
||||
|
||||
// SetDownloadProgress updates the current download progress
|
||||
func SetDownloadProgress(mbDownloaded float64) {
|
||||
currentProgressLock.Lock()
|
||||
currentProgress = mbDownloaded
|
||||
currentProgressLock.Unlock()
|
||||
}
|
||||
|
||||
// SetDownloading sets the downloading state
|
||||
func SetDownloading(downloading bool) {
|
||||
downloadingLock.Lock()
|
||||
isDownloading = downloading
|
||||
downloadingLock.Unlock()
|
||||
|
||||
if !downloading {
|
||||
// Reset progress when download completes
|
||||
SetDownloadProgress(0)
|
||||
SetDownloadSpeed(0)
|
||||
}
|
||||
}
|
||||
|
||||
// ProgressWriter wraps an io.Writer and reports download progress
|
||||
type ProgressWriter struct {
|
||||
writer io.Writer
|
||||
total int64
|
||||
lastPrinted int64
|
||||
startTime int64
|
||||
lastTime int64
|
||||
lastBytes int64
|
||||
itemID string // Track which download item this belongs to
|
||||
}
|
||||
|
||||
func NewProgressWriter(writer io.Writer) *ProgressWriter {
|
||||
now := getCurrentTimeMillis()
|
||||
return &ProgressWriter{
|
||||
writer: writer,
|
||||
total: 0,
|
||||
lastPrinted: 0,
|
||||
startTime: now,
|
||||
lastTime: now,
|
||||
lastBytes: 0,
|
||||
itemID: "",
|
||||
}
|
||||
}
|
||||
|
||||
// NewProgressWriterWithID creates a progress writer with an item ID for queue tracking
|
||||
func NewProgressWriterWithID(writer io.Writer, itemID string) *ProgressWriter {
|
||||
pw := NewProgressWriter(writer)
|
||||
pw.itemID = itemID
|
||||
return pw
|
||||
}
|
||||
|
||||
func getCurrentTimeMillis() int64 {
|
||||
return time.Now().UnixMilli()
|
||||
}
|
||||
|
||||
func (pw *ProgressWriter) Write(p []byte) (int, error) {
|
||||
n, err := pw.writer.Write(p)
|
||||
pw.total += int64(n)
|
||||
|
||||
// Report progress every 256KB for smoother updates
|
||||
if pw.total-pw.lastPrinted >= 256*1024 {
|
||||
mbDownloaded := float64(pw.total) / (1024 * 1024)
|
||||
|
||||
// Calculate speed (MB/s)
|
||||
now := getCurrentTimeMillis()
|
||||
timeDiff := float64(now-pw.lastTime) / 1000.0 // seconds
|
||||
bytesDiff := float64(pw.total - pw.lastBytes)
|
||||
|
||||
var speedMBps float64
|
||||
if timeDiff > 0 {
|
||||
speedMBps = (bytesDiff / (1024 * 1024)) / timeDiff
|
||||
SetDownloadSpeed(speedMBps)
|
||||
fmt.Printf("\rDownloaded: %.2f MB (%.2f MB/s)", mbDownloaded, speedMBps)
|
||||
} else {
|
||||
fmt.Printf("\rDownloaded: %.2f MB", mbDownloaded)
|
||||
}
|
||||
|
||||
// Update global progress
|
||||
SetDownloadProgress(mbDownloaded)
|
||||
|
||||
// Update individual item progress if we have an item ID
|
||||
if pw.itemID != "" {
|
||||
UpdateItemProgress(pw.itemID, mbDownloaded, speedMBps)
|
||||
}
|
||||
|
||||
pw.lastPrinted = pw.total
|
||||
pw.lastTime = now
|
||||
pw.lastBytes = pw.total
|
||||
}
|
||||
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (pw *ProgressWriter) GetTotal() int64 {
|
||||
return pw.total
|
||||
}
|
||||
|
||||
// Queue management functions
|
||||
|
||||
// AddToQueue adds a new item to the download queue
|
||||
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)
|
||||
|
||||
// Initialize session start time if this is the first item
|
||||
sessionStartLock.Lock()
|
||||
if sessionStartTime == 0 {
|
||||
sessionStartTime = time.Now().Unix()
|
||||
}
|
||||
sessionStartLock.Unlock()
|
||||
}
|
||||
|
||||
// StartDownloadItem marks an item as currently downloading
|
||||
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()
|
||||
}
|
||||
|
||||
// UpdateItemProgress updates the progress of the current download item
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetCurrentItemID returns the ID of the currently downloading item
|
||||
func GetCurrentItemID() string {
|
||||
currentItemLock.RLock()
|
||||
defer currentItemLock.RUnlock()
|
||||
return currentItemID
|
||||
}
|
||||
|
||||
// CompleteDownloadItem marks an item as completed
|
||||
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
|
||||
|
||||
// Add to total downloaded
|
||||
totalDownloadedLock.Lock()
|
||||
totalDownloaded += finalSize
|
||||
totalDownloadedLock.Unlock()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// FailDownloadItem marks an item as failed
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SkipDownloadItem marks an item as skipped (already exists)
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetDownloadQueue returns the complete download queue state
|
||||
func GetDownloadQueue() DownloadQueueInfo {
|
||||
// Auto-reset session if all downloads are complete
|
||||
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()
|
||||
|
||||
// Count statuses
|
||||
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++
|
||||
}
|
||||
}
|
||||
|
||||
// Create a copy of the queue
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
// ClearDownloadQueue clears all completed, failed, and skipped items from the queue
|
||||
func ClearDownloadQueue() {
|
||||
downloadQueueLock.Lock()
|
||||
defer downloadQueueLock.Unlock()
|
||||
|
||||
// Keep only queued and downloading items
|
||||
newQueue := make([]DownloadItem, 0)
|
||||
for _, item := range downloadQueue {
|
||||
if item.Status == StatusQueued || item.Status == StatusDownloading {
|
||||
newQueue = append(newQueue, item)
|
||||
}
|
||||
}
|
||||
downloadQueue = newQueue
|
||||
}
|
||||
|
||||
// ClearAllDownloads clears the entire queue and resets session stats
|
||||
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()
|
||||
|
||||
// Reset current progress and speed
|
||||
SetDownloadProgress(0)
|
||||
SetDownloadSpeed(0)
|
||||
}
|
||||
|
||||
// CancelAllQueuedItems marks all queued items as skipped (cancelled)
|
||||
// This is called when user stops a download or when batch download completes
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ResetSessionIfComplete resets session stats if no active or queued downloads
|
||||
// Note: Does NOT clear the queue - items remain visible for history
|
||||
func ResetSessionIfComplete() {
|
||||
downloadQueueLock.RLock()
|
||||
hasActiveOrQueued := false
|
||||
for _, item := range downloadQueue {
|
||||
if item.Status == StatusQueued || item.Status == StatusDownloading {
|
||||
hasActiveOrQueued = true
|
||||
break
|
||||
}
|
||||
}
|
||||
downloadQueueLock.RUnlock()
|
||||
|
||||
// If no active or queued items, reset session stats
|
||||
// But keep the queue items for history visibility
|
||||
if !hasActiveOrQueued {
|
||||
sessionStartLock.Lock()
|
||||
sessionStartTime = 0
|
||||
sessionStartLock.Unlock()
|
||||
|
||||
totalDownloadedLock.Lock()
|
||||
totalDownloaded = 0
|
||||
totalDownloadedLock.Unlock()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type QobuzDownloader struct {
|
||||
client *http.Client
|
||||
appID string
|
||||
}
|
||||
|
||||
type QobuzSearchResponse struct {
|
||||
Query string `json:"query"`
|
||||
Tracks struct {
|
||||
Limit int `json:"limit"`
|
||||
Offset int `json:"offset"`
|
||||
Total int `json:"total"`
|
||||
Items []QobuzTrack `json:"items"`
|
||||
} `json:"tracks"`
|
||||
}
|
||||
|
||||
type QobuzTrack struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Version string `json:"version"`
|
||||
Duration int `json:"duration"`
|
||||
TrackNumber int `json:"track_number"`
|
||||
MediaNumber int `json:"media_number"`
|
||||
ISRC string `json:"isrc"`
|
||||
Copyright string `json:"copyright"`
|
||||
MaximumBitDepth int `json:"maximum_bit_depth"`
|
||||
MaximumSamplingRate float64 `json:"maximum_sampling_rate"`
|
||||
Hires bool `json:"hires"`
|
||||
HiresStreamable bool `json:"hires_streamable"`
|
||||
ReleaseDateOriginal string `json:"release_date_original"`
|
||||
Performer struct {
|
||||
Name string `json:"name"`
|
||||
ID int64 `json:"id"`
|
||||
} `json:"performer"`
|
||||
Album struct {
|
||||
Title string `json:"title"`
|
||||
ID string `json:"id"`
|
||||
Image struct {
|
||||
Small string `json:"small"`
|
||||
Thumbnail string `json:"thumbnail"`
|
||||
Large string `json:"large"`
|
||||
} `json:"image"`
|
||||
Artist struct {
|
||||
Name string `json:"name"`
|
||||
ID int64 `json:"id"`
|
||||
} `json:"artist"`
|
||||
Label struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"label"`
|
||||
} `json:"album"`
|
||||
}
|
||||
|
||||
type QobuzStreamResponse struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
func NewQobuzDownloader() *QobuzDownloader {
|
||||
return &QobuzDownloader{
|
||||
client: &http.Client{
|
||||
Timeout: 60 * time.Second,
|
||||
},
|
||||
appID: "798273057",
|
||||
}
|
||||
}
|
||||
|
||||
func (q *QobuzDownloader) SearchByISRC(isrc string) (*QobuzTrack, error) {
|
||||
// Decode base64 API URL
|
||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
|
||||
url := fmt.Sprintf("%s%s&limit=1&app_id=%s", string(apiBase), isrc, q.appID)
|
||||
|
||||
resp, err := q.client.Get(url)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to search track: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var searchResp QobuzSearchResponse
|
||||
// Read body first to handle encoding issues
|
||||
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 {
|
||||
// Truncate body for error message (max 200 chars)
|
||||
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 {
|
||||
return nil, fmt.Errorf("track not found for ISRC: %s", isrc)
|
||||
}
|
||||
|
||||
return &searchResp.Tracks.Items[0], nil
|
||||
}
|
||||
|
||||
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 := quality // Use the provided quality parameter
|
||||
if qualityCode == "" {
|
||||
qualityCode = "6" // Default to FLAC 16-bit if not specified
|
||||
}
|
||||
|
||||
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")
|
||||
|
||||
// Try primary API first
|
||||
primaryURL := fmt.Sprintf("%s%d&quality=%s", string(primaryBase), trackID, qualityCode)
|
||||
fmt.Printf("Qobuz API URL: %s\n", primaryURL)
|
||||
|
||||
resp, err := q.client.Get(primaryURL)
|
||||
if err == nil && resp.StatusCode == 200 {
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
fmt.Printf("Primary API response: %s\n", string(body))
|
||||
|
||||
var streamResp QobuzStreamResponse
|
||||
if err := json.Unmarshal(body, &streamResp); err == nil && streamResp.URL != "" {
|
||||
fmt.Printf("Got download URL from primary API\n")
|
||||
return streamResp.URL, nil
|
||||
}
|
||||
}
|
||||
if resp != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
// Fallback to secondary API
|
||||
fmt.Println("Primary API failed, trying fallback...")
|
||||
fallbackBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9kYWJtdXNpYy54eXovYXBpL3N0cmVhbT90cmFja0lkPQ==")
|
||||
fallbackURL := fmt.Sprintf("%s%d&quality=%s", string(fallbackBase), trackID, qualityCode)
|
||||
|
||||
resp, err = q.client.Get(fallbackURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get download URL: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
fmt.Printf("Fallback API error response: %s\n", string(body))
|
||||
return "", fmt.Errorf("API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
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))
|
||||
|
||||
var streamResp QobuzStreamResponse
|
||||
if err := json.Unmarshal(body, &streamResp); err != nil {
|
||||
// Truncate body for error message (max 200 chars)
|
||||
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 == "" {
|
||||
return "", fmt.Errorf("no download URL available")
|
||||
}
|
||||
|
||||
fmt.Printf("Got download URL from fallback API\n")
|
||||
return streamResp.URL, nil
|
||||
}
|
||||
|
||||
func (q *QobuzDownloader) DownloadFile(url, filepath string) error {
|
||||
fmt.Println("Starting file download...")
|
||||
// Use a separate client with a longer timeout. The default client's 60s limit
|
||||
// causes downloads to fail on slow connections or for large Hi-Res files.
|
||||
downloadClient := &http.Client{
|
||||
Timeout: 5 * time.Minute, // 5 minutes for large files
|
||||
}
|
||||
|
||||
resp, err := downloadClient.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)
|
||||
}
|
||||
|
||||
fmt.Printf("Creating file: %s\n", filepath)
|
||||
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 (q *QobuzDownloader) DownloadCoverArt(coverURL, filepath string) error {
|
||||
if coverURL == "" {
|
||||
return fmt.Errorf("no cover URL provided")
|
||||
}
|
||||
|
||||
resp, err := q.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 buildQobuzFilename(title, artist, album, albumArtist, releaseDate string, trackNumber, discNumber int, format string, includeTrackNumber bool, position int, useAlbumTrackNumber bool) string {
|
||||
var filename string
|
||||
|
||||
// Determine track number to use
|
||||
numberToUse := position
|
||||
if useAlbumTrackNumber && trackNumber > 0 {
|
||||
numberToUse = trackNumber
|
||||
}
|
||||
|
||||
// Extract year from release date (format: YYYY-MM-DD or YYYY)
|
||||
year := ""
|
||||
if len(releaseDate) >= 4 {
|
||||
year = releaseDate[:4]
|
||||
}
|
||||
|
||||
// Check if format is a template (contains {})
|
||||
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)
|
||||
|
||||
// Handle disc number
|
||||
if discNumber > 0 {
|
||||
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
|
||||
} else {
|
||||
filename = strings.ReplaceAll(filename, "{disc}", "")
|
||||
}
|
||||
|
||||
// Handle track number - if numberToUse is 0, remove {track} and surrounding separators
|
||||
if numberToUse > 0 {
|
||||
filename = strings.ReplaceAll(filename, "{track}", fmt.Sprintf("%02d", numberToUse))
|
||||
} else {
|
||||
// Remove {track} with common separators
|
||||
filename = regexp.MustCompile(`\{track\}\.\s*`).ReplaceAllString(filename, "")
|
||||
filename = regexp.MustCompile(`\{track\}\s*-\s*`).ReplaceAllString(filename, "")
|
||||
filename = regexp.MustCompile(`\{track\}\s*`).ReplaceAllString(filename, "")
|
||||
}
|
||||
} else {
|
||||
// Legacy format support
|
||||
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 (legacy behavior)
|
||||
if includeTrackNumber && position > 0 {
|
||||
filename = fmt.Sprintf("%02d. %s", numberToUse, filename)
|
||||
}
|
||||
}
|
||||
|
||||
return filename + ".flac"
|
||||
}
|
||||
|
||||
func (q *QobuzDownloader) DownloadByISRC(isrc, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int) (string, error) {
|
||||
fmt.Printf("Fetching track info for ISRC: %s\n", isrc)
|
||||
|
||||
// Create output directory if it doesn't exist
|
||||
if outputDir != "." {
|
||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||
return "", fmt.Errorf("failed to create output directory: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
track, err := q.SearchByISRC(isrc)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// All metadata from Spotify - no fallback to Qobuz
|
||||
artists := spotifyArtistName
|
||||
trackTitle := spotifyTrackName
|
||||
albumTitle := spotifyAlbumName
|
||||
|
||||
fmt.Printf("Found track: %s - %s\n", artists, trackTitle)
|
||||
fmt.Printf("Album: %s\n", albumTitle)
|
||||
|
||||
qualityInfo := "Standard"
|
||||
if track.Hires {
|
||||
qualityInfo = fmt.Sprintf("Hi-Res (%d-bit / %.1f kHz)", track.MaximumBitDepth, track.MaximumSamplingRate)
|
||||
}
|
||||
fmt.Printf("Quality: %s\n", qualityInfo)
|
||||
|
||||
fmt.Println("Getting download URL...")
|
||||
downloadURL, err := q.GetDownloadURL(track.ID, quality)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get download URL: %w", err)
|
||||
}
|
||||
|
||||
if downloadURL == "" {
|
||||
return "", fmt.Errorf("received empty download URL")
|
||||
}
|
||||
|
||||
// Show partial URL for security
|
||||
urlPreview := downloadURL
|
||||
if len(downloadURL) > 60 {
|
||||
urlPreview = downloadURL[:60] + "..."
|
||||
}
|
||||
fmt.Printf("Download URL obtained: %s\n", urlPreview)
|
||||
|
||||
safeArtist := sanitizeFilename(artists)
|
||||
safeTitle := sanitizeFilename(trackTitle)
|
||||
safeAlbum := sanitizeFilename(albumTitle)
|
||||
safeAlbumArtist := sanitizeFilename(spotifyAlbumArtist)
|
||||
|
||||
// Check if file with same ISRC already exists (use Spotify ISRC)
|
||||
if existingFile, exists := CheckISRCExists(outputDir, isrc); exists {
|
||||
fmt.Printf("File with ISRC %s already exists: %s\n", isrc, existingFile)
|
||||
return "EXISTS:" + existingFile, nil
|
||||
}
|
||||
|
||||
// Build filename based on format settings (use Spotify track number)
|
||||
filename := buildQobuzFilename(safeTitle, safeArtist, safeAlbum, safeAlbumArtist, spotifyReleaseDate, spotifyTrackNumber, spotifyDiscNumber, 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.Printf("Downloading FLAC file to: %s\n", filepath)
|
||||
if err := q.DownloadFile(downloadURL, filepath); err != nil {
|
||||
return "", fmt.Errorf("failed to download file: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Downloaded: %s\n", filepath)
|
||||
|
||||
coverPath := ""
|
||||
// Use Spotify cover URL (with max resolution if enabled) - all metadata from Spotify
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("Embedding metadata and cover art...")
|
||||
|
||||
// Determine track number to embed - ALL from Spotify
|
||||
trackNumberToEmbed := spotifyTrackNumber
|
||||
if position > 0 && !useAlbumTrackNumber {
|
||||
trackNumberToEmbed = position // Use playlist position
|
||||
}
|
||||
|
||||
// ALL metadata from Spotify
|
||||
metadata := Metadata{
|
||||
Title: trackTitle,
|
||||
Artist: artists,
|
||||
Album: albumTitle,
|
||||
AlbumArtist: spotifyAlbumArtist,
|
||||
Date: spotifyReleaseDate, // Recorded date (full date YYYY-MM-DD)
|
||||
TrackNumber: trackNumberToEmbed,
|
||||
TotalTracks: spotifyTotalTracks, // Total tracks in album from Spotify
|
||||
DiscNumber: spotifyDiscNumber, // Disc number from Spotify
|
||||
ISRC: isrc, // ISRC from Spotify (passed as parameter)
|
||||
Description: "https://github.com/afkarxyz/SpotiFLAC",
|
||||
}
|
||||
|
||||
if err := EmbedMetadata(filepath, metadata, coverPath); err != nil {
|
||||
return "", fmt.Errorf("failed to embed metadata: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("Metadata embedded successfully!")
|
||||
return filepath, nil
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// Hiragana to Romaji mapping
|
||||
var hiraganaToRomaji = map[rune]string{
|
||||
'あ': "a", 'い': "i", 'う': "u", 'え': "e", 'お': "o",
|
||||
'か': "ka", 'き': "ki", 'く': "ku", 'け': "ke", 'こ': "ko",
|
||||
'さ': "sa", 'し': "shi", 'す': "su", 'せ': "se", 'そ': "so",
|
||||
'た': "ta", 'ち': "chi", 'つ': "tsu", 'て': "te", 'と': "to",
|
||||
'な': "na", 'に': "ni", 'ぬ': "nu", 'ね': "ne", 'の': "no",
|
||||
'は': "ha", 'ひ': "hi", 'ふ': "fu", 'へ': "he", 'ほ': "ho",
|
||||
'ま': "ma", 'み': "mi", 'む': "mu", 'め': "me", 'も': "mo",
|
||||
'や': "ya", 'ゆ': "yu", 'よ': "yo",
|
||||
'ら': "ra", 'り': "ri", 'る': "ru", 'れ': "re", 'ろ': "ro",
|
||||
'わ': "wa", 'を': "wo", 'ん': "n",
|
||||
// Dakuten (voiced)
|
||||
'が': "ga", 'ぎ': "gi", 'ぐ': "gu", 'げ': "ge", 'ご': "go",
|
||||
'ざ': "za", 'じ': "ji", 'ず': "zu", 'ぜ': "ze", 'ぞ': "zo",
|
||||
'だ': "da", 'ぢ': "ji", 'づ': "zu", 'で': "de", 'ど': "do",
|
||||
'ば': "ba", 'び': "bi", 'ぶ': "bu", 'べ': "be", 'ぼ': "bo",
|
||||
// Handakuten (semi-voiced)
|
||||
'ぱ': "pa", 'ぴ': "pi", 'ぷ': "pu", 'ぺ': "pe", 'ぽ': "po",
|
||||
// Small characters
|
||||
'ゃ': "ya", 'ゅ': "yu", 'ょ': "yo",
|
||||
'っ': "", // Double consonant marker
|
||||
'ぁ': "a", 'ぃ': "i", 'ぅ': "u", 'ぇ': "e", 'ぉ': "o",
|
||||
}
|
||||
|
||||
// Katakana to Romaji mapping
|
||||
var katakanaToRomaji = map[rune]string{
|
||||
'ア': "a", 'イ': "i", 'ウ': "u", 'エ': "e", 'オ': "o",
|
||||
'カ': "ka", 'キ': "ki", 'ク': "ku", 'ケ': "ke", 'コ': "ko",
|
||||
'サ': "sa", 'シ': "shi", 'ス': "su", 'セ': "se", 'ソ': "so",
|
||||
'タ': "ta", 'チ': "chi", 'ツ': "tsu", 'テ': "te", 'ト': "to",
|
||||
'ナ': "na", 'ニ': "ni", 'ヌ': "nu", 'ネ': "ne", 'ノ': "no",
|
||||
'ハ': "ha", 'ヒ': "hi", 'フ': "fu", 'ヘ': "he", 'ホ': "ho",
|
||||
'マ': "ma", 'ミ': "mi", 'ム': "mu", 'メ': "me", 'モ': "mo",
|
||||
'ヤ': "ya", 'ユ': "yu", 'ヨ': "yo",
|
||||
'ラ': "ra", 'リ': "ri", 'ル': "ru", 'レ': "re", 'ロ': "ro",
|
||||
'ワ': "wa", 'ヲ': "wo", 'ン': "n",
|
||||
// Dakuten (voiced)
|
||||
'ガ': "ga", 'ギ': "gi", 'グ': "gu", 'ゲ': "ge", 'ゴ': "go",
|
||||
'ザ': "za", 'ジ': "ji", 'ズ': "zu", 'ゼ': "ze", 'ゾ': "zo",
|
||||
'ダ': "da", 'ヂ': "ji", 'ヅ': "zu", 'デ': "de", 'ド': "do",
|
||||
'バ': "ba", 'ビ': "bi", 'ブ': "bu", 'ベ': "be", 'ボ': "bo",
|
||||
// Handakuten (semi-voiced)
|
||||
'パ': "pa", 'ピ': "pi", 'プ': "pu", 'ペ': "pe", 'ポ': "po",
|
||||
// Small characters
|
||||
'ャ': "ya", 'ュ': "yu", 'ョ': "yo",
|
||||
'ッ': "", // Double consonant marker
|
||||
'ァ': "a", 'ィ': "i", 'ゥ': "u", 'ェ': "e", 'ォ': "o",
|
||||
// Extended katakana
|
||||
'ー': "", // Long vowel mark
|
||||
'ヴ': "vu",
|
||||
}
|
||||
|
||||
// Combination mappings for きゃ, しゃ, etc.
|
||||
var combinationHiragana = map[string]string{
|
||||
"きゃ": "kya", "きゅ": "kyu", "きょ": "kyo",
|
||||
"しゃ": "sha", "しゅ": "shu", "しょ": "sho",
|
||||
"ちゃ": "cha", "ちゅ": "chu", "ちょ": "cho",
|
||||
"にゃ": "nya", "にゅ": "nyu", "にょ": "nyo",
|
||||
"ひゃ": "hya", "ひゅ": "hyu", "ひょ": "hyo",
|
||||
"みゃ": "mya", "みゅ": "myu", "みょ": "myo",
|
||||
"りゃ": "rya", "りゅ": "ryu", "りょ": "ryo",
|
||||
"ぎゃ": "gya", "ぎゅ": "gyu", "ぎょ": "gyo",
|
||||
"じゃ": "ja", "じゅ": "ju", "じょ": "jo",
|
||||
"びゃ": "bya", "びゅ": "byu", "びょ": "byo",
|
||||
"ぴゃ": "pya", "ぴゅ": "pyu", "ぴょ": "pyo",
|
||||
}
|
||||
|
||||
var combinationKatakana = map[string]string{
|
||||
"キャ": "kya", "キュ": "kyu", "キョ": "kyo",
|
||||
"シャ": "sha", "シュ": "shu", "ショ": "sho",
|
||||
"チャ": "cha", "チュ": "chu", "チョ": "cho",
|
||||
"ニャ": "nya", "ニュ": "nyu", "ニョ": "nyo",
|
||||
"ヒャ": "hya", "ヒュ": "hyu", "ヒョ": "hyo",
|
||||
"ミャ": "mya", "ミュ": "myu", "ミョ": "myo",
|
||||
"リャ": "rya", "リュ": "ryu", "リョ": "ryo",
|
||||
"ギャ": "gya", "ギュ": "gyu", "ギョ": "gyo",
|
||||
"ジャ": "ja", "ジュ": "ju", "ジョ": "jo",
|
||||
"ビャ": "bya", "ビュ": "byu", "ビョ": "byo",
|
||||
"ピャ": "pya", "ピュ": "pyu", "ピョ": "pyo",
|
||||
// Extended combinations
|
||||
"ティ": "ti", "ディ": "di", "トゥ": "tu", "ドゥ": "du",
|
||||
"ファ": "fa", "フィ": "fi", "フェ": "fe", "フォ": "fo",
|
||||
"ウィ": "wi", "ウェ": "we", "ウォ": "wo",
|
||||
}
|
||||
|
||||
// ContainsJapanese checks if a string contains Japanese characters
|
||||
func ContainsJapanese(s string) bool {
|
||||
for _, r := range s {
|
||||
if isHiragana(r) || isKatakana(r) || isKanji(r) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isHiragana(r rune) bool {
|
||||
return r >= 0x3040 && r <= 0x309F
|
||||
}
|
||||
|
||||
func isKatakana(r rune) bool {
|
||||
return r >= 0x30A0 && r <= 0x30FF
|
||||
}
|
||||
|
||||
func isKanji(r rune) bool {
|
||||
return (r >= 0x4E00 && r <= 0x9FFF) || // CJK Unified Ideographs
|
||||
(r >= 0x3400 && r <= 0x4DBF) // CJK Unified Ideographs Extension A
|
||||
}
|
||||
|
||||
// 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 {
|
||||
if !ContainsJapanese(text) {
|
||||
return text
|
||||
}
|
||||
|
||||
var result strings.Builder
|
||||
runes := []rune(text)
|
||||
i := 0
|
||||
|
||||
for i < len(runes) {
|
||||
// Check for っ/ッ (double consonant)
|
||||
if i < len(runes)-1 && (runes[i] == 'っ' || runes[i] == 'ッ') {
|
||||
nextRomaji := ""
|
||||
if romaji, ok := hiraganaToRomaji[runes[i+1]]; ok {
|
||||
nextRomaji = romaji
|
||||
} else if romaji, ok := katakanaToRomaji[runes[i+1]]; ok {
|
||||
nextRomaji = romaji
|
||||
}
|
||||
if len(nextRomaji) > 0 {
|
||||
result.WriteByte(nextRomaji[0]) // Double the first consonant
|
||||
}
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for two-character combinations
|
||||
if i < len(runes)-1 {
|
||||
combo := string(runes[i : i+2])
|
||||
if romaji, ok := combinationHiragana[combo]; ok {
|
||||
result.WriteString(romaji)
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
if romaji, ok := combinationKatakana[combo]; ok {
|
||||
result.WriteString(romaji)
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Single character conversion
|
||||
r := runes[i]
|
||||
if romaji, ok := hiraganaToRomaji[r]; ok {
|
||||
result.WriteString(romaji)
|
||||
} else if romaji, ok := katakanaToRomaji[r]; ok {
|
||||
result.WriteString(romaji)
|
||||
} else if isKanji(r) {
|
||||
// Keep kanji as-is (would need dictionary for proper conversion)
|
||||
result.WriteRune(r)
|
||||
} else {
|
||||
// Keep other characters (punctuation, spaces, etc.)
|
||||
result.WriteRune(r)
|
||||
}
|
||||
i++
|
||||
}
|
||||
|
||||
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 {
|
||||
// Convert Japanese to romaji
|
||||
trackRomaji := JapaneseToRomaji(trackName)
|
||||
artistRomaji := JapaneseToRomaji(artistName)
|
||||
|
||||
// Clean up the query - remove special characters that might interfere with search
|
||||
trackClean := cleanSearchQuery(trackRomaji)
|
||||
artistClean := cleanSearchQuery(artistRomaji)
|
||||
|
||||
return strings.TrimSpace(artistClean + " " + trackClean)
|
||||
}
|
||||
|
||||
// cleanSearchQuery removes special characters that might interfere with search
|
||||
func cleanSearchQuery(s string) string {
|
||||
var result strings.Builder
|
||||
for _, r := range s {
|
||||
if unicode.IsLetter(r) || unicode.IsNumber(r) || unicode.IsSpace(r) {
|
||||
result.WriteRune(r)
|
||||
} else if r == '-' || r == '\'' {
|
||||
result.WriteRune(r)
|
||||
}
|
||||
}
|
||||
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 {
|
||||
var result strings.Builder
|
||||
for _, r := range s {
|
||||
// Keep only ASCII letters, numbers, spaces, and basic punctuation
|
||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
|
||||
(r >= '0' && r <= '9') || r == ' ' || r == '-' || r == '\'' {
|
||||
result.WriteRune(r)
|
||||
} else if r == ',' || r == '.' {
|
||||
// Convert punctuation to space
|
||||
result.WriteRune(' ')
|
||||
}
|
||||
}
|
||||
// Clean up multiple spaces
|
||||
cleaned := strings.Join(strings.Fields(result.String()), " ")
|
||||
return strings.TrimSpace(cleaned)
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
type SongLinkClient struct {
|
||||
client *http.Client
|
||||
lastAPICallTime time.Time
|
||||
apiCallCount int
|
||||
apiCallResetTime time.Time
|
||||
}
|
||||
|
||||
type SongLinkURLs struct {
|
||||
TidalURL string `json:"tidal_url"`
|
||||
AmazonURL string `json:"amazon_url"`
|
||||
}
|
||||
|
||||
// TrackAvailability represents the availability of a track on different platforms
|
||||
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 {
|
||||
return &SongLinkClient{
|
||||
client: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
apiCallResetTime: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string) (*SongLinkURLs, error) {
|
||||
// Rate limiting: max 10 requests per minute (song.link API limit)
|
||||
now := time.Now()
|
||||
if now.Sub(s.apiCallResetTime) >= time.Minute {
|
||||
s.apiCallCount = 0
|
||||
s.apiCallResetTime = now
|
||||
}
|
||||
|
||||
// If we've hit the limit, wait until the next minute
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
// Add delay between requests (7 seconds to be safe)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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), url.QueryEscape(spotifyURL))
|
||||
|
||||
req, err := http.NewRequest("GET", apiURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("Getting streaming URLs from song.link...")
|
||||
|
||||
// Retry logic for rate limit errors
|
||||
maxRetries := 3
|
||||
var resp *http.Response
|
||||
for i := 0; i < maxRetries; i++ {
|
||||
resp, err = s.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get URLs: %w", err)
|
||||
}
|
||||
|
||||
// Update rate limit tracking
|
||||
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"`
|
||||
}
|
||||
// Read body first to handle encoding issues
|
||||
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 {
|
||||
// Truncate body for error message (max 200 chars)
|
||||
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{}
|
||||
|
||||
// Extract Tidal URL
|
||||
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
||||
urls.TidalURL = tidalLink.URL
|
||||
fmt.Printf("✓ Tidal URL found\n")
|
||||
}
|
||||
|
||||
// Extract Amazon URL
|
||||
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
||||
amazonURL := amazonLink.URL
|
||||
// Convert album URL to track URL if needed
|
||||
if len(amazonURL) > 0 {
|
||||
urls.AmazonURL = amazonURL
|
||||
fmt.Printf("✓ Amazon URL found\n")
|
||||
}
|
||||
}
|
||||
|
||||
// Check if at least one URL was found
|
||||
if urls.TidalURL == "" && urls.AmazonURL == "" {
|
||||
return nil, fmt.Errorf("no streaming URLs found")
|
||||
}
|
||||
|
||||
return urls, nil
|
||||
}
|
||||
|
||||
// CheckTrackAvailability checks the availability of a track on different platforms
|
||||
func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, error) {
|
||||
// Rate limiting: max 10 requests per minute (song.link API limit)
|
||||
now := time.Now()
|
||||
if now.Sub(s.apiCallResetTime) >= time.Minute {
|
||||
s.apiCallCount = 0
|
||||
s.apiCallResetTime = now
|
||||
}
|
||||
|
||||
// If we've hit the limit, wait until the next minute
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
// Add delay between requests (7 seconds to be safe)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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), 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)
|
||||
|
||||
// Retry logic for rate limit errors
|
||||
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)
|
||||
}
|
||||
|
||||
// Update rate limit tracking
|
||||
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"`
|
||||
}
|
||||
// Read body first to handle encoding issues
|
||||
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 {
|
||||
// Truncate body for error message (max 200 chars)
|
||||
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,
|
||||
}
|
||||
|
||||
// Check Tidal
|
||||
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
||||
availability.Tidal = true
|
||||
availability.TidalURL = tidalLink.URL
|
||||
}
|
||||
|
||||
// Check Amazon
|
||||
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
||||
availability.Amazon = true
|
||||
availability.AmazonURL = amazonLink.URL
|
||||
}
|
||||
|
||||
// Check Qobuz using ISRC (song.link doesn't support Qobuz)
|
||||
if isrc != "" {
|
||||
qobuzAvailable := checkQobuzAvailability(isrc)
|
||||
availability.Qobuz = qobuzAvailable
|
||||
}
|
||||
|
||||
return availability, nil
|
||||
}
|
||||
|
||||
// checkQobuzAvailability checks if a track is available on Qobuz using ISRC
|
||||
func checkQobuzAvailability(isrc string) bool {
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
appID := "798273057"
|
||||
|
||||
// Decode base64 API URL
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"math/cmplx"
|
||||
|
||||
"github.com/mewkiz/flac"
|
||||
)
|
||||
|
||||
// SpectrumData contains frequency spectrum information
|
||||
type SpectrumData struct {
|
||||
TimeSlices []TimeSlice `json:"time_slices"`
|
||||
SampleRate int `json:"sample_rate"`
|
||||
FreqBins int `json:"freq_bins"`
|
||||
Duration float64 `json:"duration"`
|
||||
MaxFreq float64 `json:"max_freq"`
|
||||
}
|
||||
|
||||
// TimeSlice represents spectrum data at a point in time
|
||||
type TimeSlice struct {
|
||||
Time float64 `json:"time"`
|
||||
Magnitudes []float64 `json:"magnitudes"`
|
||||
}
|
||||
|
||||
// AnalyzeSpectrum decodes FLAC file and performs FFT analysis
|
||||
func AnalyzeSpectrum(filepath string) (*SpectrumData, error) {
|
||||
// Open FLAC file
|
||||
stream, err := flac.ParseFile(filepath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse FLAC: %w", err)
|
||||
}
|
||||
defer stream.Close()
|
||||
|
||||
info := stream.Info
|
||||
sampleRate := int(info.SampleRate)
|
||||
channels := int(info.NChannels)
|
||||
|
||||
// Read audio samples
|
||||
samples, err := readSamples(stream, channels)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read samples: %w", err)
|
||||
}
|
||||
|
||||
if len(samples) == 0 {
|
||||
return nil, fmt.Errorf("no audio samples found")
|
||||
}
|
||||
|
||||
// Calculate spectrum
|
||||
return calculateSpectrum(samples, sampleRate), nil
|
||||
}
|
||||
|
||||
// readSamples reads and decodes audio samples from FLAC stream
|
||||
func readSamples(stream *flac.Stream, channels int) ([]float64, error) {
|
||||
var allSamples []float64
|
||||
maxSamples := 10 * 1024 * 1024 // Limit to ~10 million samples to avoid memory issues
|
||||
|
||||
// Decode frames
|
||||
for {
|
||||
frame, err := stream.ParseNext()
|
||||
if err != nil {
|
||||
// End of stream
|
||||
break
|
||||
}
|
||||
|
||||
// Convert samples to float64 and mix channels to mono
|
||||
for i := 0; i < frame.Subframes[0].NSamples; i++ {
|
||||
var sample float64
|
||||
|
||||
// Mix all channels to mono by averaging
|
||||
for ch := 0; ch < channels; ch++ {
|
||||
sample += float64(frame.Subframes[ch].Samples[i])
|
||||
}
|
||||
sample /= float64(channels)
|
||||
|
||||
allSamples = append(allSamples, sample)
|
||||
|
||||
// Limit sample count
|
||||
if len(allSamples) >= maxSamples {
|
||||
return allSamples, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return allSamples, nil
|
||||
}
|
||||
|
||||
// calculateSpectrum performs FFT analysis on audio samples
|
||||
func calculateSpectrum(samples []float64, sampleRate int) *SpectrumData {
|
||||
fftSize := 8192
|
||||
numTimeSlices := 300
|
||||
|
||||
duration := float64(len(samples)) / float64(sampleRate)
|
||||
|
||||
samplesPerSlice := len(samples) / numTimeSlices
|
||||
if samplesPerSlice < fftSize {
|
||||
samplesPerSlice = fftSize
|
||||
numTimeSlices = len(samples) / fftSize
|
||||
}
|
||||
|
||||
timeSlices := make([]TimeSlice, 0, numTimeSlices)
|
||||
freqBins := fftSize / 2
|
||||
maxFreq := float64(sampleRate) / 2.0
|
||||
|
||||
for i := 0; i < numTimeSlices; i++ {
|
||||
startIdx := i * samplesPerSlice
|
||||
if startIdx+fftSize > len(samples) {
|
||||
break
|
||||
}
|
||||
|
||||
window := samples[startIdx : startIdx+fftSize]
|
||||
|
||||
windowedSamples := applyHannWindow(window)
|
||||
|
||||
spectrum := fft(windowedSamples)
|
||||
|
||||
magnitudes := make([]float64, freqBins)
|
||||
for j := 0; j < freqBins; j++ {
|
||||
magnitude := cmplx.Abs(spectrum[j])
|
||||
|
||||
if magnitude < 1e-10 {
|
||||
magnitude = 1e-10
|
||||
}
|
||||
magnitudes[j] = 20 * math.Log10(magnitude)
|
||||
}
|
||||
|
||||
timeSlice := TimeSlice{
|
||||
Time: float64(startIdx) / float64(sampleRate),
|
||||
Magnitudes: magnitudes,
|
||||
}
|
||||
timeSlices = append(timeSlices, timeSlice)
|
||||
}
|
||||
|
||||
return &SpectrumData{
|
||||
TimeSlices: timeSlices,
|
||||
SampleRate: sampleRate,
|
||||
FreqBins: freqBins,
|
||||
Duration: duration,
|
||||
MaxFreq: maxFreq,
|
||||
}
|
||||
}
|
||||
|
||||
// applyHannWindow applies Hann window to reduce spectral leakage
|
||||
func applyHannWindow(samples []float64) []float64 {
|
||||
n := len(samples)
|
||||
windowed := make([]float64, n)
|
||||
|
||||
for i := 0; i < n; i++ {
|
||||
window := 0.5 * (1.0 - math.Cos(2.0*math.Pi*float64(i)/float64(n-1)))
|
||||
windowed[i] = samples[i] * window
|
||||
}
|
||||
|
||||
return windowed
|
||||
}
|
||||
|
||||
// fft performs Fast Fourier Transform using Cooley-Tukey algorithm
|
||||
func fft(samples []float64) []complex128 {
|
||||
n := len(samples)
|
||||
|
||||
x := make([]complex128, n)
|
||||
for i := 0; i < n; i++ {
|
||||
x[i] = complex(samples[i], 0)
|
||||
}
|
||||
|
||||
return fftRecursive(x)
|
||||
}
|
||||
|
||||
// fftRecursive performs recursive FFT
|
||||
func fftRecursive(x []complex128) []complex128 {
|
||||
n := len(x)
|
||||
|
||||
if n <= 1 {
|
||||
return x
|
||||
}
|
||||
|
||||
even := make([]complex128, n/2)
|
||||
odd := make([]complex128, n/2)
|
||||
|
||||
for i := 0; i < n/2; i++ {
|
||||
even[i] = x[2*i]
|
||||
odd[i] = x[2*i+1]
|
||||
}
|
||||
|
||||
evenFFT := fftRecursive(even)
|
||||
oddFFT := fftRecursive(odd)
|
||||
|
||||
result := make([]complex128, n)
|
||||
for k := 0; k < n/2; k++ {
|
||||
t := cmplx.Exp(complex(0, -2*math.Pi*float64(k)/float64(n))) * oddFFT[k]
|
||||
result[k] = evenFFT[k] + t
|
||||
result[k+n/2] = evenFFT[k] - t
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+1564
File diff suppressed because it is too large
Load Diff
@@ -1,45 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-br" viewBox="0 0 640 480">
|
||||
<g stroke-width="1pt">
|
||||
<path fill="#229e45" fill-rule="evenodd" d="M0 0h640v480H0z"/>
|
||||
<path fill="#f8e509" fill-rule="evenodd" d="m321.4 436 301.5-195.7L319.6 44 17.1 240.7z"/>
|
||||
<path fill="#2b49a3" fill-rule="evenodd" d="M452.8 240c0 70.3-57.1 127.3-127.6 127.3A127.4 127.4 0 1 1 452.8 240"/>
|
||||
<path fill="#ffffef" fill-rule="evenodd" d="m283.3 316.3-4-2.3-4 2 .9-4.5-3.2-3.4 4.5-.5 2.2-4 1.9 4.2 4.4.8-3.3 3m86 26.3-3.9-2.3-4 2 .8-4.5-3.1-3.3 4.5-.5 2.1-4.1 2 4.2 4.4.8-3.4 3.1m-36.2-30-3.4-2-3.5 1.8.8-3.9-2.8-2.9 4-.4 1.8-3.6 1.6 3.7 3.9.7-3 2.7m87-8.5-3.4-2-3.5 1.8.8-3.9-2.7-2.8 3.9-.4 1.8-3.5 1.6 3.6 3.8.7-2.9 2.6m-87.3-22-4-2.2-4 2 .8-4.6-3.1-3.3 4.5-.5 2.1-4.1 2 4.2 4.4.8-3.4 3.2m-104.6-35-4-2.2-4 2 1-4.6-3.3-3.3 4.6-.5 2-4.1 2 4.2 4.4.8-3.3 3.1m13.3 57.2-4-2.3-4 2 .9-4.5-3.2-3.3 4.5-.6 2.1-4 2 4.2 4.4.8-3.3 3.1m132-67.3-3.6-2-3.6 1.8.8-4-2.8-3 4-.5 1.9-3.6 1.7 3.8 4 .7-3 2.7m-6.7 38.3-2.7-1.6-2.9 1.4.6-3.2-2.2-2.3 3.2-.4 1.5-2.8 1.3 3 3 .5-2.2 2.2m-142.2 50.4-2.7-1.5-2.7 1.3.6-3-2.1-2.2 3-.4 1.4-2.7 1.3 2.8 3 .6-2.3 2M419 299.8l-2.2-1.1-2.2 1 .5-2.3-1.7-1.6 2.4-.3 1.2-2 1 2 2.5.5-1.9 1.5"/>
|
||||
<path fill="#ffffef" fill-rule="evenodd" d="m219.3 287.6-2.7-1.5-2.7 1.3.6-3-2.1-2.2 3-.4 1.4-2.7 1.3 2.8 3 .6-2.3 2"/>
|
||||
<path fill="#ffffef" fill-rule="evenodd" d="m219.3 287.6-2.7-1.5-2.7 1.3.6-3-2.1-2.2 3-.4 1.4-2.7 1.3 2.8 3 .6-2.3 2m42.3 3-2.6-1.4-2.7 1.3.6-3-2.1-2.2 3-.4 1.4-2.7 1.3 2.8 3 .5-2.3 2.1m-4.8 17-2.6-1.5-2.7 1.4.6-3-2.1-2.3 3-.4 1.4-2.7 1.3 2.8 3 .6-2.3 2m87.4-22.2-2.6-1.6-2.8 1.4.6-3-2-2.3 3-.3 1.4-2.7 1.2 2.8 3 .5-2.2 2.1m-25.1 3-2.7-1.5-2.7 1.4.6-3-2-2.3 3-.3 1.4-2.8 1.2 2.9 3 .5-2.2 2.1m-68.8-5.8-1.7-1-1.7.8.4-1.9-1.3-1.4 1.9-.2.8-1.7.8 1.8 1.9.3-1.4 1.3m167.8 45.4-2.6-1.5-2.7 1.4.6-3-2.1-2.3 3-.4 1.4-2.7 1.3 2.8 3 .6-2.3 2m-20.8 6-2.2-1.4-2.3 1.2.5-2.6-1.7-1.8 2.5-.3 1.2-2.3 1 2.4 2.5.4-1.9 1.8m10.4 2.3-2-1.2-2.1 1 .4-2.3-1.6-1.7 2.3-.3 1.1-2 1 2 2.3.5-1.7 1.6m29.1-22.8-2-1-2 1 .5-2.3-1.6-1.7 2.3-.3 1-2 1 2.1 2.1.4-1.6 1.6m-38.8 41.8-2.5-1.4-2.7 1.2.6-2.8-2-2 3-.3 1.3-2.5 1.2 2.6 3 .5-2.3 1.9m.6 14.2-2.4-1.4-2.4 1.3.6-2.8-1.9-2 2.7-.4 1.2-2.5 1.1 2.6 2.7.5-2 2m-19-23.1-1.9-1.2-2 1 .4-2.2-1.5-1.7 2.2-.2 1-2 1 2 2.2.4-1.6 1.6m-17.8 2.3-2-1.2-2 1 .5-2.2-1.6-1.7 2.3-.2 1-2 1 2 2.1.4-1.6 1.6m-30.4-24.6-2-1.1-2 1 .5-2.3-1.6-1.6 2.2-.3 1-2 1 2 2.2.5-1.6 1.5m3.7 57-1.6-.9-1.8.9.4-2-1.3-1.4 1.9-.2.9-1.7.8 1.8 1.9.3-1.4 1.3m-46.2-86.6-4-2.3-4 2 .9-4.5-3.2-3.3 4.5-.6 2.2-4 1.9 4.2 4.4.8-3.3 3.1"/>
|
||||
<path fill="#fff" fill-rule="evenodd" d="M444.4 285.8a125 125 0 0 0 5.8-19.8c-67.8-59.5-143.3-90-238.7-83.7a125 125 0 0 0-8.5 20.9c113-10.8 196 39.2 241.4 82.6"/>
|
||||
<path fill="#309e3a" d="m414 252.4 2.3 1.3a3 3 0 0 0-.3 2.2 3 3 0 0 0 1.4 1.7q1 .8 2 .7.9 0 1.3-.7l.2-.9-.5-1-1.5-1.8a8 8 0 0 1-1.8-3 4 4 0 0 1 2-4.4 4 4 0 0 1 2.3-.2 7 7 0 0 1 2.6 1.2q2.1 1.5 2.6 3.2a4 4 0 0 1-.6 3.3l-2.4-1.5q.5-1 .2-1.7-.2-.8-1.2-1.4a3 3 0 0 0-1.8-.7 1 1 0 0 0-.9.5q-.3.4-.1 1 .2.8 1.6 2.2t2 2.5a4 4 0 0 1-.3 4.2 4 4 0 0 1-1.9 1.5 4 4 0 0 1-2.4.3q-1.3-.3-2.8-1.3-2.2-1.5-2.7-3.3a5 5 0 0 1 .6-4zm-11.6-7.6 2.5 1.3a3 3 0 0 0-.2 2.2 3 3 0 0 0 1.4 1.6q1.1.8 2 .6.9 0 1.3-.8l.2-.8q0-.5-.5-1l-1.6-1.8q-1.7-1.6-2-2.8a4 4 0 0 1 .4-3.1 4 4 0 0 1 1.6-1.4 4 4 0 0 1 2.2-.3 7 7 0 0 1 2.6 1q2.3 1.5 2.7 3.1a4 4 0 0 1-.4 3.4l-2.5-1.4q.5-1 .2-1.7-.4-1-1.3-1.4a3 3 0 0 0-1.9-.6 1 1 0 0 0-.8.5q-.3.4-.1 1 .3.8 1.7 2.2 1.5 1.5 2 2.4a4 4 0 0 1 0 4.2 4 4 0 0 1-1.8 1.6 4 4 0 0 1-2.4.3 8 8 0 0 1-2.9-1.1 6 6 0 0 1-2.8-3.2 5 5 0 0 1 .4-4m-14.2-3.8 7.3-12 8.8 5.5-1.2 2-6.4-4-1.6 2.7 6 3.7-1.3 2-6-3.7-2 3.3 6.7 4-1.2 2zm-20.7-17 1.1-2 5.4 2.7-2.5 5q-1.2.3-3 .2a9 9 0 0 1-3.3-1 8 8 0 0 1-3-2.6 6 6 0 0 1-1-3.5 9 9 0 0 1 1-3.7 8 8 0 0 1 2.6-3 6 6 0 0 1 3.6-1.1q1.4 0 3.2 1 2.4 1.1 3.1 2.8a5 5 0 0 1 .3 3.5l-2.7-.8a3 3 0 0 0-.2-2q-.4-.9-1.6-1.4a4 4 0 0 0-3.1-.3q-1.5.5-2.6 2.6t-.7 3.8a4 4 0 0 0 2 2.4q.8.5 1.7.5h1.8l.8-1.6zm-90.2-22.3 2-14 4.2.7 1.1 9.8 3.9-9 4.2.6-2 13.8-2.7-.4 1.7-10.9-4.4 10.5-2.7-.4-1.1-11.3-1.6 11zm-14.1-1.7 1.3-14 10.3 1-.2 2.4-7.5-.7-.3 3 7 .7-.3 2.4-7-.7-.3 3.8 7.8.7-.2 2.4z"/>
|
||||
<g stroke-opacity=".5">
|
||||
<path fill="#309e3a" d="M216.5 191.3q0-2.2.7-3.6a7 7 0 0 1 1.4-1.9 5 5 0 0 1 1.8-1.2q1.5-.5 3-.5 3.1.1 5 2a7 7 0 0 1 1.6 5.5q0 3.3-2 5.3a7 7 0 0 1-5 1.7 7 7 0 0 1-4.8-2 7 7 0 0 1-1.7-5.3"/>
|
||||
<path fill="#f7ffff" d="M219.4 191.3q0 2.3 1 3.6t2.8 1.3a4 4 0 0 0 2.8-1.1q1-1.2 1.1-3.7.1-2.4-1-3.6a4 4 0 0 0-2.7-1.3 4 4 0 0 0-2.8 1.2q-1.1 1.2-1.2 3.6"/>
|
||||
</g>
|
||||
<g stroke-opacity=".5">
|
||||
<path fill="#309e3a" d="m233 198.5.2-14h6q2.2 0 3.2.5 1 .3 1.6 1.3c.6 1 .6 1.4.6 2.3a4 4 0 0 1-1 2.6 5 5 0 0 1-2.7 1.2l1.5 1.2q.6.6 1.5 2.3l1.7 2.8h-3.4l-2-3.2-1.4-2-.9-.6-1.4-.2h-.6v5.8z"/>
|
||||
<path fill="#fff" d="M236 190.5h2q2.1 0 2.6-.2.5-.1.8-.5.4-.6.3-1 0-.9-.4-1.2-.3-.4-1-.6h-2l-2.3-.1z"/>
|
||||
</g>
|
||||
<g stroke-opacity=".5">
|
||||
<path fill="#309e3a" d="m249 185.2 5.2.3q1.7 0 2.6.3a5 5 0 0 1 2 1.4 6 6 0 0 1 1.2 2.4q.4 1.4.3 3.3a9 9 0 0 1-.5 3q-.6 1.5-1.7 2.4a5 5 0 0 1-2 1q-1 .3-2.5.2l-5.3-.3z"/>
|
||||
<path fill="#fff" d="m251.7 187.7-.5 9.3h3.8q.8 0 1.2-.5.5-.4.8-1.3t.4-2.6l-.1-2.5a3 3 0 0 0-.8-1.4l-1.2-.7-2.3-.3z"/>
|
||||
</g>
|
||||
<g stroke-opacity=".5">
|
||||
<path fill="#309e3a" d="m317.6 210.2 3.3-13.6 4.4 1 3.2 1q1.1.6 1.6 1.9t.2 2.8q-.3 1.2-1 2a4 4 0 0 1-3 1.4q-1 0-3-.5l-1.7-.5-1.2 5.2z"/>
|
||||
<path fill="#fff" d="m323 199.6-.8 3.8 1.5.4q1.6.4 2.2.3a2 2 0 0 0 1.6-1.5q0-.7-.2-1.3a2 2 0 0 0-1-.9l-1.9-.5-1.3-.3z"/>
|
||||
</g>
|
||||
<g stroke-opacity=".5">
|
||||
<path fill="#309e3a" d="m330.6 214.1 4.7-13.2 5.5 2q2.2.8 3 1.4.8.7 1 1.8c.2 1.1.2 1.5 0 2.3q-.6 1.5-1.8 2.2-1.2.6-3 .3.6.7 1 1.6l.8 2.7.6 3.1-3.1-1.1-1-3.6-.7-2.4-.6-.8q-.3-.4-1.3-.7l-.5-.2-2 5.6z"/>
|
||||
<path fill="#fff" d="m336 207.4 1.9.7q2 .7 2.5.7t.9-.3q.5-.3.6-.9.3-.6 0-1.2l-.8-.9-2-.7-2-.7-1.2 3.3z"/>
|
||||
</g>
|
||||
<g stroke-opacity=".5">
|
||||
<path fill="#309e3a" d="M347 213.6a9 9 0 0 1 1.7-3.2l1.8-1.5 2-.7q1.5-.1 3.1.4a7 7 0 0 1 4.2 3.3q1.2 2.4.2 5.7a7 7 0 0 1-3.4 4.5q-2.3 1.3-5.2.4a7 7 0 0 1-4.2-3.3 7 7 0 0 1-.2-5.6"/>
|
||||
<path fill="#fff" d="M349.8 214.4q-.7 2.3 0 3.8c.7 1.5 1.2 1.6 2.3 2q1.5.5 3-.4 1.4-.8 2.1-3.2.8-2.2 0-3.7a4 4 0 0 0-2.2-2 4 4 0 0 0-3 .3q-1.5.8-2.2 3.2"/>
|
||||
</g>
|
||||
<g stroke-opacity=".5">
|
||||
<path fill="#309e3a" d="m374.3 233.1 6.4-12.4 5.3 2.7a10 10 0 0 1 2.7 1.9q.8.7.8 1.9c0 1.2 0 1.5-.4 2.2a4 4 0 0 1-2 2q-1.5.4-3.1-.2.6 1 .8 1.7.3.9.4 2.8l.2 3.2-3-1.5-.4-3.7-.3-2.5-.5-1-1.2-.7-.5-.3-2.7 5.2z"/>
|
||||
<path fill="#fff" d="m380.5 227.2 1.9 1q1.8 1 2.3 1t1-.2q.4-.2.7-.8t.2-1.2l-.7-1-1.8-1-2-1z"/>
|
||||
</g>
|
||||
<g stroke-opacity=".5">
|
||||
<path fill="#309e3a" d="M426.1 258.7a9 9 0 0 1 2.5-2.6 7 7 0 0 1 2.2-.9 6 6 0 0 1 2.2 0q1.5.3 2.8 1.2a7 7 0 0 1 3 4.4q.4 2.6-1.4 5.5a7 7 0 0 1-4.5 3.3 7 7 0 0 1-5.2-1.1 7 7 0 0 1-3-4.4q-.4-2.7 1.4-5.4"/>
|
||||
<path fill="#fff" d="M428.6 260.3q-1.4 2-1.1 3.6a4 4 0 0 0 1.6 2.5q1.5 1 3 .6t2.9-2.4q1.4-2.1 1.1-3.6t-1.6-2.6c-1.4-1.1-2-.8-3-.5q-1.5.3-3 2.4z"/>
|
||||
</g>
|
||||
<path fill="#309e3a" d="m301.8 204.5 2.3-9.8 7.2 1.7-.3 1.6-5.3-1.2-.5 2.2 4.9 1.1-.4 1.7-4.9-1.2-.6 2.7 5.5 1.3-.4 1.6z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 7.0 KiB |
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 6.4 KiB |
-241
@@ -1,241 +0,0 @@
|
||||
import requests
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from mutagen.flac import FLAC
|
||||
from random import randrange
|
||||
|
||||
def get_random_user_agent():
|
||||
return f"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_{randrange(11, 15)}_{randrange(4, 9)}) AppleWebKit/{randrange(530, 537)}.{randrange(30, 37)} (KHTML, like Gecko) Chrome/{randrange(80, 105)}.0.{randrange(3000, 4500)}.{randrange(60, 125)} Safari/{randrange(530, 537)}.{randrange(30, 36)}"
|
||||
|
||||
class DeezerDownloader:
|
||||
def __init__(self):
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update({
|
||||
'User-Agent': get_random_user_agent()
|
||||
})
|
||||
self.progress_callback = None
|
||||
|
||||
def set_progress_callback(self, callback):
|
||||
self.progress_callback = callback
|
||||
|
||||
def get_track_by_isrc(self, isrc):
|
||||
try:
|
||||
url = f"https://api.deezer.com/2.0/track/isrc:{isrc}"
|
||||
response = self.session.get(url)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
|
||||
if 'error' in data:
|
||||
print(f"Error from Deezer API: {data['error']['message']}")
|
||||
return None
|
||||
|
||||
return data
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"Error fetching track data: {e}")
|
||||
return None
|
||||
|
||||
def extract_metadata(self, track_data):
|
||||
metadata = {}
|
||||
|
||||
metadata['title'] = track_data.get('title', '')
|
||||
metadata['title_short'] = track_data.get('title_short', '')
|
||||
metadata['duration'] = track_data.get('duration', 0)
|
||||
metadata['track_position'] = track_data.get('track_position', 1)
|
||||
metadata['disk_number'] = track_data.get('disk_number', 1)
|
||||
metadata['isrc'] = track_data.get('isrc', '')
|
||||
metadata['release_date'] = track_data.get('release_date', '')
|
||||
metadata['explicit_lyrics'] = track_data.get('explicit_lyrics', False)
|
||||
|
||||
if 'artist' in track_data:
|
||||
metadata['artist'] = track_data['artist'].get('name', '')
|
||||
metadata['artist_id'] = track_data['artist'].get('id', '')
|
||||
|
||||
if 'contributors' in track_data:
|
||||
artists = []
|
||||
for contributor in track_data['contributors']:
|
||||
if contributor.get('role') == 'Main':
|
||||
artists.append(contributor.get('name', ''))
|
||||
metadata['artists'] = ', '.join(artists) if artists else metadata.get('artist', '')
|
||||
|
||||
if 'album' in track_data:
|
||||
album = track_data['album']
|
||||
metadata['album'] = album.get('title', '')
|
||||
metadata['album_id'] = album.get('id', '')
|
||||
metadata['cover_url'] = album.get('cover_xl', album.get('cover_big', ''))
|
||||
metadata['cover_md5'] = album.get('md5_image', '')
|
||||
|
||||
metadata['deezer_link'] = track_data.get('link', '')
|
||||
metadata['preview_url'] = track_data.get('preview', '')
|
||||
|
||||
return metadata
|
||||
|
||||
def download_cover_art(self, cover_url, filename):
|
||||
if not cover_url:
|
||||
return None
|
||||
|
||||
try:
|
||||
response = self.session.get(cover_url)
|
||||
response.raise_for_status()
|
||||
|
||||
cover_path = f"{filename}_cover.jpg"
|
||||
with open(cover_path, 'wb') as f:
|
||||
f.write(response.content)
|
||||
|
||||
return cover_path
|
||||
except Exception as e:
|
||||
print(f"Error downloading cover art: {e}")
|
||||
return None
|
||||
|
||||
def embed_metadata(self, file_path, metadata, cover_path=None):
|
||||
try:
|
||||
audio = FLAC(file_path)
|
||||
|
||||
audio.clear()
|
||||
|
||||
if metadata.get('title'):
|
||||
audio['TITLE'] = metadata['title']
|
||||
if metadata.get('artists'):
|
||||
audio['ARTIST'] = metadata['artists']
|
||||
elif metadata.get('artist'):
|
||||
audio['ARTIST'] = metadata['artist']
|
||||
if metadata.get('album'):
|
||||
audio['ALBUM'] = metadata['album']
|
||||
if metadata.get('release_date'):
|
||||
audio['DATE'] = metadata['release_date']
|
||||
if metadata.get('track_position'):
|
||||
audio['TRACKNUMBER'] = str(metadata['track_position'])
|
||||
if metadata.get('disk_number'):
|
||||
audio['DISCNUMBER'] = str(metadata['disk_number'])
|
||||
if metadata.get('isrc'):
|
||||
audio['ISRC'] = metadata['isrc']
|
||||
|
||||
if cover_path and os.path.exists(cover_path):
|
||||
with open(cover_path, 'rb') as f:
|
||||
cover_data = f.read()
|
||||
|
||||
from mutagen.flac import Picture
|
||||
picture = Picture()
|
||||
picture.type = 3
|
||||
picture.mime = 'image/jpeg'
|
||||
picture.desc = 'Cover'
|
||||
picture.data = cover_data
|
||||
audio.add_picture(picture)
|
||||
|
||||
audio.save()
|
||||
print(f"Metadata embedded successfully in {file_path}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error embedding metadata: {e}")
|
||||
|
||||
async def download_by_isrc(self, isrc, output_dir="."):
|
||||
print(f"Fetching track info for ISRC: {isrc}")
|
||||
|
||||
track_data = self.get_track_by_isrc(isrc)
|
||||
if not track_data:
|
||||
print("Failed to get track data from Deezer API")
|
||||
return False
|
||||
|
||||
metadata = self.extract_metadata(track_data)
|
||||
print(f"Found track: {metadata.get('artists', 'Unknown')} - {metadata.get('title', 'Unknown')}")
|
||||
|
||||
track_id = track_data.get('id')
|
||||
if not track_id:
|
||||
print("No track ID found in Deezer API response")
|
||||
return False
|
||||
|
||||
print(f"Using track ID: {track_id}")
|
||||
|
||||
api_url = f"https://api.deezmate.com/dl/{track_id}"
|
||||
print(f"Requesting download links from: {api_url}")
|
||||
|
||||
try:
|
||||
response = self.session.get(api_url)
|
||||
response.raise_for_status()
|
||||
api_data = response.json()
|
||||
|
||||
if not api_data.get('success'):
|
||||
print("API request failed")
|
||||
return False
|
||||
|
||||
links = api_data.get('links', {})
|
||||
flac_url = links.get('flac')
|
||||
|
||||
if not flac_url:
|
||||
print("No FLAC download link found in API response")
|
||||
return False
|
||||
|
||||
print(f"Successfully obtained FLAC download URL")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error getting download URL from API: {e}")
|
||||
return False
|
||||
|
||||
print("Downloading FLAC file...")
|
||||
try:
|
||||
response = self.session.get(flac_url)
|
||||
response.raise_for_status()
|
||||
|
||||
safe_title = "".join(c for c in metadata.get('title', 'Unknown') if c.isalnum() or c in (' ', '-', '_')).rstrip()
|
||||
safe_artist = "".join(c for c in metadata.get('artists', 'Unknown') if c.isalnum() or c in (' ', '-', '_')).rstrip()
|
||||
filename = f"{safe_artist} - {safe_title}.flac"
|
||||
file_path = os.path.join(output_dir, filename)
|
||||
|
||||
with open(file_path, 'wb') as f:
|
||||
f.write(response.content)
|
||||
|
||||
downloaded = len(response.content)
|
||||
print(f"File size: {downloaded} bytes ({downloaded / (1024*1024):.2f} MB)")
|
||||
|
||||
if self.progress_callback:
|
||||
self.progress_callback(downloaded, downloaded)
|
||||
|
||||
print(f"Downloaded: {file_path}")
|
||||
|
||||
cover_path = None
|
||||
if metadata.get('cover_url'):
|
||||
print("Downloading cover art...")
|
||||
cover_path = self.download_cover_art(metadata['cover_url'],
|
||||
os.path.join(output_dir, f"{safe_artist} - {safe_title}"))
|
||||
|
||||
print("Embedding metadata...")
|
||||
self.embed_metadata(file_path, metadata, cover_path)
|
||||
|
||||
if cover_path and os.path.exists(cover_path):
|
||||
os.remove(cover_path)
|
||||
|
||||
print(f"Successfully downloaded and tagged: {filename}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error downloading file: {e}")
|
||||
return False
|
||||
|
||||
async def main():
|
||||
print("=== DeezerDL - Deezer Downloader ===")
|
||||
downloader = DeezerDownloader()
|
||||
|
||||
isrc = "USAT22409172"
|
||||
output_dir = "."
|
||||
|
||||
success = await downloader.download_by_isrc(isrc, output_dir)
|
||||
if success:
|
||||
print("Download completed successfully!")
|
||||
else:
|
||||
print("Download failed!")
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
import sys
|
||||
if sys.platform == "win32":
|
||||
import os
|
||||
os.system("chcp 65001 > nul")
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding='utf-8')
|
||||
except:
|
||||
pass
|
||||
except:
|
||||
pass
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -1,28 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" id="flag-icons-eu" viewBox="0 0 640 480">
|
||||
<defs>
|
||||
<g id="eu-d">
|
||||
<g id="eu-b">
|
||||
<path id="eu-a" d="m0-1-.3 1 .5.1z"/>
|
||||
<use xlink:href="#eu-a" transform="scale(-1 1)"/>
|
||||
</g>
|
||||
<g id="eu-c">
|
||||
<use xlink:href="#eu-b" transform="rotate(72)"/>
|
||||
<use xlink:href="#eu-b" transform="rotate(144)"/>
|
||||
</g>
|
||||
<use xlink:href="#eu-c" transform="scale(-1 1)"/>
|
||||
</g>
|
||||
</defs>
|
||||
<path fill="#039" d="M0 0h640v480H0z"/>
|
||||
<g fill="#fc0" transform="translate(320 242.3)scale(23.7037)">
|
||||
<use xlink:href="#eu-d" width="100%" height="100%" y="-6"/>
|
||||
<use xlink:href="#eu-d" width="100%" height="100%" y="6"/>
|
||||
<g id="eu-e">
|
||||
<use xlink:href="#eu-d" width="100%" height="100%" x="-6"/>
|
||||
<use xlink:href="#eu-d" width="100%" height="100%" transform="rotate(-144 -2.3 -2.1)"/>
|
||||
<use xlink:href="#eu-d" width="100%" height="100%" transform="rotate(144 -2.1 -2.3)"/>
|
||||
<use xlink:href="#eu-d" width="100%" height="100%" transform="rotate(72 -4.7 -2)"/>
|
||||
<use xlink:href="#eu-d" width="100%" height="100%" transform="rotate(72 -5 .5)"/>
|
||||
</g>
|
||||
<use xlink:href="#eu-e" width="100%" height="100%" transform="scale(-1 1)"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"registries": {
|
||||
"@lucide-animated": "https://lucide-animated.com/r/{name}.json"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@300..800&family=Figtree:wght@300..900&family=Geist:wght@100..900&family=Google+Sans+Code:ital,wght@0,300..800;1,300..800&family=Google+Sans:ital,opsz,wght@0,17..18,400..700;1,17..18,400..700&family=Inter:wght@300..800&family=JetBrains+Mono:ital,wght@0,100..800;1,100..800&family=Manrope:wght@300..800&family=Noto+Sans:wght@100..900&family=Nunito+Sans:opsz,wght@6..12,200..1000&family=Outfit:wght@100..900&family=Plus+Jakarta+Sans:wght@300..800&family=Poppins:wght@300;400;500;600;700;800&family=Public+Sans:ital,wght@0,100..900;1,100..900&family=Raleway:wght@100..900&family=Roboto:wght@300;400;500;700;900&family=Space+Grotesk:wght@300..700&display=swap" rel="stylesheet">
|
||||
<title>SpotiFLAC</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"postinstall": "node scripts/generate-icon.js",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"generate-icon": "node scripts/generate-icon.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-context-menu": "^2.2.16",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@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",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.562.0",
|
||||
"motion": "^12.23.26",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss": "^4.1.18"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@types/node": "^25.0.3",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.2",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.26",
|
||||
"globals": "^16.5.0",
|
||||
"sharp": "^0.34.5",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.50.1",
|
||||
"vite": "^7.3.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
0f9764c2a4597a75120d3e76c32af7a9
|
||||
Generated
+3812
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:sketch="http://www.bohemiancoding.com/sketch/ns" viewBox="0 0 512 512">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #733e0a;
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: #fdc700;
|
||||
}
|
||||
|
||||
.cls-3 {
|
||||
fill: #1ed760;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<!-- Generator: Adobe Illustrator 28.7.2, SVG Export Plug-In . SVG Version: 1.2.0 Build 154) -->
|
||||
<g>
|
||||
<g id="Layer_1">
|
||||
<g>
|
||||
<g id="_1818452274576">
|
||||
<g id="SVGRepo_iconCarrier">
|
||||
<path class="cls-3" d="M384.2,203.1c-46.4-23.4-101.2-37.1-159.1-37.1s-64.9,4.4-95.2,12.7l2.6-.6c-1.8.6-3.8.9-6,.9-11,0-19.9-8.9-19.9-19.9s5.8-16.4,13.8-19h.2c77.1-22.9,204-18.5,284.4,29.3,6.1,3.8,10.2,10.4,10.2,18.1s-1,7.3-2.7,10.3h0c-4.3,5-10.4,8-17.5,8s-7.6-1-10.8-2.8h0ZM381.9,263.9c-2.9,4.9-8.1,7.9-14.1,7.9s-6.2-.9-8.8-2.6h0c-39.7-22.6-87.2-35.9-137.8-35.9s-54.8,4.1-80.2,11.6l2-.5c-1.5.4-3.2.8-5,.8-9.1,0-16.5-7.3-16.5-16.5s4.9-13.6,11.4-15.7h0c26.1-7.7,56.1-12.2,87.2-12.2,57.7,0,111.9,15.5,158.6,42.4l-1.5-.9c4.4,2.8,7.1,7.5,7.1,13s-.9,6.1-2.6,8.5h.3-.1ZM355.9,323.6c-2.3,3.9-6.4,6.5-11.3,6.5s-5.2-.9-7.3-2.2h0c-34.7-19.5-76.1-30.9-120.1-30.9s-49.7,3.8-72.7,10.8l1.8-.4c-.9.3-2.1.4-3.2.4-7.3,0-13.4-6.1-13.4-13.4s4.4-11.5,10.1-13h0c22.9-6.7,49.2-10.7,76.4-10.7,49.3,0,95.5,12.8,135.6,35.4l-1.5-.8c4.4,2.2,7.3,6.6,7.3,11.7s-.7,4.8-1.9,6.6h.2,0ZM256,10h0c-119.9,0-217.1,97.2-217.1,217.1s97.2,217.1,217.1,217.1,217.1-97.2,217.1-217.1h0c-.3-119.7-97.3-216.7-217.1-217.1h0Z"/>
|
||||
</g>
|
||||
</g>
|
||||
<path class="cls-2" d="M53.9,351h398.8c11.2,0,20.4,9,20.4,20.1v110.8c0,11.1-9.1,20.1-20.4,20.1H53.9c-11.2,0-20.4-9-20.4-20.1v-110.8c0-11.1,9.1-20.1,20.4-20.1Z"/>
|
||||
<g>
|
||||
<path class="cls-1" d="M113.6,479.3c-2.4,0-4.4-.8-5.9-2.3-1.5-1.5-2.3-3.5-2.3-5.8v-89.3c0-2.4.8-4.3,2.3-5.9,1.5-1.5,3.5-2.3,5.9-2.3s4.3.8,5.8,2.3c1.5,1.5,2.3,3.5,2.3,5.9v35h17.5v-35c0-2.4.8-4.3,2.3-5.9,1.5-1.5,3.5-2.3,5.9-2.3s4.3.8,5.8,2.3c1.5,1.5,2.3,3.5,2.3,5.9v89.3c0,2.4-.7,4.4-2.2,5.9-1.5,1.5-3.5,2.2-5.9,2.2s-4.4-.8-5.9-2.3c-1.5-1.5-2.3-3.5-2.3-5.8v-39.5h-17.5v39.5c0,2.4-.8,4.4-2.3,5.9-1.5,1.5-3.5,2.2-5.8,2.2Z"/>
|
||||
<path class="cls-1" d="M175.9,479.3c-2.4,0-4.4-.8-5.9-2.3-1.5-1.5-2.3-3.5-2.3-5.8v-89.3c0-2.4.8-4.3,2.3-5.9,1.5-1.5,3.5-2.3,5.9-2.3s4.3.8,5.8,2.3c1.5,1.5,2.3,3.5,2.3,5.9v89.3c0,2.4-.8,4.4-2.3,5.9-1.5,1.5-3.5,2.2-5.8,2.2Z"/>
|
||||
<path class="cls-1" d="M200.4,434c-2,0-3.7-.7-5.2-2.2-1.5-1.4-2.2-3.2-2.2-5.3s.7-3.8,2.2-5.2c1.4-1.4,3.2-2.2,5.2-2.2h19.5c2,0,3.8.7,5.2,2.2s2.2,3.2,2.2,5.2-.7,3.8-2.2,5.3-3.2,2.2-5.2,2.2h-19.5Z"/>
|
||||
<path class="cls-1" d="M250.3,477.2c-1.4,1.4-3.4,2.1-6,2.1s-4.6-.7-6-2.1c-1.4-1.4-2.1-3.4-2.1-6v-88.4c0-2.6.7-4.6,2.1-6,1.4-1.4,3.4-2.1,6-2.1h16c8.4,0,14.5,2,18.4,5.9,3.9,3.9,5.8,9.9,5.8,18v6.4c0,10.7-3.6,17.6-10.7,20.5v.3c3.9,1.2,6.7,3.6,8.4,7.2s2.5,8.5,2.5,14.7v16.1c0,2.5.2,4.5.6,6.1.4,1.5.6,2.8.6,4.1,0,3.6-2.6,5.4-7.7,5.4s-5.9-1-7.5-2.9c-1.6-2-2.4-5.2-2.4-9.8v-19.8c0-4.8-.8-8.1-2.3-10-1.5-1.9-4.2-2.8-7.9-2.8h-5.6v37.2c0,2.6-.7,4.6-2.1,6ZM252.4,419.1h5.9c3.2,0,5.7-.8,7.3-2.5s2.5-4.5,2.5-8.5v-8c0-3.7-.7-6.4-2-8.1s-3.4-2.6-6.3-2.6h-7.5v29.7Z"/>
|
||||
<path class="cls-1" d="M304,478.4c-2.4,0-4.3-.8-5.9-2.3-1.5-1.5-2.3-3.5-2.3-5.9v-87.5c0-2.4.8-4.3,2.3-5.9s3.5-2.3,5.9-2.3h29.8c2.1,0,3.9.7,5.3,2.1,1.4,1.4,2.1,3.2,2.1,5.3s-.7,3.9-2.1,5.3c-1.4,1.4-3.2,2.1-5.3,2.1h-21.7v27.4h15.9c2.1,0,3.9.7,5.3,2.1,1.4,1.4,2.1,3.2,2.1,5.3s-.7,3.9-2.1,5.3c-1.4,1.4-3.2,2.1-5.3,2.1h-15.9v31.9h21.7c2.1,0,3.9.7,5.3,2.1,1.4,1.4,2.1,3.2,2.1,5.3s-.7,3.9-2.1,5.3c-1.4,1.4-3.2,2.1-5.3,2.1h-29.8Z"/>
|
||||
<path class="cls-1" d="M371.2,479.9c-7.9,0-13.8-2.2-17.9-6.6-4.1-4.4-6.1-10.6-6.1-18.6s.7-4.3,2-5.7c1.4-1.4,3.3-2.1,5.7-2.1s4.2.7,5.6,2c1.4,1.3,2.1,3.4,2.1,6.2,0,6.8,2.8,10.1,8.5,10.1s8.5-3.5,8.5-10.4-1-8.1-3-11.4c-2-3.3-5.6-7.3-11-12-6.8-5.9-11.5-11.3-14.1-16.1-2.7-4.8-4-10.2-4-16.2s2.1-14.6,6.3-19c4.2-4.5,10.2-6.7,18.1-6.7s13.5,2.2,17.6,6.6c4.1,4.4,6.2,10.1,6.2,17s-2.6,7.8-7.7,7.8-4.4-.7-5.7-2.2-2-3.3-2-5.6-.7-4.9-2.1-6.4c-1.4-1.5-3.4-2.3-6-2.3-5.5,0-8.2,3.3-8.2,9.9s1,7.3,3.1,10.5c2.1,3.2,5.7,7.2,11,11.9,6.8,6,11.4,11.4,14,16.3,2.6,4.9,3.9,10.5,3.9,17s-2.1,15-6.3,19.5c-4.2,4.6-10.3,6.8-18.3,6.8Z"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,33 @@
|
||||
import sharp from 'sharp';
|
||||
import { readFileSync, mkdirSync } from 'fs';
|
||||
import { dirname, join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const rootDir = join(__dirname, '..', '..');
|
||||
|
||||
const svgPath = join(rootDir, 'frontend', 'public', 'icon.svg');
|
||||
const outputPath = join(rootDir, 'build', 'appicon.png');
|
||||
|
||||
async function generateIcon() {
|
||||
try {
|
||||
// Ensure build directory exists
|
||||
mkdirSync(join(rootDir, 'build'), { recursive: true });
|
||||
|
||||
// Read SVG
|
||||
const svgBuffer = readFileSync(svgPath);
|
||||
|
||||
// Convert SVG to PNG (1024x1024 for Wails)
|
||||
await sharp(svgBuffer)
|
||||
.resize(1024, 1024)
|
||||
.png()
|
||||
.toFile(outputPath);
|
||||
|
||||
console.log('✓ Icon generated:', outputPath);
|
||||
} catch (error) {
|
||||
console.error('✗ Failed to generate icon:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
generateIcon();
|
||||
@@ -0,0 +1,719 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Search, X, ArrowUp } from "lucide-react";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { getSettings, getSettingsWithDefaults, saveSettings, applyThemeMode, applyFont } from "@/lib/settings";
|
||||
import { applyTheme } from "@/lib/themes";
|
||||
import { OpenFolder } from "../wailsjs/go/main/App";
|
||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||
|
||||
// Components
|
||||
import { TitleBar } from "@/components/TitleBar";
|
||||
import { Sidebar, type PageType } from "@/components/Sidebar";
|
||||
import { Header } from "@/components/Header";
|
||||
import { SearchBar } from "@/components/SearchBar";
|
||||
import { TrackInfo } from "@/components/TrackInfo";
|
||||
import { AlbumInfo } from "@/components/AlbumInfo";
|
||||
import { PlaylistInfo } from "@/components/PlaylistInfo";
|
||||
import { ArtistInfo } from "@/components/ArtistInfo";
|
||||
import { DownloadQueue } from "@/components/DownloadQueue";
|
||||
import { DownloadProgressToast } from "@/components/DownloadProgressToast";
|
||||
import { AudioAnalysisPage } from "@/components/AudioAnalysisPage";
|
||||
import { AudioConverterPage } from "@/components/AudioConverterPage";
|
||||
import { FileManagerPage } from "@/components/FileManagerPage";
|
||||
import { SettingsPage } from "@/components/SettingsPage";
|
||||
import { DebugLoggerPage } from "@/components/DebugLoggerPage";
|
||||
import type { HistoryItem } from "@/components/FetchHistory";
|
||||
|
||||
// Hooks
|
||||
import { useDownload } from "@/hooks/useDownload";
|
||||
import { useMetadata } from "@/hooks/useMetadata";
|
||||
import { useLyrics } from "@/hooks/useLyrics";
|
||||
import { useCover } from "@/hooks/useCover";
|
||||
import { useAvailability } from "@/hooks/useAvailability";
|
||||
import { useDownloadQueueDialog } from "@/hooks/useDownloadQueueDialog";
|
||||
|
||||
const HISTORY_KEY = "spotiflac_fetch_history";
|
||||
const MAX_HISTORY = 5;
|
||||
|
||||
function App() {
|
||||
const [currentPage, setCurrentPage] = useState<PageType>("main");
|
||||
const [spotifyUrl, setSpotifyUrl] = useState("");
|
||||
const [selectedTracks, setSelectedTracks] = useState<string[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [sortBy, setSortBy] = useState<string>("default");
|
||||
const [currentListPage, setCurrentListPage] = useState(1);
|
||||
const [hasUpdate, setHasUpdate] = useState(false);
|
||||
const [releaseDate, setReleaseDate] = useState<string | null>(null);
|
||||
const [fetchHistory, setFetchHistory] = useState<HistoryItem[]>([]);
|
||||
const [isSearchMode, setIsSearchMode] = useState(false);
|
||||
const [showScrollTop, setShowScrollTop] = useState(false);
|
||||
|
||||
const ITEMS_PER_PAGE = 50;
|
||||
const CURRENT_VERSION = "7.0";
|
||||
|
||||
const download = useDownload();
|
||||
const metadata = useMetadata();
|
||||
const lyrics = useLyrics();
|
||||
const cover = useCover();
|
||||
const availability = useAvailability();
|
||||
const downloadQueue = useDownloadQueueDialog();
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const initSettings = async () => {
|
||||
const settings = getSettings();
|
||||
applyThemeMode(settings.themeMode);
|
||||
applyTheme(settings.theme);
|
||||
applyFont(settings.fontFamily);
|
||||
|
||||
// Initialize default download path if not set
|
||||
if (!settings.downloadPath) {
|
||||
const settingsWithDefaults = await getSettingsWithDefaults();
|
||||
saveSettings(settingsWithDefaults);
|
||||
}
|
||||
};
|
||||
initSettings();
|
||||
|
||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const handleChange = () => {
|
||||
const currentSettings = getSettings();
|
||||
if (currentSettings.themeMode === "auto") {
|
||||
applyThemeMode("auto");
|
||||
applyTheme(currentSettings.theme);
|
||||
}
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener("change", handleChange);
|
||||
checkForUpdates();
|
||||
loadHistory();
|
||||
|
||||
// Scroll listener for jump to top button
|
||||
const handleScroll = () => {
|
||||
setShowScrollTop(window.scrollY > 300);
|
||||
};
|
||||
window.addEventListener("scroll", handleScroll);
|
||||
|
||||
return () => {
|
||||
mediaQuery.removeEventListener("change", handleChange);
|
||||
window.removeEventListener("scroll", handleScroll);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const scrollToTop = useCallback(() => {
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedTracks([]);
|
||||
setSearchQuery("");
|
||||
download.resetDownloadedTracks();
|
||||
lyrics.resetLyricsState();
|
||||
cover.resetCoverState();
|
||||
availability.clearAvailability();
|
||||
setSortBy("default");
|
||||
setCurrentListPage(1);
|
||||
}, [metadata.metadata]);
|
||||
|
||||
const checkForUpdates = async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
"https://api.github.com/repos/afkarxyz/SpotiFLAC/releases/latest"
|
||||
);
|
||||
const data = await response.json();
|
||||
const latestVersion = data.tag_name?.replace(/^v/, "") || "";
|
||||
|
||||
if (data.published_at) {
|
||||
setReleaseDate(data.published_at);
|
||||
}
|
||||
|
||||
if (latestVersion && latestVersion > CURRENT_VERSION) {
|
||||
setHasUpdate(true);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to check for updates:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const loadHistory = () => {
|
||||
try {
|
||||
const saved = localStorage.getItem(HISTORY_KEY);
|
||||
if (saved) {
|
||||
setFetchHistory(JSON.parse(saved));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load history:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const saveHistory = (history: HistoryItem[]) => {
|
||||
try {
|
||||
localStorage.setItem(HISTORY_KEY, JSON.stringify(history));
|
||||
} catch (err) {
|
||||
console.error("Failed to save history:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const addToHistory = (item: Omit<HistoryItem, "id" | "timestamp">) => {
|
||||
setFetchHistory((prev) => {
|
||||
const filtered = prev.filter((h) => h.url !== item.url);
|
||||
const newItem: HistoryItem = {
|
||||
...item,
|
||||
id: crypto.randomUUID(),
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
const updated = [newItem, ...filtered].slice(0, MAX_HISTORY);
|
||||
saveHistory(updated);
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
const removeFromHistory = (id: string) => {
|
||||
setFetchHistory((prev) => {
|
||||
const updated = prev.filter((h) => h.id !== id);
|
||||
saveHistory(updated);
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
const handleHistorySelect = async (item: HistoryItem) => {
|
||||
setSpotifyUrl(item.url);
|
||||
const updatedUrl = await metadata.handleFetchMetadata(item.url);
|
||||
if (updatedUrl) {
|
||||
setSpotifyUrl(updatedUrl);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFetchMetadata = async () => {
|
||||
const updatedUrl = await metadata.handleFetchMetadata(spotifyUrl);
|
||||
if (updatedUrl) {
|
||||
setSpotifyUrl(updatedUrl);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!metadata.metadata || !spotifyUrl) return;
|
||||
|
||||
let historyItem: Omit<HistoryItem, "id" | "timestamp"> | null = null;
|
||||
|
||||
if ("track" in metadata.metadata) {
|
||||
const { track } = metadata.metadata;
|
||||
historyItem = {
|
||||
url: spotifyUrl,
|
||||
type: "track",
|
||||
name: track.name,
|
||||
artist: track.artists,
|
||||
image: track.images,
|
||||
};
|
||||
} else if ("album_info" in metadata.metadata) {
|
||||
const { album_info } = metadata.metadata;
|
||||
historyItem = {
|
||||
url: spotifyUrl,
|
||||
type: "album",
|
||||
name: album_info.name,
|
||||
artist: album_info.artists,
|
||||
image: album_info.images,
|
||||
};
|
||||
} else if ("playlist_info" in metadata.metadata) {
|
||||
const { playlist_info } = metadata.metadata;
|
||||
historyItem = {
|
||||
url: spotifyUrl,
|
||||
type: "playlist",
|
||||
name: playlist_info.owner.name,
|
||||
artist: `${playlist_info.tracks.total} tracks • ${playlist_info.owner.display_name}`,
|
||||
image: playlist_info.owner.images || "",
|
||||
};
|
||||
} else if ("artist_info" in metadata.metadata) {
|
||||
const { artist_info } = metadata.metadata;
|
||||
historyItem = {
|
||||
url: spotifyUrl,
|
||||
type: "artist",
|
||||
name: artist_info.name,
|
||||
artist: `${artist_info.total_albums} albums`,
|
||||
image: artist_info.images,
|
||||
};
|
||||
}
|
||||
|
||||
if (historyItem) {
|
||||
addToHistory(historyItem);
|
||||
}
|
||||
}, [metadata.metadata]);
|
||||
|
||||
const handleSearchChange = (value: string) => {
|
||||
setSearchQuery(value);
|
||||
setCurrentListPage(1);
|
||||
};
|
||||
|
||||
const toggleTrackSelection = (isrc: string) => {
|
||||
setSelectedTracks((prev) =>
|
||||
prev.includes(isrc) ? prev.filter((id) => id !== isrc) : [...prev, isrc]
|
||||
);
|
||||
};
|
||||
|
||||
const toggleSelectAll = (tracks: any[]) => {
|
||||
const tracksWithIsrc = tracks.filter((track) => track.isrc).map((track) => track.isrc);
|
||||
if (selectedTracks.length === tracksWithIsrc.length) {
|
||||
setSelectedTracks([]);
|
||||
} else {
|
||||
setSelectedTracks(tracksWithIsrc);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenFolder = async () => {
|
||||
const settings = getSettings();
|
||||
if (!settings.downloadPath) {
|
||||
toast.error("Download path not set");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await OpenFolder(settings.downloadPath);
|
||||
} catch (error) {
|
||||
console.error("Error opening folder:", error);
|
||||
toast.error(`Error opening folder: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const renderMetadata = () => {
|
||||
if (!metadata.metadata) return null;
|
||||
|
||||
if ("track" in metadata.metadata) {
|
||||
const { track } = metadata.metadata;
|
||||
return (
|
||||
<TrackInfo
|
||||
track={track}
|
||||
isDownloading={download.isDownloading}
|
||||
downloadingTrack={download.downloadingTrack}
|
||||
isDownloaded={download.downloadedTracks.has(track.isrc)}
|
||||
isFailed={download.failedTracks.has(track.isrc)}
|
||||
isSkipped={download.skippedTracks.has(track.isrc)}
|
||||
downloadingLyricsTrack={lyrics.downloadingLyricsTrack}
|
||||
downloadedLyrics={lyrics.downloadedLyrics.has(track.spotify_id || "")}
|
||||
failedLyrics={lyrics.failedLyrics.has(track.spotify_id || "")}
|
||||
skippedLyrics={lyrics.skippedLyrics.has(track.spotify_id || "")}
|
||||
checkingAvailability={availability.checkingTrackId === track.spotify_id}
|
||||
availability={availability.getAvailability(track.spotify_id || "")}
|
||||
downloadingCover={cover.downloadingCover}
|
||||
downloadedCover={cover.downloadedCovers.has(track.spotify_id || "")}
|
||||
failedCover={cover.failedCovers.has(track.spotify_id || "")}
|
||||
skippedCover={cover.skippedCovers.has(track.spotify_id || "")}
|
||||
onDownload={download.handleDownloadTrack}
|
||||
onDownloadLyrics={(spotifyId, name, artists, albumName, albumArtist, releaseDate, discNumber) =>
|
||||
lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, undefined, undefined, albumArtist, releaseDate, discNumber)
|
||||
}
|
||||
onCheckAvailability={availability.checkAvailability}
|
||||
onDownloadCover={(coverUrl, trackName, artistName, albumName, _playlistName, _position, trackId, albumArtist, releaseDate, discNumber) =>
|
||||
cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, undefined, undefined, trackId, albumArtist, releaseDate, discNumber)
|
||||
}
|
||||
onOpenFolder={handleOpenFolder}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if ("album_info" in metadata.metadata) {
|
||||
const { album_info, track_list } = metadata.metadata;
|
||||
return (
|
||||
<AlbumInfo
|
||||
albumInfo={album_info}
|
||||
trackList={track_list}
|
||||
searchQuery={searchQuery}
|
||||
sortBy={sortBy}
|
||||
selectedTracks={selectedTracks}
|
||||
downloadedTracks={download.downloadedTracks}
|
||||
failedTracks={download.failedTracks}
|
||||
skippedTracks={download.skippedTracks}
|
||||
downloadingTrack={download.downloadingTrack}
|
||||
isDownloading={download.isDownloading}
|
||||
bulkDownloadType={download.bulkDownloadType}
|
||||
downloadProgress={download.downloadProgress}
|
||||
currentDownloadInfo={download.currentDownloadInfo}
|
||||
currentPage={currentListPage}
|
||||
itemsPerPage={ITEMS_PER_PAGE}
|
||||
downloadedLyrics={lyrics.downloadedLyrics}
|
||||
failedLyrics={lyrics.failedLyrics}
|
||||
skippedLyrics={lyrics.skippedLyrics}
|
||||
downloadingLyricsTrack={lyrics.downloadingLyricsTrack}
|
||||
checkingAvailabilityTrack={availability.checkingTrackId}
|
||||
availabilityMap={availability.availabilityMap}
|
||||
downloadedCovers={cover.downloadedCovers}
|
||||
failedCovers={cover.failedCovers}
|
||||
skippedCovers={cover.skippedCovers}
|
||||
downloadingCoverTrack={cover.downloadingCoverTrack}
|
||||
isBulkDownloadingCovers={cover.isBulkDownloadingCovers}
|
||||
isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics}
|
||||
onSearchChange={handleSearchChange}
|
||||
onSortChange={setSortBy}
|
||||
onToggleTrack={toggleTrackSelection}
|
||||
onToggleSelectAll={toggleSelectAll}
|
||||
onDownloadTrack={download.handleDownloadTrack}
|
||||
onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) =>
|
||||
lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, album_info.name, position, albumArtist, releaseDate, discNumber)
|
||||
}
|
||||
onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) =>
|
||||
cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, album_info.name, position, trackId, albumArtist, releaseDate, discNumber)
|
||||
}
|
||||
onCheckAvailability={availability.checkAvailability}
|
||||
onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, album_info.name)}
|
||||
onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, album_info.name)}
|
||||
onDownloadAll={() => download.handleDownloadAll(track_list, undefined, true)}
|
||||
onDownloadSelected={() =>
|
||||
download.handleDownloadSelected(selectedTracks, track_list, undefined, true)
|
||||
}
|
||||
onStopDownload={download.handleStopDownload}
|
||||
onOpenFolder={handleOpenFolder}
|
||||
onPageChange={setCurrentListPage}
|
||||
onArtistClick={async (artist) => {
|
||||
const artistUrl = await metadata.handleArtistClick(artist);
|
||||
if (artistUrl) {
|
||||
setSpotifyUrl(artistUrl);
|
||||
}
|
||||
}}
|
||||
onTrackClick={async (track) => {
|
||||
if (track.external_urls) {
|
||||
setSpotifyUrl(track.external_urls);
|
||||
await metadata.handleFetchMetadata(track.external_urls);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if ("playlist_info" in metadata.metadata) {
|
||||
const { playlist_info, track_list } = metadata.metadata;
|
||||
return (
|
||||
<PlaylistInfo
|
||||
playlistInfo={playlist_info}
|
||||
trackList={track_list}
|
||||
searchQuery={searchQuery}
|
||||
sortBy={sortBy}
|
||||
selectedTracks={selectedTracks}
|
||||
downloadedTracks={download.downloadedTracks}
|
||||
failedTracks={download.failedTracks}
|
||||
skippedTracks={download.skippedTracks}
|
||||
downloadingTrack={download.downloadingTrack}
|
||||
isDownloading={download.isDownloading}
|
||||
bulkDownloadType={download.bulkDownloadType}
|
||||
downloadProgress={download.downloadProgress}
|
||||
currentDownloadInfo={download.currentDownloadInfo}
|
||||
currentPage={currentListPage}
|
||||
itemsPerPage={ITEMS_PER_PAGE}
|
||||
downloadedLyrics={lyrics.downloadedLyrics}
|
||||
failedLyrics={lyrics.failedLyrics}
|
||||
skippedLyrics={lyrics.skippedLyrics}
|
||||
downloadingLyricsTrack={lyrics.downloadingLyricsTrack}
|
||||
checkingAvailabilityTrack={availability.checkingTrackId}
|
||||
availabilityMap={availability.availabilityMap}
|
||||
downloadedCovers={cover.downloadedCovers}
|
||||
failedCovers={cover.failedCovers}
|
||||
skippedCovers={cover.skippedCovers}
|
||||
downloadingCoverTrack={cover.downloadingCoverTrack}
|
||||
isBulkDownloadingCovers={cover.isBulkDownloadingCovers}
|
||||
isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics}
|
||||
onSearchChange={handleSearchChange}
|
||||
onSortChange={setSortBy}
|
||||
onToggleTrack={toggleTrackSelection}
|
||||
onToggleSelectAll={toggleSelectAll}
|
||||
onDownloadTrack={download.handleDownloadTrack}
|
||||
onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) =>
|
||||
lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, playlist_info.owner.name, position, albumArtist, releaseDate, discNumber)
|
||||
}
|
||||
onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) =>
|
||||
cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, playlist_info.owner.name, position, trackId, albumArtist, releaseDate, discNumber)
|
||||
}
|
||||
onCheckAvailability={availability.checkAvailability}
|
||||
onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, playlist_info.owner.name)}
|
||||
onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, playlist_info.owner.name)}
|
||||
onDownloadAll={() => download.handleDownloadAll(track_list, playlist_info.owner.name)}
|
||||
onDownloadSelected={() =>
|
||||
download.handleDownloadSelected(
|
||||
selectedTracks,
|
||||
track_list,
|
||||
playlist_info.owner.name
|
||||
)
|
||||
}
|
||||
onStopDownload={download.handleStopDownload}
|
||||
onOpenFolder={handleOpenFolder}
|
||||
onPageChange={setCurrentListPage}
|
||||
onAlbumClick={metadata.handleAlbumClick}
|
||||
onArtistClick={async (artist) => {
|
||||
const artistUrl = await metadata.handleArtistClick(artist);
|
||||
if (artistUrl) {
|
||||
setSpotifyUrl(artistUrl);
|
||||
}
|
||||
}}
|
||||
onTrackClick={async (track) => {
|
||||
if (track.external_urls) {
|
||||
setSpotifyUrl(track.external_urls);
|
||||
await metadata.handleFetchMetadata(track.external_urls);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if ("artist_info" in metadata.metadata) {
|
||||
const { artist_info, album_list, track_list } = metadata.metadata;
|
||||
return (
|
||||
<ArtistInfo
|
||||
artistInfo={artist_info}
|
||||
albumList={album_list}
|
||||
trackList={track_list}
|
||||
searchQuery={searchQuery}
|
||||
sortBy={sortBy}
|
||||
selectedTracks={selectedTracks}
|
||||
downloadedTracks={download.downloadedTracks}
|
||||
failedTracks={download.failedTracks}
|
||||
skippedTracks={download.skippedTracks}
|
||||
downloadingTrack={download.downloadingTrack}
|
||||
isDownloading={download.isDownloading}
|
||||
bulkDownloadType={download.bulkDownloadType}
|
||||
downloadProgress={download.downloadProgress}
|
||||
currentDownloadInfo={download.currentDownloadInfo}
|
||||
currentPage={currentListPage}
|
||||
itemsPerPage={ITEMS_PER_PAGE}
|
||||
downloadedLyrics={lyrics.downloadedLyrics}
|
||||
failedLyrics={lyrics.failedLyrics}
|
||||
skippedLyrics={lyrics.skippedLyrics}
|
||||
downloadingLyricsTrack={lyrics.downloadingLyricsTrack}
|
||||
checkingAvailabilityTrack={availability.checkingTrackId}
|
||||
availabilityMap={availability.availabilityMap}
|
||||
downloadedCovers={cover.downloadedCovers}
|
||||
failedCovers={cover.failedCovers}
|
||||
skippedCovers={cover.skippedCovers}
|
||||
downloadingCoverTrack={cover.downloadingCoverTrack}
|
||||
isBulkDownloadingCovers={cover.isBulkDownloadingCovers}
|
||||
isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics}
|
||||
onSearchChange={handleSearchChange}
|
||||
onSortChange={setSortBy}
|
||||
onToggleTrack={toggleTrackSelection}
|
||||
onToggleSelectAll={toggleSelectAll}
|
||||
onDownloadTrack={download.handleDownloadTrack}
|
||||
onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) =>
|
||||
lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, artist_info.name, position, albumArtist, releaseDate, discNumber)
|
||||
}
|
||||
onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) =>
|
||||
cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, artist_info.name, position, trackId, albumArtist, releaseDate, discNumber)
|
||||
}
|
||||
onCheckAvailability={availability.checkAvailability}
|
||||
onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, artist_info.name)}
|
||||
onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, artist_info.name)}
|
||||
onDownloadAll={() => download.handleDownloadAll(track_list, artist_info.name)}
|
||||
onDownloadSelected={() =>
|
||||
download.handleDownloadSelected(selectedTracks, track_list, artist_info.name)
|
||||
}
|
||||
onStopDownload={download.handleStopDownload}
|
||||
onOpenFolder={handleOpenFolder}
|
||||
onAlbumClick={metadata.handleAlbumClick}
|
||||
onArtistClick={async (artist) => {
|
||||
const artistUrl = await metadata.handleArtistClick(artist);
|
||||
if (artistUrl) {
|
||||
setSpotifyUrl(artistUrl);
|
||||
}
|
||||
}}
|
||||
onPageChange={setCurrentListPage}
|
||||
onTrackClick={async (track) => {
|
||||
if (track.external_urls) {
|
||||
setSpotifyUrl(track.external_urls);
|
||||
await metadata.handleFetchMetadata(track.external_urls);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
|
||||
const renderPage = () => {
|
||||
switch (currentPage) {
|
||||
case "settings":
|
||||
return <SettingsPage />;
|
||||
case "debug":
|
||||
return <DebugLoggerPage />;
|
||||
case "audio-analysis":
|
||||
return <AudioAnalysisPage />;
|
||||
case "audio-converter":
|
||||
return <AudioConverterPage />;
|
||||
case "file-manager":
|
||||
return <FileManagerPage />;
|
||||
default:
|
||||
return (
|
||||
<>
|
||||
<Header
|
||||
version={CURRENT_VERSION}
|
||||
hasUpdate={hasUpdate}
|
||||
releaseDate={releaseDate}
|
||||
/>
|
||||
|
||||
{/* Timeout Dialog */}
|
||||
<Dialog
|
||||
open={metadata.showTimeoutDialog}
|
||||
onOpenChange={metadata.setShowTimeoutDialog}
|
||||
>
|
||||
<DialogContent className="sm:max-w-[425px] p-6 [&>button]:hidden">
|
||||
<div className="absolute right-4 top-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 opacity-70 hover:opacity-100"
|
||||
onClick={() => metadata.setShowTimeoutDialog(false)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<DialogTitle className="text-sm font-medium">Fetch Artist</DialogTitle>
|
||||
<DialogDescription>
|
||||
Set timeout for fetching metadata. Longer timeout is recommended for artists
|
||||
with large discography.
|
||||
</DialogDescription>
|
||||
{metadata.pendingArtistName && (
|
||||
<div className="py-2">
|
||||
<p className="font-medium bg-muted/50 rounded-md px-3 py-2">{metadata.pendingArtistName}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="timeout">Timeout (seconds)</Label>
|
||||
<Input
|
||||
id="timeout"
|
||||
type="number"
|
||||
min="10"
|
||||
max="600"
|
||||
value={metadata.timeoutValue}
|
||||
onChange={(e) => metadata.setTimeoutValue(Number(e.target.value))}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Default: 60 seconds. For large discographies, try 300-600 seconds (5-10
|
||||
minutes).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => metadata.setShowTimeoutDialog(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={metadata.handleConfirmFetch}>
|
||||
<Search className="h-4 w-4" />
|
||||
Fetch
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Album Fetch Dialog */}
|
||||
<Dialog open={metadata.showAlbumDialog} onOpenChange={metadata.setShowAlbumDialog}>
|
||||
<DialogContent className="sm:max-w-[425px] p-6 [&>button]:hidden">
|
||||
<div className="absolute right-4 top-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 opacity-70 hover:opacity-100"
|
||||
onClick={() => metadata.setShowAlbumDialog(false)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<DialogTitle className="text-sm font-medium">Fetch Album</DialogTitle>
|
||||
<DialogDescription>
|
||||
Do you want to fetch metadata for this album?
|
||||
</DialogDescription>
|
||||
{metadata.selectedAlbum && (
|
||||
<div className="py-2">
|
||||
<p className="font-medium bg-muted/50 rounded-md px-3 py-2">{metadata.selectedAlbum.name}</p>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => metadata.setShowAlbumDialog(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={async () => {
|
||||
const albumUrl = await metadata.handleConfirmAlbumFetch();
|
||||
if (albumUrl) {
|
||||
setSpotifyUrl(albumUrl);
|
||||
}
|
||||
}}>
|
||||
<Search className="h-4 w-4" />
|
||||
Fetch Album
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<SearchBar
|
||||
url={spotifyUrl}
|
||||
loading={metadata.loading}
|
||||
onUrlChange={setSpotifyUrl}
|
||||
onFetch={handleFetchMetadata}
|
||||
onFetchUrl={async (url) => {
|
||||
setSpotifyUrl(url);
|
||||
const updatedUrl = await metadata.handleFetchMetadata(url);
|
||||
if (updatedUrl) {
|
||||
setSpotifyUrl(updatedUrl);
|
||||
}
|
||||
}}
|
||||
history={fetchHistory}
|
||||
onHistorySelect={handleHistorySelect}
|
||||
onHistoryRemove={removeFromHistory}
|
||||
hasResult={!!metadata.metadata}
|
||||
searchMode={isSearchMode}
|
||||
onSearchModeChange={setIsSearchMode}
|
||||
/>
|
||||
|
||||
{!isSearchMode && metadata.metadata && renderMetadata()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="min-h-screen bg-background flex flex-col">
|
||||
<TitleBar />
|
||||
<Sidebar currentPage={currentPage} onPageChange={setCurrentPage} />
|
||||
|
||||
{/* Main content area with sidebar offset */}
|
||||
<div className="flex-1 ml-14 mt-10 p-4 md:p-8">
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
{renderPage()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Download Progress Toast - Bottom Left */}
|
||||
<DownloadProgressToast onClick={downloadQueue.openQueue} />
|
||||
|
||||
{/* Download Queue Dialog */}
|
||||
<DownloadQueue
|
||||
isOpen={downloadQueue.isOpen}
|
||||
onClose={downloadQueue.closeQueue}
|
||||
/>
|
||||
|
||||
{/* Jump to Top Button - Bottom Right */}
|
||||
{showScrollTop && (
|
||||
<Button
|
||||
onClick={scrollToTop}
|
||||
className="fixed bottom-6 right-6 z-50 h-10 w-10 rounded-full shadow-lg"
|
||||
size="icon"
|
||||
>
|
||||
<ArrowUp className="h-5 w-5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,273 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Download, FolderOpen, ImageDown, FileText } from "lucide-react";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { SearchAndSort } from "./SearchAndSort";
|
||||
import { TrackList } from "./TrackList";
|
||||
import { DownloadProgress } from "./DownloadProgress";
|
||||
import type { TrackMetadata, TrackAvailability } from "@/types/api";
|
||||
|
||||
interface AlbumInfoProps {
|
||||
albumInfo: {
|
||||
name: string;
|
||||
artists: string;
|
||||
images: string;
|
||||
release_date: string;
|
||||
total_tracks: number;
|
||||
artist_id?: string;
|
||||
artist_url?: string;
|
||||
};
|
||||
trackList: TrackMetadata[];
|
||||
searchQuery: string;
|
||||
sortBy: string;
|
||||
selectedTracks: string[];
|
||||
downloadedTracks: Set<string>;
|
||||
failedTracks: Set<string>;
|
||||
skippedTracks: Set<string>;
|
||||
downloadingTrack: string | null;
|
||||
isDownloading: boolean;
|
||||
bulkDownloadType: "all" | "selected" | null;
|
||||
downloadProgress: number;
|
||||
currentDownloadInfo: { name: string; artists: string } | null;
|
||||
currentPage: number;
|
||||
itemsPerPage: number;
|
||||
// Lyrics props
|
||||
downloadedLyrics?: Set<string>;
|
||||
failedLyrics?: Set<string>;
|
||||
skippedLyrics?: Set<string>;
|
||||
downloadingLyricsTrack?: string | null;
|
||||
// Availability props
|
||||
checkingAvailabilityTrack?: string | null;
|
||||
availabilityMap?: Map<string, TrackAvailability>;
|
||||
// Cover props
|
||||
downloadedCovers?: Set<string>;
|
||||
failedCovers?: Set<string>;
|
||||
skippedCovers?: Set<string>;
|
||||
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) => 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) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardContent className="px-6">
|
||||
<div className="flex gap-6 items-start">
|
||||
{albumInfo.images && (
|
||||
<img
|
||||
src={albumInfo.images}
|
||||
alt={albumInfo.name}
|
||||
className="w-48 h-48 rounded-md shadow-lg object-cover"
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">Album</p>
|
||||
<h2 className="text-4xl font-bold">{albumInfo.name}</h2>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
{onArtistClick && albumInfo.artist_id && albumInfo.artist_url ? (
|
||||
<span
|
||||
className="font-medium cursor-pointer hover:underline"
|
||||
onClick={() =>
|
||||
onArtistClick({
|
||||
id: albumInfo.artist_id!,
|
||||
name: albumInfo.artists,
|
||||
external_urls: albumInfo.artist_url!,
|
||||
})
|
||||
}
|
||||
>
|
||||
{albumInfo.artists}
|
||||
</span>
|
||||
) : (
|
||||
<span className="font-medium">{albumInfo.artists}</span>
|
||||
)}
|
||||
<span>•</span>
|
||||
<span>{albumInfo.release_date}</span>
|
||||
<span>•</span>
|
||||
<span>
|
||||
{albumInfo.total_tracks} {albumInfo.total_tracks === 1 ? "song" : "songs"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button onClick={onDownloadAll} disabled={isDownloading}>
|
||||
{isDownloading && bulkDownloadType === "all" ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<Download className="h-4 w-4" />
|
||||
)}
|
||||
Download All
|
||||
</Button>
|
||||
{selectedTracks.length > 0 && (
|
||||
<Button
|
||||
onClick={onDownloadSelected}
|
||||
variant="secondary"
|
||||
disabled={isDownloading}
|
||||
>
|
||||
{isDownloading && bulkDownloadType === "selected" ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<Download className="h-4 w-4" />
|
||||
)}
|
||||
Download Selected ({selectedTracks.length})
|
||||
</Button>
|
||||
)}
|
||||
{onDownloadAllLyrics && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
onClick={onDownloadAllLyrics}
|
||||
variant="outline"
|
||||
disabled={isBulkDownloadingLyrics}
|
||||
>
|
||||
{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
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{isDownloading && (
|
||||
<DownloadProgress
|
||||
progress={downloadProgress}
|
||||
currentTrack={currentDownloadInfo}
|
||||
onStop={onStopDownload}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="space-y-4">
|
||||
<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={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}
|
||||
onTrackClick={onTrackClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Download, FolderOpen, ImageDown, FileText } from "lucide-react";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { SearchAndSort } from "./SearchAndSort";
|
||||
import { TrackList } from "./TrackList";
|
||||
import { DownloadProgress } from "./DownloadProgress";
|
||||
import type { TrackMetadata, TrackAvailability } from "@/types/api";
|
||||
|
||||
interface ArtistInfoProps {
|
||||
artistInfo: {
|
||||
name: string;
|
||||
images: string;
|
||||
followers: number;
|
||||
genres: string[];
|
||||
};
|
||||
albumList: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
images: string;
|
||||
release_date: string;
|
||||
album_type: string;
|
||||
external_urls: string;
|
||||
}>;
|
||||
trackList: TrackMetadata[];
|
||||
searchQuery: string;
|
||||
sortBy: string;
|
||||
selectedTracks: string[];
|
||||
downloadedTracks: Set<string>;
|
||||
failedTracks: Set<string>;
|
||||
skippedTracks: Set<string>;
|
||||
downloadingTrack: string | null;
|
||||
isDownloading: boolean;
|
||||
bulkDownloadType: "all" | "selected" | null;
|
||||
downloadProgress: number;
|
||||
currentDownloadInfo: { name: string; artists: string } | null;
|
||||
currentPage: number;
|
||||
itemsPerPage: number;
|
||||
// Lyrics props
|
||||
downloadedLyrics?: Set<string>;
|
||||
failedLyrics?: Set<string>;
|
||||
skippedLyrics?: Set<string>;
|
||||
downloadingLyricsTrack?: string | null;
|
||||
// Availability props
|
||||
checkingAvailabilityTrack?: string | null;
|
||||
availabilityMap?: Map<string, TrackAvailability>;
|
||||
// Cover props
|
||||
downloadedCovers?: Set<string>;
|
||||
failedCovers?: Set<string>;
|
||||
skippedCovers?: Set<string>;
|
||||
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) => 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) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardContent className="px-6">
|
||||
<div className="flex gap-6 items-start">
|
||||
{artistInfo.images && (
|
||||
<img
|
||||
src={artistInfo.images}
|
||||
alt={artistInfo.name}
|
||||
className="w-48 h-48 rounded-full shadow-lg object-cover"
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 space-y-2">
|
||||
<p className="text-sm font-medium">Artist</p>
|
||||
<h2 className="text-4xl font-bold">{artistInfo.name}</h2>
|
||||
<div className="flex items-center gap-2 text-sm flex-wrap">
|
||||
<span>{artistInfo.followers.toLocaleString()} followers</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>
|
||||
|
||||
{albumList.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-2xl font-bold">Discography</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||
{albumList.map((album) => (
|
||||
<div
|
||||
key={album.id}
|
||||
className="group cursor-pointer"
|
||||
onClick={() =>
|
||||
onAlbumClick({
|
||||
id: album.id,
|
||||
name: album.name,
|
||||
external_urls: album.external_urls,
|
||||
})
|
||||
}
|
||||
>
|
||||
<div className="relative mb-4">
|
||||
{album.images && (
|
||||
<img
|
||||
src={album.images}
|
||||
alt={album.name}
|
||||
className="w-full aspect-square object-cover rounded-md shadow-md transition-shadow group-hover:shadow-xl"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<h4 className="font-semibold truncate">{album.name}</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{album.release_date?.split("-")[0]} • {album.album_type}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{trackList.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<h3 className="text-2xl font-bold">Popular Tracks</h3>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button onClick={onDownloadAll} size="sm" disabled={isDownloading}>
|
||||
{isDownloading && bulkDownloadType === "all" ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<Download className="h-4 w-4" />
|
||||
)}
|
||||
Download All
|
||||
</Button>
|
||||
{selectedTracks.length > 0 && (
|
||||
<Button
|
||||
onClick={onDownloadSelected}
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
disabled={isDownloading}
|
||||
>
|
||||
{isDownloading && bulkDownloadType === "selected" ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<Download className="h-4 w-4" />
|
||||
)}
|
||||
Download Selected ({selectedTracks.length})
|
||||
</Button>
|
||||
)}
|
||||
{onDownloadAllLyrics && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
onClick={onDownloadAllLyrics}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={isBulkDownloadingLyrics}
|
||||
>
|
||||
{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
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isDownloading && (
|
||||
<DownloadProgress
|
||||
progress={downloadProgress}
|
||||
currentTrack={currentDownloadInfo}
|
||||
onStop={onStopDownload}
|
||||
/>
|
||||
)}
|
||||
<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}
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Activity,
|
||||
Waves,
|
||||
Radio,
|
||||
TrendingUp,
|
||||
FileAudio,
|
||||
Clock,
|
||||
Gauge,
|
||||
HardDrive
|
||||
} from "lucide-react";
|
||||
import type { AnalysisResult } from "@/types/api";
|
||||
|
||||
interface AudioAnalysisProps {
|
||||
result: AnalysisResult | null;
|
||||
analyzing: boolean;
|
||||
onAnalyze?: () => void;
|
||||
showAnalyzeButton?: boolean;
|
||||
filePath?: string;
|
||||
}
|
||||
|
||||
export function AudioAnalysis({
|
||||
result,
|
||||
analyzing,
|
||||
onAnalyze,
|
||||
showAnalyzeButton = true,
|
||||
filePath
|
||||
}: AudioAnalysisProps) {
|
||||
if (analyzing) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="px-6">
|
||||
<div className="flex items-center justify-center py-8 gap-3">
|
||||
<Spinner />
|
||||
<span className="text-muted-foreground">Analyzing audio quality...</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!result && showAnalyzeButton) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="px-6">
|
||||
<div className="flex flex-col items-center justify-center py-8 gap-4">
|
||||
<Activity className="h-12 w-12 text-primary" />
|
||||
<div className="text-center space-y-2">
|
||||
<p className="font-medium">Audio Quality Analysis</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Verify the true lossless quality of downloaded files
|
||||
</p>
|
||||
</div>
|
||||
{onAnalyze && (
|
||||
<Button onClick={onAnalyze}>
|
||||
<Activity className="h-4 w-4" />
|
||||
Analyze Audio
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
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];
|
||||
};
|
||||
|
||||
// Calculate Nyquist frequency (half of sample rate)
|
||||
const nyquistFreq = result.sample_rate / 2;
|
||||
|
||||
return (
|
||||
<Card className="gap-2">
|
||||
<CardHeader>
|
||||
{filePath && (
|
||||
<p className="text-sm font-mono break-all">{filePath}</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-2">
|
||||
{/* Audio Properties - Single line */}
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
|
||||
<div className="flex items-center gap-1">
|
||||
<Radio className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">Sample Rate:</span>
|
||||
<span className="font-semibold">{(result.sample_rate / 1000).toFixed(1)} kHz</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<FileAudio className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">Bit Depth:</span>
|
||||
<span className="font-semibold">{result.bit_depth}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Waves className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">Channels:</span>
|
||||
<span className="font-semibold">{result.channels === 2 ? "Stereo" : result.channels === 1 ? "Mono" : `${result.channels}`}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">Duration:</span>
|
||||
<span className="font-semibold">{formatDuration(result.duration)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Gauge className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">Nyquist:</span>
|
||||
<span className="font-semibold">{(nyquistFreq / 1000).toFixed(1)} kHz</span>
|
||||
</div>
|
||||
{result.file_size > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<HardDrive className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">Size:</span>
|
||||
<span className="font-semibold">{formatFileSize(result.file_size)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Dynamic Range - Single line */}
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs border-t pt-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<TrendingUp className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">Dynamic Range:</span>
|
||||
<span className="font-semibold">{formatNumber(result.dynamic_range)} dB</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-muted-foreground">Peak:</span>
|
||||
<span className="font-semibold">{formatNumber(result.peak_amplitude)} dB</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-muted-foreground">RMS:</span>
|
||||
<span className="font-semibold">{formatNumber(result.rms_level)} dB</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 ml-auto">
|
||||
<span className="text-muted-foreground">Samples:</span>
|
||||
<span className="font-semibold">{result.total_samples.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
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">
|
||||
{/* Header */}
|
||||
<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>
|
||||
|
||||
{/* File Selection */}
|
||||
{!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>
|
||||
)}
|
||||
|
||||
{/* Loading State */}
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* Analysis Results */}
|
||||
{result && (
|
||||
<div className="space-y-4">
|
||||
{/* Detailed Analysis */}
|
||||
<AudioAnalysis result={result} analyzing={analyzing} showAnalyzeButton={false} filePath={selectedFilePath} />
|
||||
|
||||
{/* Spectrum Visualization */}
|
||||
{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,671 @@
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
ToggleGroup,
|
||||
ToggleGroupItem,
|
||||
} from "@/components/ui/toggle-group";
|
||||
import {
|
||||
Upload,
|
||||
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";
|
||||
|
||||
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 [files, setFiles] = useState<AudioFile[]>(() => {
|
||||
// Initialize from sessionStorage synchronously
|
||||
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) {
|
||||
// Ignore
|
||||
}
|
||||
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) {
|
||||
// Ignore
|
||||
}
|
||||
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) {
|
||||
// Ignore
|
||||
}
|
||||
return "aac";
|
||||
});
|
||||
const [converting, setConverting] = useState(false);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
|
||||
// Helper function to save state to sessionStorage
|
||||
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);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Load saved state from sessionStorage on mount (only for ffmpeg check)
|
||||
useEffect(() => {
|
||||
checkFfmpegInstallation();
|
||||
}, []);
|
||||
|
||||
// Save state to sessionStorage whenever files, outputFormat, bitrate, or m4aCodec changes
|
||||
useEffect(() => {
|
||||
saveState({ files, outputFormat, bitrate, m4aCodec });
|
||||
}, [files, outputFormat, bitrate, m4aCodec, saveState]);
|
||||
|
||||
// Auto-set output format to M4A if all files are MP3
|
||||
useEffect(() => {
|
||||
if (files.length === 0) return;
|
||||
|
||||
const allMP3 = files.every((f) => f.format === "mp3");
|
||||
if (allMP3 && outputFormat !== "m4a") {
|
||||
setOutputFormat("m4a");
|
||||
}
|
||||
|
||||
// Reset to AAC if no FLAC files (ALAC doesn't make sense for lossy input)
|
||||
const hasFlac = files.some((f) => f.format === "flac");
|
||||
if (!hasFlac && m4aCodec === "alac") {
|
||||
setM4aCodec("aac");
|
||||
}
|
||||
}, [files, outputFormat, m4aCodec]);
|
||||
|
||||
// Check if format selection should be disabled (all files are MP3)
|
||||
const isFormatDisabled = files.length > 0 && files.every((f) => f.format === "mp3");
|
||||
|
||||
// Check if any file is FLAC (ALAC only makes sense for lossless input)
|
||||
const hasFlacFiles = files.some((f) => f.format === "flac");
|
||||
|
||||
// Detect fullscreen/maximized window
|
||||
useEffect(() => {
|
||||
const checkFullscreen = () => {
|
||||
// Check if window is maximized or fullscreen
|
||||
// For Wails, we can check if window height is close to screen height
|
||||
const isMaximized = window.innerHeight >= window.screen.height * 0.9;
|
||||
setIsFullscreen(isMaximized);
|
||||
};
|
||||
|
||||
checkFullscreen();
|
||||
window.addEventListener("resize", checkFullscreen);
|
||||
|
||||
// Also check on window focus in case user maximizes externally
|
||||
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"];
|
||||
|
||||
// Check for M4A files specifically
|
||||
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.",
|
||||
});
|
||||
}
|
||||
|
||||
// Get file sizes from backend
|
||||
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(() => {
|
||||
// Only enable drag and drop for audio files if FFmpeg is installed
|
||||
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 {
|
||||
// Include all files (including previously successful ones) for conversion
|
||||
const inputPaths = files.map((f) => f.path);
|
||||
|
||||
// Mark all files as converting (including previously successful ones)
|
||||
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 : "",
|
||||
});
|
||||
|
||||
// Update file statuses based on results
|
||||
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" />;
|
||||
}
|
||||
};
|
||||
|
||||
// Count files that can be converted (pending + success files that can be re-converted)
|
||||
const convertableCount = files.filter((f) => f.status === "pending" || f.status === "success").length;
|
||||
const successCount = files.filter((f) => f.status === "success").length;
|
||||
|
||||
// Show FFmpeg installation prompt if not installed
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`space-y-6 ${isFullscreen ? "h-full flex flex-col" : ""}`}>
|
||||
{/* Header */}
|
||||
<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>
|
||||
|
||||
{/* Drop Zone / File List */}
|
||||
<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">
|
||||
{/* Settings Row - Only show when files exist */}
|
||||
<div className="space-y-2 pb-4 border-b shrink-0">
|
||||
{/* Format and Bitrate in one line */}
|
||||
<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>
|
||||
{/* Codec selection for M4A - only show ALAC option when input has FLAC files */}
|
||||
{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>
|
||||
)}
|
||||
{/* Bitrate selection - hide for ALAC (lossless) */}
|
||||
{!(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>
|
||||
|
||||
{/* File List Header */}
|
||||
<div className="flex items-center justify-between shrink-0">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{files.length} file(s) • {successCount} converted
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* File List */}
|
||||
<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>
|
||||
|
||||
{/* Convert Button */}
|
||||
<div className="flex justify-center pt-4 border-t shrink-0">
|
||||
<Button
|
||||
onClick={handleConvert}
|
||||
disabled={converting || convertableCount === 0}
|
||||
size="lg"
|
||||
>
|
||||
{converting ? (
|
||||
<>
|
||||
<Spinner className="h-4 w-4" />
|
||||
Converting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<WandSparkles className="h-4 w-4" />
|
||||
Convert {convertableCount > 0 ? `${convertableCount} File(s)` : ""}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { StopCircle } from "lucide-react";
|
||||
|
||||
interface DownloadProgressProps {
|
||||
progress: number;
|
||||
currentTrack: { name: string; artists: string } | null;
|
||||
onStop: () => void;
|
||||
}
|
||||
|
||||
export function DownloadProgress({ progress, currentTrack, onStop }: DownloadProgressProps) {
|
||||
const clampedProgress = Math.min(100, Math.max(0, progress));
|
||||
return (
|
||||
<div className="w-full space-y-2 mt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Progress value={clampedProgress} className="h-2 flex-1" />
|
||||
<Button variant="destructive" size="sm" onClick={onStop} className="gap-1.5">
|
||||
<StopCircle className="h-4 w-4" />
|
||||
Stop
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{clampedProgress}% -{" "}
|
||||
{currentTrack
|
||||
? `${currentTrack.name} - ${currentTrack.artists}`
|
||||
: "Preparing download..."}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useDownloadProgress } from "@/hooks/useDownloadProgress";
|
||||
import { useDownloadQueueData } from "@/hooks/useDownloadQueueData";
|
||||
import { Download, ChevronRight } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface DownloadProgressToastProps {
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export function DownloadProgressToast({ onClick }: DownloadProgressToastProps) {
|
||||
const progress = useDownloadProgress();
|
||||
const queueInfo = useDownloadQueueData();
|
||||
|
||||
// Show indicator if there are any queued or downloading items
|
||||
// Don't show for completed/failed/skipped only
|
||||
const hasActiveDownloads = queueInfo.queue.some(
|
||||
item => item.status === "queued" || item.status === "downloading"
|
||||
);
|
||||
|
||||
if (!hasActiveDownloads) {
|
||||
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">
|
||||
<Download className={`h-4 w-4 text-primary ${progress.is_downloading ? 'animate-bounce' : ''}`} />
|
||||
<div className="flex flex-col min-w-[80px]">
|
||||
<p className="text-sm font-medium font-mono tabular-nums">
|
||||
{progress.mb_downloaded.toFixed(2)} MB
|
||||
</p>
|
||||
{progress.speed_mbps > 0 && (
|
||||
<p className="text-xs text-muted-foreground font-mono tabular-nums">
|
||||
{progress.speed_mbps.toFixed(2)} MB/s
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground ml-1" />
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
// Initial fetch
|
||||
fetchQueue();
|
||||
|
||||
// Poll every 500ms when dialog is open
|
||||
const interval = setInterval(fetchQueue, 500);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isOpen]);
|
||||
|
||||
const handleClearHistory = async () => {
|
||||
try {
|
||||
await ClearCompletedDownloads();
|
||||
// Refetch immediately to update UI
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
// Format session duration
|
||||
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>
|
||||
|
||||
{/* Queue Status */}
|
||||
<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>
|
||||
|
||||
{/* Session Stats */}
|
||||
<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>
|
||||
|
||||
{/* Download Queue List */}
|
||||
<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>
|
||||
|
||||
{/* Info for downloading items */}
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* Completed info */}
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* Skipped info */}
|
||||
{item.status === "skipped" && (
|
||||
<div className="mt-1.5 text-xs text-muted-foreground">
|
||||
File already exists
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error message */}
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* File path for completed/skipped */}
|
||||
{(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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { X } from "lucide-react";
|
||||
|
||||
export interface HistoryItem {
|
||||
id: string;
|
||||
url: string;
|
||||
type: "track" | "album" | "playlist" | "artist";
|
||||
name: string;
|
||||
artist: string;
|
||||
image: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface FetchHistoryProps {
|
||||
history: HistoryItem[];
|
||||
onSelect: (item: HistoryItem) => void;
|
||||
onRemove: (id: string) => void;
|
||||
}
|
||||
|
||||
export function FetchHistory({ history, onSelect, onRemove }: FetchHistoryProps) {
|
||||
if (history.length === 0) return null;
|
||||
|
||||
const getTypeLabel = (type: string) => {
|
||||
switch (type) {
|
||||
case "track":
|
||||
return "Track";
|
||||
case "album":
|
||||
return "Album";
|
||||
case "playlist":
|
||||
return "Playlist";
|
||||
case "artist":
|
||||
return "Artist";
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<span className="text-sm text-muted-foreground">Recent Fetches</span>
|
||||
<div className="flex gap-2 overflow-x-auto pb-2 pt-2">
|
||||
{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)}
|
||||
>
|
||||
<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();
|
||||
onRemove(item.id);
|
||||
}}
|
||||
>
|
||||
<X className="h-3 w-3 text-red-900" strokeWidth={3} />
|
||||
</button>
|
||||
<div className="p-2">
|
||||
<div className="aspect-square w-full rounded-md overflow-hidden mb-2 bg-muted">
|
||||
{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">
|
||||
No Image
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-xs font-medium truncate" title={item.name}>
|
||||
{item.name}
|
||||
</p>
|
||||
<p
|
||||
className="text-xs text-muted-foreground truncate"
|
||||
title={item.artist}
|
||||
>
|
||||
{item.artist}
|
||||
</p>
|
||||
<span className="inline-block text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
|
||||
{getTypeLabel(item.type)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,832 @@
|
||||
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 { 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 { /* ignore */ }
|
||||
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 { /* ignore */ }
|
||||
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 { /* ignore */ }
|
||||
}, [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 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>
|
||||
|
||||
{/* Path Selection */}
|
||||
<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>
|
||||
|
||||
{/* Tabs */}
|
||||
<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>
|
||||
|
||||
{/* Rename Format - Only for Track tab */}
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* File Tree */}
|
||||
<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>
|
||||
|
||||
{/* Reset Confirmation Dialog */}
|
||||
<Dialog open={showResetConfirm} onOpenChange={setShowResetConfirm}>
|
||||
<DialogContent className="max-w-md [&>button]:hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Reset to Default?</DialogTitle>
|
||||
<DialogDescription>This will reset the rename format to "Title - Artist". Your custom format will be lost.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowResetConfirm(false)}>Cancel</Button>
|
||||
<Button onClick={resetToDefault}>Reset</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Preview 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>
|
||||
|
||||
{/* Metadata 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>
|
||||
|
||||
{/* FFprobe Install 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>
|
||||
|
||||
{/* Lyrics Preview 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">
|
||||
<pre className="text-sm whitespace-pre-wrap font-mono bg-muted/30 p-4 rounded-lg">
|
||||
{lyricsTab === "synced" ? lyricsContent : 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>
|
||||
|
||||
{/* Cover Preview 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>
|
||||
|
||||
{/* Manual Rename 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { openExternal } from "@/lib/utils";
|
||||
import { formatRelativeTime } from "@/lib/relative-time";
|
||||
|
||||
interface HeaderProps {
|
||||
version: string;
|
||||
hasUpdate: boolean;
|
||||
releaseDate?: string | null;
|
||||
}
|
||||
|
||||
export function Header({ version, hasUpdate, releaseDate }: HeaderProps) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="text-center space-y-2">
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<img
|
||||
src="/icon.svg"
|
||||
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
|
||||
</h1>
|
||||
<div className="relative">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge variant="default" asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openExternal("https://github.com/afkarxyz/SpotiFLAC/releases")}
|
||||
className="cursor-pointer hover:opacity-80 transition-opacity"
|
||||
>
|
||||
v{version}
|
||||
</button>
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
{hasUpdate && releaseDate && (
|
||||
<TooltipContent>
|
||||
<p>{formatRelativeTime(releaseDate)}</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</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="relative inline-flex rounded-full h-3 w-3 bg-green-500"></span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account required.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// Platform Icons for streaming services
|
||||
|
||||
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>
|
||||
);
|
||||
@@ -0,0 +1,266 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Download, FolderOpen, ImageDown, FileText } from "lucide-react";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { SearchAndSort } from "./SearchAndSort";
|
||||
import { TrackList } from "./TrackList";
|
||||
import { DownloadProgress } from "./DownloadProgress";
|
||||
import type { TrackMetadata, TrackAvailability } from "@/types/api";
|
||||
|
||||
interface PlaylistInfoProps {
|
||||
playlistInfo: {
|
||||
owner: {
|
||||
name: string;
|
||||
display_name: string;
|
||||
images: string;
|
||||
};
|
||||
tracks: {
|
||||
total: number;
|
||||
};
|
||||
followers: {
|
||||
total: number;
|
||||
};
|
||||
};
|
||||
trackList: TrackMetadata[];
|
||||
searchQuery: string;
|
||||
sortBy: string;
|
||||
selectedTracks: string[];
|
||||
downloadedTracks: Set<string>;
|
||||
failedTracks: Set<string>;
|
||||
skippedTracks: Set<string>;
|
||||
downloadingTrack: string | null;
|
||||
isDownloading: boolean;
|
||||
bulkDownloadType: "all" | "selected" | null;
|
||||
downloadProgress: number;
|
||||
currentDownloadInfo: { name: string; artists: string } | null;
|
||||
currentPage: number;
|
||||
itemsPerPage: number;
|
||||
// Lyrics props
|
||||
downloadedLyrics?: Set<string>;
|
||||
failedLyrics?: Set<string>;
|
||||
skippedLyrics?: Set<string>;
|
||||
downloadingLyricsTrack?: string | null;
|
||||
// Availability props
|
||||
checkingAvailabilityTrack?: string | null;
|
||||
availabilityMap?: Map<string, TrackAvailability>;
|
||||
// Cover props
|
||||
downloadedCovers?: Set<string>;
|
||||
failedCovers?: Set<string>;
|
||||
skippedCovers?: Set<string>;
|
||||
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) => 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;
|
||||
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) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardContent className="px-6">
|
||||
<div className="flex gap-6 items-start">
|
||||
{playlistInfo.owner.images && (
|
||||
<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="space-y-2">
|
||||
<p className="text-sm font-medium">Playlist</p>
|
||||
<h2 className="text-4xl font-bold">{playlistInfo.owner.name}</h2>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="font-medium">{playlistInfo.owner.display_name}</span>
|
||||
<span>•</span>
|
||||
<span>
|
||||
{playlistInfo.tracks.total} {playlistInfo.tracks.total === 1 ? "song" : "songs"}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>{playlistInfo.followers.total.toLocaleString()} followers</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button onClick={onDownloadAll} disabled={isDownloading}>
|
||||
{isDownloading && bulkDownloadType === "all" ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<Download className="h-4 w-4" />
|
||||
)}
|
||||
Download All
|
||||
</Button>
|
||||
{selectedTracks.length > 0 && (
|
||||
<Button
|
||||
onClick={onDownloadSelected}
|
||||
variant="secondary"
|
||||
disabled={isDownloading}
|
||||
>
|
||||
{isDownloading && bulkDownloadType === "selected" ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<Download className="h-4 w-4" />
|
||||
)}
|
||||
Download Selected ({selectedTracks.length})
|
||||
</Button>
|
||||
)}
|
||||
{onDownloadAllLyrics && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
onClick={onDownloadAllLyrics}
|
||||
variant="outline"
|
||||
disabled={isBulkDownloadingLyrics}
|
||||
>
|
||||
{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
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{isDownloading && (
|
||||
<DownloadProgress
|
||||
progress={downloadProgress}
|
||||
currentTrack={currentDownloadInfo}
|
||||
onStop={onStopDownload}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="space-y-4">
|
||||
<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={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}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Search, ArrowUpDown, XCircle } from "lucide-react";
|
||||
|
||||
interface SearchAndSortProps {
|
||||
searchQuery: string;
|
||||
sortBy: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
onSortChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export function SearchAndSort({
|
||||
searchQuery,
|
||||
sortBy,
|
||||
onSearchChange,
|
||||
onSortChange,
|
||||
}: SearchAndSortProps) {
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search tracks..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
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>
|
||||
<Select value={sortBy} onValueChange={onSortChange}>
|
||||
<SelectTrigger className="w-[200px] gap-1.5">
|
||||
<ArrowUpDown className="h-4 w-4" />
|
||||
<SelectValue placeholder="Sort by" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">Default</SelectItem>
|
||||
<SelectItem value="title-asc">Title (A-Z)</SelectItem>
|
||||
<SelectItem value="title-desc">Title (Z-A)</SelectItem>
|
||||
<SelectItem value="artist-asc">Artist (A-Z)</SelectItem>
|
||||
<SelectItem value="artist-desc">Artist (Z-A)</SelectItem>
|
||||
<SelectItem value="duration-asc">Duration (Short)</SelectItem>
|
||||
<SelectItem value="duration-desc">Duration (Long)</SelectItem>
|
||||
<SelectItem value="downloaded">Downloaded</SelectItem>
|
||||
<SelectItem value="not-downloaded">Not Downloaded</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,551 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { InputWithContext } from "@/components/ui/input-with-context";
|
||||
import { CloudDownload, Info, XCircle, Link, Search, X, ChevronDown } from "lucide-react";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { FetchHistory } 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 {
|
||||
url: string;
|
||||
loading: boolean;
|
||||
onUrlChange: (url: string) => void;
|
||||
onFetch: () => void;
|
||||
onFetchUrl: (url: string) => Promise<void>;
|
||||
history: HistoryItem[];
|
||||
onHistorySelect: (item: HistoryItem) => void;
|
||||
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) {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [searchResults, setSearchResults] = useState<backend.SearchResponse | null>(null);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
const [lastSearchedQuery, setLastSearchedQuery] = useState("");
|
||||
const [activeTab, setActiveTab] = useState<ResultTab>("tracks");
|
||||
const [recentSearches, setRecentSearches] = useState<string[]>([]);
|
||||
const [hasMore, setHasMore] = useState<Record<ResultTab, boolean>>({
|
||||
tracks: false,
|
||||
albums: false,
|
||||
artists: false,
|
||||
playlists: false,
|
||||
});
|
||||
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Load recent searches from localStorage
|
||||
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;
|
||||
});
|
||||
};
|
||||
|
||||
// Debounced search - only search if query changed
|
||||
useEffect(() => {
|
||||
if (!searchMode || !searchQuery.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't search again if query is the same
|
||||
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());
|
||||
|
||||
// Check if there might be more results
|
||||
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,
|
||||
});
|
||||
|
||||
// Auto-select first tab with results
|
||||
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;
|
||||
// Create new SearchResponse with updated array for the active tab
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
// Update hasMore for this tab
|
||||
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="flex items-center gap-2">
|
||||
{/* Mode Toggle */}
|
||||
<div className="flex items-center bg-muted rounded-md p-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSearchModeChange(false)}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-2.5 py-1 rounded text-sm font-medium transition-colors cursor-pointer",
|
||||
!searchMode
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<Link className="h-3.5 w-3.5" />
|
||||
URL
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSearchModeChange(true)}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-2.5 py-1 rounded text-sm font-medium transition-colors cursor-pointer",
|
||||
searchMode
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<Search className="h-3.5 w-3.5" />
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="h-4 w-4 text-muted-foreground cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
{!searchMode ? (
|
||||
<>
|
||||
<p>Supports track, album, playlist, and artist URLs</p>
|
||||
<p className="mt-1">Note: Playlist must be public (not private)</p>
|
||||
</>
|
||||
) : (
|
||||
<p>Search for tracks, albums, artists, or playlists</p>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
{!searchMode ? (
|
||||
<>
|
||||
<InputWithContext
|
||||
id="spotify-url"
|
||||
placeholder="https://open.spotify.com/..."
|
||||
value={url}
|
||||
onChange={(e) => onUrlChange(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && onFetch()}
|
||||
className="pr-8"
|
||||
/>
|
||||
{url && (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
|
||||
onClick={() => onUrlChange("")}
|
||||
>
|
||||
<XCircle className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<InputWithContext
|
||||
id="spotify-search"
|
||||
placeholder="Search tracks, albums, artists..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pr-8"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
|
||||
onClick={() => {
|
||||
setSearchQuery("");
|
||||
setSearchResults(null);
|
||||
setLastSearchedQuery("");
|
||||
}}
|
||||
>
|
||||
<XCircle className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!searchMode && (
|
||||
<Button onClick={onFetch} disabled={loading}>
|
||||
{loading ? (
|
||||
<>
|
||||
<Spinner />
|
||||
Fetching...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CloudDownload className="h-4 w-4" />
|
||||
Fetch
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!searchMode && !hasResult && (
|
||||
<FetchHistory
|
||||
history={history}
|
||||
onSelect={onHistorySelect}
|
||||
onRemove={onHistoryRemove}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Search Results with Tabs */}
|
||||
{searchMode && (
|
||||
<div className="space-y-4">
|
||||
{/* Recent Searches - show when no query or no results yet */}
|
||||
{!searchQuery && !searchResults && recentSearches.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">Recent Searches</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{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 && (
|
||||
<>
|
||||
{/* Tabs */}
|
||||
<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>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="grid gap-2">
|
||||
{/* Tracks */}
|
||||
{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>
|
||||
))}
|
||||
|
||||
{/* Albums */}
|
||||
{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.total_tracks} tracks
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* Artists */}
|
||||
{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>
|
||||
))}
|
||||
|
||||
{/* Playlists */}
|
||||
{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} • {playlist.total_tracks} tracks
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Load More Button */}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
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 {
|
||||
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";
|
||||
|
||||
// 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 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 SettingsPage() {
|
||||
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);
|
||||
|
||||
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);
|
||||
// Save to localStorage so it persists on reload
|
||||
saveSettings(settingsWithDefaults);
|
||||
}
|
||||
};
|
||||
loadDefaults();
|
||||
}, []);
|
||||
|
||||
const handleSave = () => {
|
||||
saveSettings(tempSettings);
|
||||
setSavedSettings(tempSettings);
|
||||
toast.success("Settings saved");
|
||||
};
|
||||
|
||||
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">
|
||||
{/* 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) => 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>
|
||||
|
||||
{/* Theme Mode */}
|
||||
<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>
|
||||
|
||||
{/* Accent */}
|
||||
<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>
|
||||
|
||||
{/* Font */}
|
||||
<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>
|
||||
|
||||
{/* Sound Effects */}
|
||||
<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>
|
||||
|
||||
{/* Right Column */}
|
||||
<div className="space-y-4">
|
||||
{/* Source Selection */}
|
||||
<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>
|
||||
{/* Quality dropdown for Tidal */}
|
||||
{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>
|
||||
)}
|
||||
{/* Quality dropdown for Qobuz */}
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Embed Lyrics & Embed Max Quality Cover */}
|
||||
<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" />
|
||||
|
||||
{/* Folder Structure */}
|
||||
<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" />
|
||||
|
||||
{/* Filename Format */}
|
||||
<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>
|
||||
|
||||
{/* Actions */}
|
||||
<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>
|
||||
|
||||
{/* Reset Confirmation Dialog */}
|
||||
<Dialog open={showResetConfirm} onOpenChange={setShowResetConfirm}>
|
||||
<DialogContent className="max-w-md [&>button]:hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Reset to Default?</DialogTitle>
|
||||
<DialogDescription>
|
||||
This will reset all settings to their default values. Your custom configurations will be lost.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowResetConfirm(false)}>Cancel</Button>
|
||||
<Button onClick={handleReset}>Reset</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { FileMusic, FilePen } from "lucide-react";
|
||||
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 { 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">
|
||||
{/* Home */}
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={currentPage === "main" ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
className="h-10 w-10"
|
||||
onClick={() => onPageChange("main")}
|
||||
>
|
||||
<HomeIcon size={20} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Home</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Settings */}
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={currentPage === "settings" ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
className="h-10 w-10"
|
||||
onClick={() => onPageChange("settings")}
|
||||
>
|
||||
<SettingsIcon size={20} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Settings</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Audio Analysis */}
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={currentPage === "audio-analysis" ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
className="h-10 w-10"
|
||||
onClick={() => onPageChange("audio-analysis")}
|
||||
>
|
||||
<ActivityIcon size={20} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Audio Quality Analyzer</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Audio Converter - using lucide icon (no animated version) */}
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={currentPage === "audio-converter" ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
className="h-10 w-10"
|
||||
onClick={() => onPageChange("audio-converter")}
|
||||
>
|
||||
<FileMusic className="h-5 w-5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Audio Converter</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* File Manager - using lucide icon (no animated version) */}
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={currentPage === "file-manager" ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
className="h-10 w-10"
|
||||
onClick={() => onPageChange("file-manager")}
|
||||
>
|
||||
<FilePen className="h-5 w-5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>File Manager</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Debug */}
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={currentPage === "debug" ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
className="h-10 w-10"
|
||||
onClick={() => onPageChange("debug")}
|
||||
>
|
||||
<TerminalIcon size={20} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Debug Logs</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Bottom icons */}
|
||||
<div className="mt-auto flex flex-col gap-2">
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-10 w-10"
|
||||
onClick={() => openExternal("https://github.com/afkarxyz/SpotiFLAC/issues")}
|
||||
>
|
||||
<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"
|
||||
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"
|
||||
onClick={() => openExternal("https://ko-fi.com/afkarxyz")}
|
||||
>
|
||||
<CoffeeIcon size={20} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Support me on Ko-fi</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { SpectrumData } from "@/types/api";
|
||||
|
||||
interface SpectrumVisualizationProps {
|
||||
sampleRate: number;
|
||||
bitsPerSample: number;
|
||||
duration: number;
|
||||
spectrumData?: SpectrumData;
|
||||
}
|
||||
|
||||
export function SpectrumVisualization({
|
||||
sampleRate,
|
||||
bitsPerSample,
|
||||
duration,
|
||||
spectrumData,
|
||||
}: SpectrumVisualizationProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const width = canvas.width;
|
||||
const height = canvas.height;
|
||||
|
||||
// Calculate margins for labels
|
||||
const marginLeft = 70; // More space for Frequency label
|
||||
const marginRight = 70; // Space for color bar
|
||||
const marginTop = 30; // More space at top
|
||||
const marginBottom = 65; // More space at bottom for Time label
|
||||
|
||||
const plotWidth = width - marginLeft - marginRight;
|
||||
const plotHeight = height - marginTop - marginBottom;
|
||||
|
||||
// Black background
|
||||
ctx.fillStyle = "#000000";
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
// Calculate Nyquist frequency
|
||||
const nyquistFreq = sampleRate / 2;
|
||||
|
||||
if (spectrumData) {
|
||||
drawRealSpectrum(
|
||||
ctx,
|
||||
marginLeft,
|
||||
marginTop,
|
||||
plotWidth,
|
||||
plotHeight,
|
||||
spectrumData
|
||||
);
|
||||
}
|
||||
|
||||
// Draw axes, labels, and color bar
|
||||
drawAxesAndLabels(ctx, marginLeft, marginTop, plotWidth, plotHeight, nyquistFreq, duration, sampleRate);
|
||||
drawColorBar(ctx, marginLeft + plotWidth + 15, marginTop, 20, plotHeight);
|
||||
}, [sampleRate, bitsPerSample, duration, spectrumData]);
|
||||
|
||||
const drawRealSpectrum = (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
spectrum: SpectrumData
|
||||
) => {
|
||||
const timeSlices = spectrum.time_slices;
|
||||
if (timeSlices.length === 0) return;
|
||||
|
||||
const freqBins = timeSlices[0].magnitudes.length;
|
||||
const nyquistFreq = spectrum.max_freq;
|
||||
|
||||
// Find min/max dB values
|
||||
let minDB = 0;
|
||||
let maxDB = -200;
|
||||
|
||||
timeSlices.forEach((slice) => {
|
||||
slice.magnitudes.forEach((db) => {
|
||||
if (db > maxDB) maxDB = db;
|
||||
if (db < minDB && db > -200) minDB = db;
|
||||
});
|
||||
});
|
||||
|
||||
// Clamp range for better visualization
|
||||
minDB = Math.max(minDB, maxDB - 90); // 90dB dynamic range
|
||||
const dbRange = maxDB - minDB;
|
||||
|
||||
const sliceWidth = Math.ceil(width / timeSlices.length);
|
||||
|
||||
for (let t = 0; t < timeSlices.length; t++) {
|
||||
const slice = timeSlices[t];
|
||||
const xPos = x + (t / timeSlices.length) * width;
|
||||
|
||||
for (let f = 0; f < freqBins && f < slice.magnitudes.length; f++) {
|
||||
const db = slice.magnitudes[f];
|
||||
|
||||
// Linear frequency scale
|
||||
const freq = (f / freqBins) * nyquistFreq;
|
||||
const freqRatio = freq / nyquistFreq;
|
||||
|
||||
const yPos = y + height - (freqRatio * height);
|
||||
|
||||
// Calculate bin height
|
||||
const nextFreq = ((f + 1) / freqBins) * nyquistFreq;
|
||||
const nextFreqRatio = nextFreq / nyquistFreq;
|
||||
const nextYPos = y + height - (nextFreqRatio * height);
|
||||
|
||||
const binHeight = Math.max(1, Math.abs(yPos - nextYPos) + 1);
|
||||
|
||||
// Normalize intensity
|
||||
const intensity = Math.max(0, Math.min(1, (db - minDB) / dbRange));
|
||||
|
||||
const color = getSpekColor(intensity);
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(xPos, nextYPos, sliceWidth, binHeight);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Vibrant color scheme like Spek - NGEJERENG!
|
||||
const getSpekColor = (intensity: number): string => {
|
||||
if (intensity < 0.08) {
|
||||
// Black to deep blue
|
||||
const t = intensity / 0.08;
|
||||
return `rgb(0, 0, ${Math.floor(t * 80)})`;
|
||||
} else if (intensity < 0.18) {
|
||||
// Deep blue to bright blue
|
||||
const t = (intensity - 0.08) / 0.10;
|
||||
return `rgb(${Math.floor(t * 50)}, ${Math.floor(t * 30)}, ${Math.floor(80 + t * 175)})`;
|
||||
} else if (intensity < 0.28) {
|
||||
// Blue to magenta/purple
|
||||
const t = (intensity - 0.18) / 0.10;
|
||||
return `rgb(${Math.floor(50 + t * 150)}, ${Math.floor(30 - t * 30)}, ${Math.floor(255 - t * 55)})`;
|
||||
} else if (intensity < 0.40) {
|
||||
// Magenta to bright red
|
||||
const t = (intensity - 0.28) / 0.12;
|
||||
return `rgb(${Math.floor(200 + t * 55)}, 0, ${Math.floor(200 - t * 200)})`;
|
||||
} else if (intensity < 0.52) {
|
||||
// Red to orange-red
|
||||
const t = (intensity - 0.40) / 0.12;
|
||||
return `rgb(255, ${Math.floor(t * 100)}, 0)`;
|
||||
} else if (intensity < 0.65) {
|
||||
// Orange-red to bright orange
|
||||
const t = (intensity - 0.52) / 0.13;
|
||||
return `rgb(255, ${Math.floor(100 + t * 80)}, 0)`;
|
||||
} else if (intensity < 0.78) {
|
||||
// Orange to yellow-orange
|
||||
const t = (intensity - 0.65) / 0.13;
|
||||
return `rgb(255, ${Math.floor(180 + t * 55)}, ${Math.floor(t * 30)})`;
|
||||
} else if (intensity < 0.90) {
|
||||
// Yellow-orange to bright yellow
|
||||
const t = (intensity - 0.78) / 0.12;
|
||||
return `rgb(255, ${Math.floor(235 + t * 20)}, ${Math.floor(30 + t * 100)})`;
|
||||
} else {
|
||||
// Yellow to white (hottest)
|
||||
const t = (intensity - 0.90) / 0.10;
|
||||
return `rgb(255, 255, ${Math.floor(130 + t * 125)})`;
|
||||
}
|
||||
};
|
||||
|
||||
const drawAxesAndLabels = (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
nyquistFreq: number,
|
||||
duration: number,
|
||||
sampleRate: number
|
||||
) => {
|
||||
// Frequency labels on Y-axis
|
||||
ctx.fillStyle = "#CCCCCC";
|
||||
ctx.font = "12px Arial";
|
||||
ctx.textAlign = "right";
|
||||
ctx.textBaseline = "middle";
|
||||
|
||||
// Generate frequency labels based on Nyquist
|
||||
const freqLabels = generateFreqLabels(nyquistFreq);
|
||||
|
||||
freqLabels.forEach(freq => {
|
||||
if (freq <= nyquistFreq) {
|
||||
const freqRatio = freq / nyquistFreq;
|
||||
const yPos = y + height - (freqRatio * height);
|
||||
const label = freq >= 1000 ? `${freq / 1000}k` : `${freq}`;
|
||||
ctx.fillText(label, x - 8, yPos);
|
||||
}
|
||||
});
|
||||
|
||||
// "0" at bottom
|
||||
ctx.fillText("0", x - 8, y + height);
|
||||
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { X, Minus, Maximize } from "lucide-react";
|
||||
import { WindowMinimise, WindowToggleMaximise, Quit } from "../../wailsjs/runtime/runtime";
|
||||
|
||||
export function TitleBar() {
|
||||
const handleMinimize = () => {
|
||||
WindowMinimise();
|
||||
};
|
||||
|
||||
const handleMaximize = () => {
|
||||
WindowToggleMaximise();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
Quit();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Draggable area */}
|
||||
<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}
|
||||
/>
|
||||
|
||||
{/* Window control buttons - Windows style, right side */}
|
||||
<div className="fixed top-1.5 right-2 z-50 flex h-7 gap-0.5">
|
||||
<button
|
||||
onClick={handleMinimize}
|
||||
className="w-8 h-7 flex items-center justify-center hover:bg-muted transition-colors rounded"
|
||||
style={{ "--wails-draggable": "no-drag" } as React.CSSProperties}
|
||||
aria-label="Minimize"
|
||||
>
|
||||
<Minus className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<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"
|
||||
>
|
||||
<Maximize className="w-3.5 h-3.5" />
|
||||
</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"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Download, FolderOpen, CheckCircle, XCircle, FileText, FileCheck, Globe, ImageDown } from "lucide-react";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import type { TrackMetadata, TrackAvailability } from "@/types/api";
|
||||
import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons";
|
||||
|
||||
interface TrackInfoProps {
|
||||
track: TrackMetadata & { album_name: string; release_date: string };
|
||||
isDownloading: boolean;
|
||||
downloadingTrack: string | null;
|
||||
isDownloaded: boolean;
|
||||
isFailed: boolean;
|
||||
isSkipped: boolean;
|
||||
downloadingLyricsTrack?: string | null;
|
||||
downloadedLyrics?: boolean;
|
||||
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) => 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) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="px-6">
|
||||
<div className="flex gap-6 items-start">
|
||||
<div className="shrink-0">
|
||||
{track.images && (
|
||||
<img
|
||||
src={track.images}
|
||||
alt={track.name}
|
||||
className="w-48 h-48 rounded-md shadow-lg object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 space-y-4 min-w-0">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-3xl font-bold wrap-break-word">{track.name}</h1>
|
||||
{isSkipped ? (
|
||||
<FileCheck className="h-6 w-6 text-yellow-500 shrink-0" />
|
||||
) : isDownloaded ? (
|
||||
<CheckCircle className="h-6 w-6 text-green-500 shrink-0" />
|
||||
) : isFailed ? (
|
||||
<XCircle className="h-6 w-6 text-red-500 shrink-0" />
|
||||
) : null}
|
||||
</div>
|
||||
<p className="text-lg text-muted-foreground">{track.artists}</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Album</p>
|
||||
<p className="font-medium truncate">{track.album_name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Release Date</p>
|
||||
<p className="font-medium">{track.release_date}</p>
|
||||
</div>
|
||||
</div>
|
||||
{track.isrc && (
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button
|
||||
onClick={() => onDownload(track.isrc, track.name, track.artists, track.album_name, track.spotify_id, undefined, track.duration_ms, track.track_number, track.album_artist, track.release_date, track.images, track.track_number, track.disc_number, track.total_tracks)}
|
||||
disabled={isDownloading || downloadingTrack === track.isrc}
|
||||
>
|
||||
{downloadingTrack === track.isrc ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<>
|
||||
<Download className="h-4 w-4" />
|
||||
Download
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{track.spotify_id && onDownloadLyrics && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
onClick={() => onDownloadLyrics(track.spotify_id!, track.name, track.artists, track.album_name, track.album_artist, track.release_date, track.disc_number)}
|
||||
variant="outline"
|
||||
disabled={downloadingLyricsTrack === track.spotify_id}
|
||||
>
|
||||
{downloadingLyricsTrack === track.spotify_id ? (
|
||||
<Spinner />
|
||||
) : skippedLyrics ? (
|
||||
<FileCheck className="h-4 w-4 text-yellow-500" />
|
||||
) : downloadedLyrics ? (
|
||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||
) : failedLyrics ? (
|
||||
<XCircle className="h-4 w-4 text-red-500" />
|
||||
) : (
|
||||
<FileText className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Download Lyric</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{track.images && onDownloadCover && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
onClick={() => onDownloadCover(track.images, track.name, track.artists, track.album_name, undefined, undefined, track.spotify_id, track.album_artist, track.release_date, track.disc_number)}
|
||||
variant="outline"
|
||||
disabled={downloadingCover}
|
||||
>
|
||||
{downloadingCover ? (
|
||||
<Spinner />
|
||||
) : skippedCover ? (
|
||||
<FileCheck className="h-4 w-4 text-yellow-500" />
|
||||
) : downloadedCover ? (
|
||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||
) : failedCover ? (
|
||||
<XCircle className="h-4 w-4 text-red-500" />
|
||||
) : (
|
||||
<ImageDown className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Download Cover</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{track.spotify_id && onCheckAvailability && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
onClick={() => onCheckAvailability(track.spotify_id!, track.isrc)}
|
||||
variant="outline"
|
||||
disabled={checkingAvailability}
|
||||
>
|
||||
{checkingAvailability ? (
|
||||
<Spinner />
|
||||
) : availability ? (
|
||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<Globe className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{availability ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<TidalIcon className={`w-4 h-4 ${availability.tidal ? "text-green-500" : "text-red-500"}`} />
|
||||
<QobuzIcon className={`w-4 h-4 ${availability.qobuz ? "text-green-500" : "text-red-500"}`} />
|
||||
<AmazonIcon className={`w-4 h-4 ${availability.amazon ? "text-green-500" : "text-red-500"}`} />
|
||||
</div>
|
||||
) : (
|
||||
<p>Check Availability</p>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isDownloaded && (
|
||||
<Button onClick={onOpenFolder} variant="outline">
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
Open Folder
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Download, CheckCircle, XCircle, FileCheck, FileText, Globe, ImageDown } from "lucide-react";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from "@/components/ui/pagination";
|
||||
import type { TrackMetadata, TrackAvailability } from "@/types/api";
|
||||
import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons";
|
||||
|
||||
interface TrackListProps {
|
||||
tracks: TrackMetadata[];
|
||||
searchQuery: string;
|
||||
sortBy: string;
|
||||
selectedTracks: string[];
|
||||
downloadedTracks: Set<string>;
|
||||
failedTracks: Set<string>;
|
||||
skippedTracks: Set<string>;
|
||||
downloadingTrack: string | null;
|
||||
isDownloading: boolean;
|
||||
currentPage: number;
|
||||
itemsPerPage: number;
|
||||
showCheckboxes?: boolean;
|
||||
hideAlbumColumn?: boolean;
|
||||
folderName?: string;
|
||||
isArtistDiscography?: boolean;
|
||||
// Lyrics props
|
||||
downloadedLyrics?: Set<string>;
|
||||
failedLyrics?: Set<string>;
|
||||
skippedLyrics?: Set<string>;
|
||||
downloadingLyricsTrack?: string | null;
|
||||
// Availability props
|
||||
checkingAvailabilityTrack?: string | null;
|
||||
availabilityMap?: Map<string, TrackAvailability>;
|
||||
// Cover props
|
||||
downloadedCovers?: Set<string>;
|
||||
failedCovers?: Set<string>;
|
||||
skippedCovers?: Set<string>;
|
||||
downloadingCoverTrack?: string | null;
|
||||
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) => 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) {
|
||||
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") {
|
||||
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")}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-md border">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
{showCheckboxes && (
|
||||
<th className="h-12 px-4 text-left align-middle w-12">
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
onCheckedChange={() => onToggleSelectAll(filteredTracks)}
|
||||
/>
|
||||
</th>
|
||||
)}
|
||||
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground w-12">
|
||||
#
|
||||
</th>
|
||||
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground">
|
||||
Title
|
||||
</th>
|
||||
{!hideAlbumColumn && (
|
||||
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground hidden md:table-cell">
|
||||
Album
|
||||
</th>
|
||||
)}
|
||||
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground hidden lg:table-cell w-24">
|
||||
Duration
|
||||
</th>
|
||||
<th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground w-32">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paginatedTracks.map((track, index) => (
|
||||
<tr key={index} className="border-b transition-colors hover:bg-muted/50">
|
||||
{showCheckboxes && (
|
||||
<td className="p-4 align-middle">
|
||||
{track.isrc && (
|
||||
<Checkbox
|
||||
checked={selectedTracks.includes(track.isrc)}
|
||||
onCheckedChange={() => onToggleTrack(track.isrc)}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
<td className="p-4 align-middle text-sm text-muted-foreground">
|
||||
{startIndex + index + 1}
|
||||
</td>
|
||||
<td className="p-4 align-middle">
|
||||
<div className="flex items-center gap-3">
|
||||
{track.images && (
|
||||
<img
|
||||
src={track.images}
|
||||
alt={track.name}
|
||||
className="w-10 h-10 rounded object-cover"
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center gap-2">
|
||||
{onTrackClick ? (
|
||||
<span
|
||||
className="font-medium cursor-pointer hover:underline"
|
||||
onClick={() => onTrackClick(track)}
|
||||
>
|
||||
{track.name}
|
||||
</span>
|
||||
) : (
|
||||
<span className="font-medium">{track.name}</span>
|
||||
)}
|
||||
{skippedTracks.has(track.isrc) ? (
|
||||
<FileCheck className="h-4 w-4 text-yellow-500 shrink-0" />
|
||||
) : downloadedTracks.has(track.isrc) ? (
|
||||
<CheckCircle className="h-4 w-4 text-green-500 shrink-0" />
|
||||
) : failedTracks.has(track.isrc) ? (
|
||||
<XCircle className="h-4 w-4 text-red-500 shrink-0" />
|
||||
) : null}
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{track.artists_data && track.artists_data.length > 0 ? (
|
||||
track.artists_data.map((artist, i, arr) => (
|
||||
<span key={artist.id}>
|
||||
{onArtistClick ? (
|
||||
<span
|
||||
className="cursor-pointer hover:underline"
|
||||
onClick={() =>
|
||||
onArtistClick({
|
||||
id: artist.id,
|
||||
name: artist.name,
|
||||
external_urls: artist.external_urls,
|
||||
})
|
||||
}
|
||||
>
|
||||
{artist.name}
|
||||
</span>
|
||||
) : (
|
||||
artist.name
|
||||
)}
|
||||
{i < arr.length - 1 && ", "}
|
||||
</span>
|
||||
))
|
||||
) : onArtistClick && track.artist_id && track.artist_url ? (
|
||||
<span
|
||||
className="cursor-pointer hover:underline"
|
||||
onClick={() =>
|
||||
onArtistClick({
|
||||
id: track.artist_id!,
|
||||
name: track.artists,
|
||||
external_urls: track.artist_url!,
|
||||
})
|
||||
}
|
||||
>
|
||||
{track.artists}
|
||||
</span>
|
||||
) : (
|
||||
track.artists
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
{!hideAlbumColumn && (
|
||||
<td className="p-4 align-middle text-sm text-muted-foreground hidden md:table-cell">
|
||||
{onAlbumClick && track.album_id && track.album_url ? (
|
||||
<span
|
||||
className="cursor-pointer hover:underline"
|
||||
onClick={() =>
|
||||
onAlbumClick({
|
||||
id: track.album_id!,
|
||||
name: track.album_name,
|
||||
external_urls: track.album_url!,
|
||||
})
|
||||
}
|
||||
>
|
||||
{track.album_name}
|
||||
</span>
|
||||
) : (
|
||||
track.album_name
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
<td className="p-4 align-middle text-sm text-muted-foreground hidden lg:table-cell">
|
||||
{formatDuration(track.duration_ms)}
|
||||
</td>
|
||||
<td className="p-4 align-middle text-center">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
{track.isrc && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
onClick={() =>
|
||||
onDownloadTrack(track.isrc, track.name, track.artists, track.album_name, track.spotify_id, folderName, track.duration_ms, startIndex + index + 1, track.album_artist, track.release_date, track.images, track.track_number, track.disc_number, track.total_tracks)
|
||||
}
|
||||
size="sm"
|
||||
disabled={isDownloading || downloadingTrack === track.isrc}
|
||||
>
|
||||
{downloadingTrack === track.isrc ? (
|
||||
<Spinner />
|
||||
) : skippedTracks.has(track.isrc) ? (
|
||||
<FileCheck className="h-4 w-4" />
|
||||
) : downloadedTracks.has(track.isrc) ? (
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
) : failedTracks.has(track.isrc) ? (
|
||||
<XCircle className="h-4 w-4" />
|
||||
) : (
|
||||
<Download className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{downloadingTrack === track.isrc ? (
|
||||
<p>Downloading...</p>
|
||||
) : skippedTracks.has(track.isrc) ? (
|
||||
<p>Already exists</p>
|
||||
) : downloadedTracks.has(track.isrc) ? (
|
||||
<p>Downloaded</p>
|
||||
) : failedTracks.has(track.isrc) ? (
|
||||
<p>Failed</p>
|
||||
) : (
|
||||
<p>Download Track</p>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{track.spotify_id && onDownloadLyrics && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
onClick={() =>
|
||||
onDownloadLyrics(track.spotify_id!, track.name, track.artists, track.album_name, folderName, isArtistDiscography, startIndex + index + 1, track.album_artist, track.release_date, track.disc_number)
|
||||
}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={downloadingLyricsTrack === track.spotify_id}
|
||||
>
|
||||
{downloadingLyricsTrack === track.spotify_id ? (
|
||||
<Spinner />
|
||||
) : skippedLyrics?.has(track.spotify_id) ? (
|
||||
<FileCheck className="h-4 w-4 text-yellow-500" />
|
||||
) : downloadedLyrics?.has(track.spotify_id) ? (
|
||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||
) : failedLyrics?.has(track.spotify_id) ? (
|
||||
<XCircle className="h-4 w-4 text-red-500" />
|
||||
) : (
|
||||
<FileText className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Download Lyric</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{track.images && onDownloadCover && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
onClick={() => {
|
||||
const trackId = track.spotify_id || `${track.name}-${track.artists}`;
|
||||
onDownloadCover(track.images, track.name, track.artists, track.album_name, folderName, isArtistDiscography, startIndex + index + 1, trackId, track.album_artist, track.release_date, track.disc_number);
|
||||
}}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={downloadingCoverTrack === (track.spotify_id || `${track.name}-${track.artists}`)}
|
||||
>
|
||||
{downloadingCoverTrack === (track.spotify_id || `${track.name}-${track.artists}`) ? (
|
||||
<Spinner />
|
||||
) : skippedCovers?.has(track.spotify_id || `${track.name}-${track.artists}`) ? (
|
||||
<FileCheck className="h-4 w-4 text-yellow-500" />
|
||||
) : downloadedCovers?.has(track.spotify_id || `${track.name}-${track.artists}`) ? (
|
||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||
) : failedCovers?.has(track.spotify_id || `${track.name}-${track.artists}`) ? (
|
||||
<XCircle className="h-4 w-4 text-red-500" />
|
||||
) : (
|
||||
<ImageDown className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Download Cover</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{track.spotify_id && onCheckAvailability && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
onClick={() => onCheckAvailability(track.spotify_id!, track.isrc)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={checkingAvailabilityTrack === track.spotify_id}
|
||||
>
|
||||
{checkingAvailabilityTrack === track.spotify_id ? (
|
||||
<Spinner />
|
||||
) : availabilityMap?.has(track.spotify_id) ? (
|
||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<Globe className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{availabilityMap?.has(track.spotify_id) ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<TidalIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.tidal ? "text-green-500" : "text-red-500"}`} />
|
||||
<QobuzIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.qobuz ? "text-green-500" : "text-red-500"}`} />
|
||||
<AmazonIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.amazon ? "text-green-500" : "text-red-500"}`} />
|
||||
</div>
|
||||
) : (
|
||||
<p>Check Availability</p>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (currentPage > 1) onPageChange(currentPage - 1);
|
||||
}}
|
||||
className={
|
||||
currentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"
|
||||
}
|
||||
/>
|
||||
</PaginationItem>
|
||||
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
|
||||
<PaginationItem key={page}>
|
||||
<PaginationLink
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onPageChange(page);
|
||||
}}
|
||||
isActive={currentPage === page}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{page}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
))}
|
||||
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (currentPage < totalPages) onPageChange(currentPage + 1);
|
||||
}}
|
||||
className={
|
||||
currentPage === totalPages
|
||||
? "pointer-events-none opacity-50"
|
||||
: "cursor-pointer"
|
||||
}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
'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 };
|
||||
@@ -0,0 +1,46 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
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",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"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",
|
||||
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: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
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 }
|
||||
@@ -0,0 +1,92 @@
|
||||
'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 };
|
||||
@@ -0,0 +1,60 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
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",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"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",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
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 }
|
||||
@@ -0,0 +1,92 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<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">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<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,
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { CheckIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Checkbox({
|
||||
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.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Checkbox }
|
||||
@@ -0,0 +1,118 @@
|
||||
'use client';
|
||||
|
||||
import type { Variants } from 'motion/react';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
|
||||
import { motion, useAnimation } from 'motion/react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface 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 };
|
||||
@@ -0,0 +1,252 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function ContextMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
|
||||
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
|
||||
}
|
||||
|
||||
function ContextMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
|
||||
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function ContextMenuRadioGroup({
|
||||
...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> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
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}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto" />
|
||||
</ContextMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuSubContent({
|
||||
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>) {
|
||||
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> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuCheckboxItem({
|
||||
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">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuRadioItem({
|
||||
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">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuSeparator({
|
||||
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">) {
|
||||
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,
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
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> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<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}
|
||||
>
|
||||
{children}
|
||||
{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"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<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">) {
|
||||
return (
|
||||
<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>) {
|
||||
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>) {
|
||||
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,
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
'use client';
|
||||
|
||||
import type { Variants } from 'motion/react';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
|
||||
import { motion, useAnimation } from 'motion/react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface GithubIconHandle {
|
||||
startAnimation: () => void;
|
||||
stopAnimation: () => void;
|
||||
}
|
||||
|
||||
interface GithubIconProps extends HTMLAttributes<HTMLDivElement> {
|
||||
size?: number;
|
||||
}
|
||||
|
||||
const BODY_VARIANTS: Variants = {
|
||||
normal: {
|
||||
opacity: 1,
|
||||
pathLength: 1,
|
||||
scale: 1,
|
||||
transition: {
|
||||
duration: 0.3,
|
||||
},
|
||||
},
|
||||
animate: {
|
||||
opacity: [0, 1],
|
||||
pathLength: [0, 1],
|
||||
scale: [0.9, 1],
|
||||
transition: {
|
||||
duration: 0.4,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const TAIL_VARIANTS: Variants = {
|
||||
normal: {
|
||||
pathLength: 1,
|
||||
rotate: 0,
|
||||
transition: {
|
||||
duration: 0.3,
|
||||
},
|
||||
},
|
||||
draw: {
|
||||
pathLength: [0, 1],
|
||||
rotate: 0,
|
||||
transition: {
|
||||
duration: 0.5,
|
||||
},
|
||||
},
|
||||
wag: {
|
||||
pathLength: 1,
|
||||
rotate: [0, -15, 15, -10, 10, -5, 5],
|
||||
transition: {
|
||||
duration: 2.5,
|
||||
ease: 'easeInOut',
|
||||
repeat: Infinity,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const GithubIcon = forwardRef<GithubIconHandle, GithubIconProps>(
|
||||
({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
|
||||
const bodyControls = useAnimation();
|
||||
const tailControls = useAnimation();
|
||||
const isControlledRef = useRef(false);
|
||||
|
||||
useImperativeHandle(ref, () => {
|
||||
isControlledRef.current = true;
|
||||
|
||||
return {
|
||||
startAnimation: async () => {
|
||||
bodyControls.start('animate');
|
||||
await tailControls.start('draw');
|
||||
tailControls.start('wag');
|
||||
},
|
||||
stopAnimation: () => {
|
||||
bodyControls.start('normal');
|
||||
tailControls.start('normal');
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const handleMouseEnter = useCallback(
|
||||
async (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!isControlledRef.current) {
|
||||
bodyControls.start('animate');
|
||||
await tailControls.start('draw');
|
||||
tailControls.start('wag');
|
||||
} else {
|
||||
onMouseEnter?.(e);
|
||||
}
|
||||
},
|
||||
[bodyControls, onMouseEnter, tailControls]
|
||||
);
|
||||
|
||||
const handleMouseLeave = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!isControlledRef.current) {
|
||||
bodyControls.start('normal');
|
||||
tailControls.start('normal');
|
||||
} else {
|
||||
onMouseLeave?.(e);
|
||||
}
|
||||
},
|
||||
[bodyControls, tailControls, onMouseLeave]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(className)}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
{...props}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<motion.path
|
||||
variants={BODY_VARIANTS}
|
||||
initial="normal"
|
||||
animate={bodyControls}
|
||||
d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"
|
||||
/>
|
||||
<motion.path
|
||||
variants={TAIL_VARIANTS}
|
||||
initial="normal"
|
||||
animate={tailControls}
|
||||
d="M9 18c-4.51 2-5-2-7-2"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
GithubIcon.displayName = 'GithubIcon';
|
||||
|
||||
export { GithubIcon };
|
||||
@@ -0,0 +1,103 @@
|
||||
'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 };
|
||||
@@ -0,0 +1,216 @@
|
||||
import * as React from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu";
|
||||
import { Scissors, Copy, Clipboard, Type } from "lucide-react";
|
||||
|
||||
export interface InputWithContextProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
onValueChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
const InputWithContext = React.forwardRef<HTMLInputElement, InputWithContextProps>(
|
||||
({ className, type, onValueChange, onChange, ...props }, ref) => {
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
const [hasSelection, setHasSelection] = React.useState(false);
|
||||
const [canPaste, setCanPaste] = React.useState(false);
|
||||
|
||||
// Combine refs
|
||||
React.useImperativeHandle(ref, () => inputRef.current as HTMLInputElement);
|
||||
|
||||
// Check selection state
|
||||
const updateSelectionState = () => {
|
||||
const input = inputRef.current;
|
||||
if (!input) return;
|
||||
const start = input.selectionStart ?? 0;
|
||||
const end = input.selectionEnd ?? 0;
|
||||
setHasSelection(start !== end);
|
||||
};
|
||||
|
||||
// Check clipboard permission when user explicitly opens the context menu.
|
||||
const checkClipboard = async () => {
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
setCanPaste(text.length > 0);
|
||||
} catch {
|
||||
setCanPaste(false);
|
||||
}
|
||||
};
|
||||
|
||||
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 end = input.selectionEnd ?? 0;
|
||||
|
||||
const newValue =
|
||||
input.value.substring(0, start) + text + input.value.substring(end);
|
||||
|
||||
// Update value and trigger change
|
||||
input.value = newValue;
|
||||
const newPosition = start + text.length;
|
||||
input.setSelectionRange(newPosition, newPosition);
|
||||
|
||||
// Trigger React onChange
|
||||
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 input = inputRef.current;
|
||||
if (!input) return;
|
||||
input.select();
|
||||
input.focus();
|
||||
updateSelectionState();
|
||||
};
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (onChange) {
|
||||
onChange(e);
|
||||
}
|
||||
if (onValueChange) {
|
||||
onValueChange(e.target.value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ContextMenu
|
||||
onOpenChange={(open) => {
|
||||
if (open) {
|
||||
checkClipboard();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ContextMenuTrigger asChild>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
type={type}
|
||||
className={className}
|
||||
onChange={handleInputChange}
|
||||
onSelect={updateSelectionState}
|
||||
onMouseUp={updateSelectionState}
|
||||
onKeyUp={updateSelectionState}
|
||||
{...props}
|
||||
/>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent className="w-48">
|
||||
<ContextMenuItem
|
||||
onSelect={handleCut}
|
||||
disabled={!hasSelection || props.disabled || props.readOnly}
|
||||
>
|
||||
<Scissors className="mr-2 h-4 w-4" />
|
||||
Cut
|
||||
<span className="ml-auto text-xs text-muted-foreground">Ctrl+X</span>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onSelect={handleCopy}
|
||||
disabled={!hasSelection || props.disabled}
|
||||
>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Copy
|
||||
<span className="ml-auto text-xs text-muted-foreground">Ctrl+C</span>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onSelect={handlePaste}
|
||||
disabled={!canPaste || props.disabled || props.readOnly}
|
||||
>
|
||||
<Clipboard className="mr-2 h-4 w-4" />
|
||||
Paste
|
||||
<span className="ml-auto text-xs text-muted-foreground">Ctrl+V</span>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
onSelect={handleSelectAll}
|
||||
disabled={!inputRef.current?.value || props.disabled}
|
||||
>
|
||||
<Type className="mr-2 h-4 w-4" />
|
||||
Select All
|
||||
<span className="ml-auto text-xs text-muted-foreground">Ctrl+A</span>
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
InputWithContext.displayName = "InputWithContext";
|
||||
|
||||
export { InputWithContext };
|
||||
@@ -0,0 +1,21 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
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 }
|
||||
@@ -0,0 +1,127 @@
|
||||
import * as React from "react"
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
MoreHorizontalIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
|
||||
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
|
||||
return (
|
||||
<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">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="pagination-content"
|
||||
className={cn("flex flex-row items-center gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
|
||||
return <li data-slot="pagination-item" {...props} />
|
||||
}
|
||||
|
||||
type PaginationLinkProps = {
|
||||
isActive?: boolean
|
||||
} & Pick<React.ComponentProps<typeof Button>, "size"> &
|
||||
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({
|
||||
variant: isActive ? "outline" : "ghost",
|
||||
size,
|
||||
}),
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationPrevious({
|
||||
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 />
|
||||
<span className="hidden sm:block">Previous</span>
|
||||
</PaginationLink>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationNext({
|
||||
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>
|
||||
<ChevronRightIcon />
|
||||
</PaginationLink>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationEllipsis({
|
||||
className,
|
||||
...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>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationLink,
|
||||
PaginationItem,
|
||||
PaginationPrevious,
|
||||
PaginationNext,
|
||||
PaginationEllipsis,
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Progress({
|
||||
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 }
|
||||
@@ -0,0 +1,185 @@
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...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> & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
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}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
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 />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-(--radix-select-trigger-height) w-full min-w-(--radix-select-trigger-width) scroll-my-1"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
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>) {
|
||||
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">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
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>) {
|
||||
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>) {
|
||||
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,
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
'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 };
|
||||
@@ -0,0 +1,47 @@
|
||||
import {
|
||||
CircleCheckIcon,
|
||||
InfoIcon,
|
||||
Loader2Icon,
|
||||
OctagonXIcon,
|
||||
TriangleAlertIcon,
|
||||
} from "lucide-react"
|
||||
import { useTheme } from "next-themes"
|
||||
import { Toaster as Sonner, type ToasterProps } from "sonner"
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme()
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
icons={{
|
||||
success: <CircleCheckIcon className="size-4" />,
|
||||
info: <InfoIcon className="size-4" />,
|
||||
warning: <TriangleAlertIcon className="size-4" />,
|
||||
error: <OctagonXIcon className="size-4" />,
|
||||
loading: <Loader2Icon className="size-4 animate-spin" />,
|
||||
}}
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
success: "border-green-500 bg-green-50 text-green-900 dark:bg-green-950 dark:text-green-100 [&>svg]:text-green-500",
|
||||
error: "border-red-500 bg-red-50 text-red-900 dark:bg-red-950 dark:text-red-100 [&>svg]:text-red-500",
|
||||
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",
|
||||
},
|
||||
}}
|
||||
style={
|
||||
{
|
||||
"--normal-bg": "var(--popover)",
|
||||
"--normal-text": "var(--popover-foreground)",
|
||||
"--normal-border": "var(--border)",
|
||||
"--border-radius": "var(--radius)",
|
||||
left: "calc(56px + 1rem)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toaster }
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Loader2 } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
|
||||
return (
|
||||
<Loader2
|
||||
role="status"
|
||||
aria-label="Loading"
|
||||
className={cn("size-4 animate-spin", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Spinner }
|
||||
@@ -0,0 +1,31 @@
|
||||
"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 }
|
||||
@@ -0,0 +1,103 @@
|
||||
'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,83 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
|
||||
import { type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { toggleVariants } from "@/components/ui/toggle"
|
||||
|
||||
const ToggleGroupContext = React.createContext<
|
||||
VariantProps<typeof toggleVariants> & {
|
||||
spacing?: number
|
||||
}
|
||||
>({
|
||||
size: "default",
|
||||
variant: "default",
|
||||
spacing: 0,
|
||||
})
|
||||
|
||||
function ToggleGroup({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
spacing = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants> & {
|
||||
spacing?: number
|
||||
}) {
|
||||
return (
|
||||
<ToggleGroupPrimitive.Root
|
||||
data-slot="toggle-group"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
data-spacing={spacing}
|
||||
style={{ "--gap": spacing } as React.CSSProperties}
|
||||
className={cn(
|
||||
"group/toggle-group flex w-fit items-center gap-[--spacing(var(--gap))] rounded-md data-[spacing=default]:data-[variant=outline]:shadow-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ToggleGroupContext.Provider value={{ variant, size, spacing }}>
|
||||
{children}
|
||||
</ToggleGroupContext.Provider>
|
||||
</ToggleGroupPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ToggleGroupItem({
|
||||
className,
|
||||
children,
|
||||
variant,
|
||||
size,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
|
||||
VariantProps<typeof toggleVariants>) {
|
||||
const context = React.useContext(ToggleGroupContext)
|
||||
|
||||
return (
|
||||
<ToggleGroupPrimitive.Item
|
||||
data-slot="toggle-group-item"
|
||||
data-variant={context.variant || variant}
|
||||
data-size={context.size || size}
|
||||
data-spacing={context.spacing}
|
||||
className={cn(
|
||||
toggleVariants({
|
||||
variant: context.variant || variant,
|
||||
size: context.size || size,
|
||||
}),
|
||||
"w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10",
|
||||
"data-[spacing=0]:rounded-none data-[spacing=0]:shadow-none data-[spacing=0]:first:rounded-l-md data-[spacing=0]:last:rounded-r-md data-[spacing=0]:data-[variant=outline]:border-l-0 data-[spacing=0]:data-[variant=outline]:first:border-l",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ToggleGroupPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
export { ToggleGroup, ToggleGroupItem }
|
||||
@@ -0,0 +1,47 @@
|
||||
"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 }
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
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>) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
...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>) {
|
||||
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}
|
||||
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px]" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
@@ -0,0 +1,172 @@
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { AnalyzeTrack } from "../../wailsjs/go/main/App";
|
||||
import type { AnalysisResult } from "@/types/api";
|
||||
import { logger } from "@/lib/logger";
|
||||
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() {
|
||||
const [analyzing, setAnalyzing] = useState(false);
|
||||
const [result, setResult] = useState<AnalysisResult | null>(() => {
|
||||
// Load from sessionStorage on mount - only detail, no spectrum
|
||||
try {
|
||||
const saved = sessionStorage.getItem(STORAGE_KEY);
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved);
|
||||
if (parsed.filePath && parsed.result) {
|
||||
// Return result WITHOUT spectrum - spectrum will be loaded async
|
||||
return {
|
||||
...parsed.result,
|
||||
spectrum: undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load saved analysis state:", err);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const [selectedFilePath, setSelectedFilePath] = useState<string>(() => {
|
||||
// Load file path from sessionStorage
|
||||
try {
|
||||
const saved = sessionStorage.getItem(STORAGE_KEY);
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved);
|
||||
return parsed.filePath || "";
|
||||
}
|
||||
} catch (err) {
|
||||
// Ignore
|
||||
}
|
||||
return "";
|
||||
});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [spectrumLoading, setSpectrumLoading] = useState(() => {
|
||||
// If result exists from sessionStorage, show loading for spectrum
|
||||
try {
|
||||
const saved = sessionStorage.getItem(STORAGE_KEY);
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved);
|
||||
if (parsed.filePath && parsed.result) {
|
||||
// Always show loading initially, will be resolved async
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Ignore
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const analyzeFile = useCallback(async (filePath: string) => {
|
||||
if (!filePath) {
|
||||
setError("No file path provided");
|
||||
return null;
|
||||
}
|
||||
|
||||
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`);
|
||||
|
||||
// Save spectrum to memory cache
|
||||
if (analysisResult.spectrum) {
|
||||
setSpectrumCache(filePath, analysisResult.spectrum);
|
||||
}
|
||||
|
||||
// Save detail (without spectrum) to sessionStorage
|
||||
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); // Spectrum is now available
|
||||
|
||||
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) {
|
||||
// Ignore
|
||||
}
|
||||
clearSpectrumCache();
|
||||
}, []);
|
||||
|
||||
// Load spectrum from cache asynchronously after detail is displayed
|
||||
useEffect(() => {
|
||||
// Only load spectrum if we have result without spectrum and are in loading state
|
||||
if (!result || !selectedFilePath || result.spectrum || !spectrumLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Load spectrum asynchronously to avoid blocking UI
|
||||
// Use requestAnimationFrame to ensure detail renders first
|
||||
let rafId: number;
|
||||
const loadSpectrum = () => {
|
||||
rafId = requestAnimationFrame(() => {
|
||||
const cachedSpectrum = getSpectrumCache(selectedFilePath);
|
||||
if (cachedSpectrum) {
|
||||
setResult(prev => prev ? { ...prev, spectrum: cachedSpectrum } : null);
|
||||
setSpectrumLoading(false);
|
||||
} else {
|
||||
// Spectrum not in cache - user needs to re-analyze
|
||||
setSpectrumLoading(false);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Double RAF to ensure detail is fully rendered
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(loadSpectrum);
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (rafId) {
|
||||
cancelAnimationFrame(rafId);
|
||||
}
|
||||
};
|
||||
}, [result, selectedFilePath, spectrumLoading]);
|
||||
|
||||
return {
|
||||
analyzing,
|
||||
result,
|
||||
error,
|
||||
selectedFilePath,
|
||||
spectrumLoading,
|
||||
analyzeFile,
|
||||
clearResult,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
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;
|
||||
}
|
||||
|
||||
// Check if already cached
|
||||
if (availabilityMap.has(spotifyId)) {
|
||||
return availabilityMap.get(spotifyId)!;
|
||||
}
|
||||
|
||||
setChecking(true);
|
||||
setCheckingTrackId(spotifyId);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
logger.info(`Checking availability for track: ${spotifyId}`);
|
||||
const response = await CheckTrackAvailability(spotifyId, isrc || "");
|
||||
const availability: TrackAvailability = JSON.parse(response);
|
||||
|
||||
setAvailabilityMap((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
newMap.set(spotifyId, availability);
|
||||
return newMap;
|
||||
});
|
||||
|
||||
logger.success(`Availability check completed for ${spotifyId}`);
|
||||
return availability;
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : "Failed to check availability";
|
||||
logger.error(`Availability check error: ${errorMessage}`);
|
||||
setError(errorMessage);
|
||||
return null;
|
||||
} finally {
|
||||
setChecking(false);
|
||||
setCheckingTrackId(null);
|
||||
}
|
||||
}, [availabilityMap]);
|
||||
|
||||
const getAvailability = useCallback((spotifyId: string) => {
|
||||
return availabilityMap.get(spotifyId);
|
||||
}, [availabilityMap]);
|
||||
|
||||
const clearAvailability = useCallback(() => {
|
||||
setAvailabilityMap(new Map());
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
checking,
|
||||
checkingTrackId,
|
||||
availabilityMap,
|
||||
error,
|
||||
checkAvailability,
|
||||
getAvailability,
|
||||
clearAvailability,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
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
|
||||
) => {
|
||||
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;
|
||||
|
||||
// Replace forward slashes in template data values to prevent them from being interpreted as path separators
|
||||
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),
|
||||
};
|
||||
|
||||
// For playlist/discography, prepend the folder name
|
||||
if (playlistName) {
|
||||
outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
|
||||
}
|
||||
|
||||
// Apply folder template
|
||||
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) {
|
||||
// Restore any slashes that were in the original values as spaces
|
||||
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
|
||||
) => {
|
||||
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;
|
||||
|
||||
// Replace forward slashes in template data values to prevent them from being interpreted as path separators
|
||||
const placeholder = "__SLASH_PLACEHOLDER__";
|
||||
// Determine if we should use album track number or sequential position
|
||||
const useAlbumTrackNumber = settings.folderTemplate?.includes("{album}") || false;
|
||||
// Use track.track_number for album context, otherwise use sequential position (consistent with track download)
|
||||
const trackPosition = useAlbumTrackNumber ? (track.track_number || i + 1) : (i + 1);
|
||||
// Build output path using template system
|
||||
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),
|
||||
};
|
||||
|
||||
// For playlist/discography, prepend the folder name
|
||||
if (playlistName) {
|
||||
outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
|
||||
}
|
||||
|
||||
// Apply folder template
|
||||
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) {
|
||||
// Restore any slashes that were in the original values as spaces
|
||||
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
@@ -0,0 +1,44 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { GetDownloadProgress } from "../../wailsjs/go/main/App";
|
||||
|
||||
export interface DownloadProgressInfo {
|
||||
is_downloading: boolean;
|
||||
mb_downloaded: number;
|
||||
speed_mbps: number;
|
||||
}
|
||||
|
||||
export function useDownloadProgress() {
|
||||
const [progress, setProgress] = useState<DownloadProgressInfo>({
|
||||
is_downloading: false,
|
||||
mb_downloaded: 0,
|
||||
speed_mbps: 0,
|
||||
});
|
||||
const intervalRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Poll progress every 200ms for smooth updates
|
||||
const pollProgress = async () => {
|
||||
try {
|
||||
const progressInfo = await GetDownloadProgress();
|
||||
setProgress(progressInfo);
|
||||
} catch (error) {
|
||||
console.error("Failed to get download progress:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// Start polling
|
||||
intervalRef.current = window.setInterval(pollProgress, 200);
|
||||
|
||||
// Initial fetch
|
||||
pollProgress();
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return progress;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
// Initial fetch
|
||||
fetchQueue();
|
||||
|
||||
// Poll every 200ms
|
||||
const interval = setInterval(fetchQueue, 200);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return queueInfo;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
import { useState, useRef } from "react";
|
||||
import { downloadLyrics } 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 useLyrics() {
|
||||
const [downloadingLyricsTrack, setDownloadingLyricsTrack] = useState<string | null>(null);
|
||||
const [downloadedLyrics, setDownloadedLyrics] = useState<Set<string>>(new Set());
|
||||
const [failedLyrics, setFailedLyrics] = useState<Set<string>>(new Set());
|
||||
const [skippedLyrics, setSkippedLyrics] = useState<Set<string>>(new Set());
|
||||
const [isBulkDownloadingLyrics, setIsBulkDownloadingLyrics] = useState(false);
|
||||
const [lyricsDownloadProgress, setLyricsDownloadProgress] = useState(0);
|
||||
const stopBulkDownloadRef = useRef(false);
|
||||
|
||||
const handleDownloadLyrics = async (
|
||||
spotifyId: string,
|
||||
trackName: string,
|
||||
artistName: string,
|
||||
albumName?: string,
|
||||
playlistName?: string,
|
||||
position?: number,
|
||||
albumArtist?: string,
|
||||
releaseDate?: string,
|
||||
discNumber?: number
|
||||
) => {
|
||||
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 using template system
|
||||
// Replace forward slashes in template data values to prevent them from being interpreted as path separators
|
||||
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),
|
||||
};
|
||||
|
||||
// For playlist/discography, prepend the folder name
|
||||
if (playlistName) {
|
||||
outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
|
||||
}
|
||||
|
||||
// Apply folder template
|
||||
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) {
|
||||
// Restore any slashes that were in the original values as spaces
|
||||
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));
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to download lyrics");
|
||||
setFailedLyrics((prev) => new Set(prev).add(spotifyId));
|
||||
} finally {
|
||||
setDownloadingLyricsTrack(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadAllLyrics = async (
|
||||
tracks: TrackMetadata[],
|
||||
playlistName?: string,
|
||||
_isArtistDiscography?: boolean
|
||||
) => {
|
||||
const tracksWithSpotifyId = tracks.filter((track) => track.spotify_id);
|
||||
|
||||
if (tracksWithSpotifyId.length === 0) {
|
||||
toast.error("No tracks with Spotify ID available for lyrics download");
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = getSettings();
|
||||
setIsBulkDownloadingLyrics(true);
|
||||
setLyricsDownloadProgress(0);
|
||||
stopBulkDownloadRef.current = false;
|
||||
|
||||
let completed = 0;
|
||||
let success = 0;
|
||||
let failed = 0;
|
||||
let skipped = 0;
|
||||
const total = tracksWithSpotifyId.length;
|
||||
|
||||
for (let i = 0; i < tracksWithSpotifyId.length; i++) {
|
||||
const track = tracksWithSpotifyId[i];
|
||||
if (stopBulkDownloadRef.current) {
|
||||
toast.info("Lyrics download stopped by user");
|
||||
break;
|
||||
}
|
||||
|
||||
const id = track.spotify_id!;
|
||||
setDownloadingLyricsTrack(id);
|
||||
setLyricsDownloadProgress(Math.round((completed / total) * 100));
|
||||
|
||||
try {
|
||||
const os = settings.operatingSystem;
|
||||
let outputDir = settings.downloadPath;
|
||||
|
||||
// Replace forward slashes in template data values to prevent them from being interpreted as path separators
|
||||
const placeholder = "__SLASH_PLACEHOLDER__";
|
||||
|
||||
// Determine if we should use album track number or sequential position
|
||||
const useAlbumTrackNumber = settings.folderTemplate?.includes("{album}") || false;
|
||||
// Use track.track_number for album context, otherwise use sequential position (consistent with track download)
|
||||
const trackPosition = useAlbumTrackNumber ? (track.track_number || i + 1) : (i + 1);
|
||||
|
||||
// Build output path using template system
|
||||
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),
|
||||
};
|
||||
|
||||
// For playlist/discography, prepend the folder name
|
||||
if (playlistName) {
|
||||
outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
|
||||
}
|
||||
|
||||
// Apply folder template
|
||||
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) {
|
||||
// Restore any slashes that were in the original values as spaces
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { useState } from "react";
|
||||
import { fetchSpotifyMetadata } from "@/lib/api";
|
||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||
import { logger } from "@/lib/logger";
|
||||
import type { SpotifyMetadataResponse } from "@/types/api";
|
||||
|
||||
export function useMetadata() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [metadata, setMetadata] = useState<SpotifyMetadataResponse | null>(null);
|
||||
const [showTimeoutDialog, setShowTimeoutDialog] = useState(false);
|
||||
const [timeoutValue, setTimeoutValue] = useState(60);
|
||||
const [pendingUrl, setPendingUrl] = useState("");
|
||||
const [showAlbumDialog, setShowAlbumDialog] = useState(false);
|
||||
const [selectedAlbum, setSelectedAlbum] = useState<{
|
||||
id: string;
|
||||
name: string;
|
||||
external_urls: string;
|
||||
} | null>(null);
|
||||
const [pendingArtistName, setPendingArtistName] = useState<string | null>(null);
|
||||
|
||||
const getUrlType = (url: string): string => {
|
||||
if (url.includes("/track/")) return "track";
|
||||
if (url.includes("/album/")) return "album";
|
||||
if (url.includes("/playlist/")) return "playlist";
|
||||
if (url.includes("/artist/")) return "artist";
|
||||
return "unknown";
|
||||
};
|
||||
|
||||
const fetchMetadataDirectly = async (url: string) => {
|
||||
const urlType = getUrlType(url);
|
||||
logger.info(`fetching ${urlType} metadata...`);
|
||||
logger.debug(`url: ${url}`);
|
||||
|
||||
setLoading(true);
|
||||
setMetadata(null);
|
||||
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
const data = await fetchSpotifyMetadata(url);
|
||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(2);
|
||||
|
||||
setMetadata(data);
|
||||
|
||||
// Log detailed info based on type
|
||||
if ("track" in data) {
|
||||
logger.success(`fetched track: ${data.track.name} - ${data.track.artists}`);
|
||||
logger.debug(`isrc: ${data.track.isrc}, duration: ${data.track.duration_ms}ms`);
|
||||
} 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 ("playlist_info" in data) {
|
||||
logger.success(`fetched playlist: ${data.track_list.length} tracks`);
|
||||
logger.debug(`by ${data.playlist_info.owner.display_name || data.playlist_info.owner.name}`);
|
||||
} else if ("artist_info" in data) {
|
||||
logger.success(`fetched artist: ${data.artist_info.name}`);
|
||||
logger.debug(`${data.album_list.length} albums, ${data.track_list.length} tracks`);
|
||||
}
|
||||
|
||||
logger.info(`fetch completed in ${elapsed}s`);
|
||||
toast.success("Metadata fetched successfully");
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : "Failed to fetch metadata";
|
||||
logger.error(`fetch failed: ${errorMsg}`);
|
||||
toast.error(errorMsg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFetchMetadata = async (url: string) => {
|
||||
if (!url.trim()) {
|
||||
logger.warning("empty url provided");
|
||||
toast.error("Please enter a Spotify URL");
|
||||
return;
|
||||
}
|
||||
|
||||
let urlToFetch = url.trim();
|
||||
const isArtistUrl = urlToFetch.includes("/artist/");
|
||||
|
||||
if (isArtistUrl && !urlToFetch.includes("/discography")) {
|
||||
urlToFetch = urlToFetch.replace(/\/$/, "") + "/discography/all";
|
||||
logger.debug("converted to discography url");
|
||||
}
|
||||
|
||||
if (isArtistUrl) {
|
||||
logger.info("artist url detected, showing timeout dialog");
|
||||
setPendingUrl(urlToFetch);
|
||||
setPendingArtistName(null); // Clear artist name for URL input
|
||||
setShowTimeoutDialog(true);
|
||||
} else {
|
||||
await fetchMetadataDirectly(urlToFetch);
|
||||
}
|
||||
|
||||
return urlToFetch;
|
||||
};
|
||||
|
||||
const handleConfirmFetch = async () => {
|
||||
setShowTimeoutDialog(false);
|
||||
logger.info(`fetching artist discography (timeout: ${timeoutValue}s)...`);
|
||||
logger.debug(`url: ${pendingUrl}`);
|
||||
|
||||
setLoading(true);
|
||||
setMetadata(null);
|
||||
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
const data = await fetchSpotifyMetadata(pendingUrl, true, 1.0, timeoutValue);
|
||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(2);
|
||||
|
||||
setMetadata(data);
|
||||
|
||||
if ("artist_info" in data) {
|
||||
logger.success(`fetched artist: ${data.artist_info.name}`);
|
||||
logger.debug(`${data.album_list.length} albums, ${data.track_list.length} tracks`);
|
||||
}
|
||||
|
||||
logger.info(`fetch completed in ${elapsed}s`);
|
||||
toast.success("Metadata fetched successfully");
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : "Failed to fetch metadata";
|
||||
logger.error(`fetch failed: ${errorMsg}`);
|
||||
toast.error(errorMsg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAlbumClick = (album: {
|
||||
id: string;
|
||||
name: string;
|
||||
external_urls: string;
|
||||
}) => {
|
||||
logger.debug(`album clicked: ${album.name}`);
|
||||
setSelectedAlbum(album);
|
||||
setShowAlbumDialog(true);
|
||||
};
|
||||
|
||||
const handleArtistClick = async (artist: {
|
||||
id: string;
|
||||
name: string;
|
||||
external_urls: string;
|
||||
}) => {
|
||||
logger.debug(`artist clicked: ${artist.name}`);
|
||||
const artistUrl = artist.external_urls.replace(/\/$/, "") + "/discography/all";
|
||||
setPendingUrl(artistUrl);
|
||||
setPendingArtistName(artist.name);
|
||||
setShowTimeoutDialog(true);
|
||||
return artistUrl;
|
||||
};
|
||||
|
||||
const handleConfirmAlbumFetch = async () => {
|
||||
if (!selectedAlbum) return;
|
||||
|
||||
const albumUrl = selectedAlbum.external_urls;
|
||||
logger.info(`fetching album: ${selectedAlbum.name}...`);
|
||||
logger.debug(`url: ${albumUrl}`);
|
||||
|
||||
setShowAlbumDialog(false);
|
||||
setLoading(true);
|
||||
setMetadata(null);
|
||||
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
const data = await fetchSpotifyMetadata(albumUrl);
|
||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(2);
|
||||
|
||||
setMetadata(data);
|
||||
|
||||
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}`);
|
||||
}
|
||||
|
||||
logger.info(`fetch completed in ${elapsed}s`);
|
||||
toast.success("Album metadata fetched successfully");
|
||||
return albumUrl;
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : "Failed to fetch album metadata";
|
||||
logger.error(`fetch failed: ${errorMsg}`);
|
||||
toast.error(errorMsg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setSelectedAlbum(null);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
loading,
|
||||
metadata,
|
||||
showTimeoutDialog,
|
||||
setShowTimeoutDialog,
|
||||
timeoutValue,
|
||||
setTimeoutValue,
|
||||
showAlbumDialog,
|
||||
setShowAlbumDialog,
|
||||
selectedAlbum,
|
||||
pendingArtistName,
|
||||
handleFetchMetadata,
|
||||
handleConfirmFetch,
|
||||
handleAlbumClick,
|
||||
handleConfirmAlbumFetch,
|
||||
handleArtistClick,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-family: "Google Sans", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
code, pre, .font-mono {
|
||||
font-family: "Google Sans Code", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace !important;
|
||||
}
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--font-mono: "Google Sans Code", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
}
|
||||
|
||||
/* Sonner Toast Styling */
|
||||
[data-sonner-toast] {
|
||||
@apply rounded-lg shadow-lg border;
|
||||
}
|
||||
|
||||
[data-sonner-toast][data-type="success"] {
|
||||
@apply bg-green-50 border-green-200 text-green-900;
|
||||
}
|
||||
|
||||
[data-sonner-toast][data-type="success"] [data-icon] {
|
||||
@apply text-green-600;
|
||||
}
|
||||
|
||||
[data-sonner-toast][data-type="error"] {
|
||||
@apply bg-red-50 border-red-200 text-red-900;
|
||||
}
|
||||
|
||||
[data-sonner-toast][data-type="error"] [data-icon] {
|
||||
@apply text-red-600;
|
||||
}
|
||||
|
||||
[data-sonner-toast][data-type="warning"] {
|
||||
@apply bg-yellow-50 border-yellow-200 text-yellow-900;
|
||||
}
|
||||
|
||||
[data-sonner-toast][data-type="warning"] [data-icon] {
|
||||
@apply text-yellow-600;
|
||||
}
|
||||
|
||||
[data-sonner-toast][data-type="info"] {
|
||||
@apply bg-blue-50 border-blue-200 text-blue-900;
|
||||
}
|
||||
|
||||
[data-sonner-toast][data-type="info"] [data-icon] {
|
||||
@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 [data-sonner-toast][data-type="success"] {
|
||||
@apply bg-green-950 border-green-800 text-green-100;
|
||||
}
|
||||
|
||||
.dark [data-sonner-toast][data-type="success"] [data-icon] {
|
||||
@apply text-green-400;
|
||||
}
|
||||
|
||||
.dark [data-sonner-toast][data-type="error"] {
|
||||
@apply bg-red-950 border-red-800 text-red-100;
|
||||
}
|
||||
|
||||
.dark [data-sonner-toast][data-type="error"] [data-icon] {
|
||||
@apply text-red-400;
|
||||
}
|
||||
|
||||
.dark [data-sonner-toast][data-type="warning"] {
|
||||
@apply bg-yellow-950 border-yellow-800 text-yellow-100;
|
||||
}
|
||||
|
||||
.dark [data-sonner-toast][data-type="warning"] [data-icon] {
|
||||
@apply text-yellow-400;
|
||||
}
|
||||
|
||||
.dark [data-sonner-toast][data-type="info"] {
|
||||
@apply bg-blue-950 border-blue-800 text-blue-100;
|
||||
}
|
||||
|
||||
.dark [data-sonner-toast][data-type="info"] [data-icon] {
|
||||
@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);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type {
|
||||
SpotifyMetadataResponse,
|
||||
DownloadRequest,
|
||||
DownloadResponse,
|
||||
HealthResponse,
|
||||
LyricsDownloadRequest,
|
||||
LyricsDownloadResponse,
|
||||
CoverDownloadRequest,
|
||||
CoverDownloadResponse,
|
||||
} from "@/types/api";
|
||||
import { GetSpotifyMetadata, DownloadTrack, DownloadLyrics, DownloadCover } from "../../wailsjs/go/main/App";
|
||||
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> {
|
||||
const req = new main.SpotifyMetadataRequest({
|
||||
url,
|
||||
batch,
|
||||
delay,
|
||||
timeout,
|
||||
});
|
||||
|
||||
const jsonString = await GetSpotifyMetadata(req);
|
||||
return JSON.parse(jsonString);
|
||||
}
|
||||
|
||||
export async function downloadTrack(
|
||||
request: DownloadRequest
|
||||
): Promise<DownloadResponse> {
|
||||
const req = new main.DownloadRequest(request);
|
||||
return await DownloadTrack(req);
|
||||
}
|
||||
|
||||
export async function checkHealth(): Promise<HealthResponse> {
|
||||
// For Wails, we can just return a simple health check
|
||||
// since the app is running locally
|
||||
return {
|
||||
status: "ok",
|
||||
time: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function downloadLyrics(
|
||||
request: LyricsDownloadRequest
|
||||
): Promise<LyricsDownloadResponse> {
|
||||
const req = new main.LyricsDownloadRequest(request);
|
||||
return await DownloadLyrics(req);
|
||||
}
|
||||
|
||||
export async function downloadCover(
|
||||
request: CoverDownloadRequest
|
||||
): Promise<CoverDownloadResponse> {
|
||||
const req = new main.CoverDownloadRequest(request);
|
||||
return await DownloadCover(req);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user