Compare commits
92 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 49a8de1b35 | |||
| cd2500d1df | |||
| ea1372f1fe | |||
| 65fbb9a8e9 | |||
| de16d9e25d | |||
| 6dd19b563b | |||
| 303b76d1ec | |||
| dbcd49225d | |||
| bdc7717ef3 | |||
| 9a7c539418 | |||
| 888ce2b61c | |||
| e2e1ab1cfa | |||
| 9bddeab0d1 | |||
| 03a30ee09a | |||
| 2d908e2f75 | |||
| e8f7bf7313 | |||
| 1f0922f358 | |||
| 3f267a3fa1 | |||
| 22da74a027 | |||
| 783350fe88 | |||
| 0057d43f46 | |||
| 9928968ffb |
@@ -0,0 +1,375 @@
|
|||||||
|
name: Build Multi-Platform
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
GO_VERSION: '1.25.4'
|
||||||
|
NODE_VERSION: '20'
|
||||||
|
|
||||||
|
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: Build application
|
||||||
|
run: wails build -platform windows/amd64
|
||||||
|
|
||||||
|
- name: Prepare artifacts
|
||||||
|
run: |
|
||||||
|
mkdir -p dist
|
||||||
|
Copy-Item -Path "build\bin\SpotiFLAC.exe" -Destination "dist\SpotiFLAC-${{ steps.version.outputs.version }}.exe"
|
||||||
|
|
||||||
|
- name: Upload artifacts
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: windows-portable
|
||||||
|
path: dist/SpotiFLAC-${{ steps.version.outputs.version }}.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-${{ steps.version.outputs.version }}.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-${{ steps.version.outputs.version }}.dmg
|
||||||
|
|
||||||
|
- name: Upload artifacts
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: macos-portable
|
||||||
|
path: dist/SpotiFLAC-${{ steps.version.outputs.version }}.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
|
||||||
|
|
||||||
|
# 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: Download appimagetool
|
||||||
|
run: |
|
||||||
|
wget -O appimagetool https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage
|
||||||
|
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-${{ steps.version.outputs.version }}.AppImage
|
||||||
|
|
||||||
|
- name: Upload artifacts
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: linux-portable
|
||||||
|
path: dist/SpotiFLAC-${{ steps.version.outputs.version }}.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-${{ steps.version.outputs.version }}.exe` - Windows
|
||||||
|
- `SpotiFLAC-${{ steps.version.outputs.version }}.dmg` - macOS
|
||||||
|
- `SpotiFLAC-${{ steps.version.outputs.version }}.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-${{ steps.version.outputs.version }}.AppImage
|
||||||
|
./SpotiFLAC-${{ steps.version.outputs.version }}.AppImage
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
files: |
|
||||||
|
artifacts/windows-portable/*.exe
|
||||||
|
artifacts/macos-portable/*.dmg
|
||||||
|
artifacts/linux-portable/*.AppImage
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
+61
@@ -0,0 +1,61 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
# Build notes (optional - uncomment if you don't want to commit)
|
||||||
|
# BUILD_NOTES.md
|
||||||
|
build.txt
|
||||||
@@ -1,22 +1,24 @@
|
|||||||
[](https://github.com/afkarxyz/SpotiFLAC/releases)
|
[](https://github.com/afkarxyz/SpotiFLAC/releases)
|
||||||
|
|
||||||

|

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

|
||||||
|

|
||||||
|

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

|

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

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

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

|
|
||||||
|
|
||||||
## Lossless Audio Check
|
## Lossless Audio Check
|
||||||
|
|
||||||
@@ -24,4 +26,4 @@
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
#### [Download](https://github.com/afkarxyz/SpotiFLAC/releases/download/v0/FLAC-Checker.zip) FLAC Checker
|
### [Download](https://github.com/afkarxyz/SpotiFLAC/releases/download/v0/FLAC-Checker.zip)
|
||||||
|
|||||||
-1426
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,197 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"spotiflac/backend"
|
||||||
|
"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"`
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 = "deezer"
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.OutputDir == "" {
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set downloading state
|
||||||
|
backend.SetDownloading(true)
|
||||||
|
defer backend.SetDownloading(false)
|
||||||
|
|
||||||
|
if req.Service == "tidal" {
|
||||||
|
searchQuery := req.Query
|
||||||
|
if searchQuery == "" {
|
||||||
|
searchQuery = req.ISRC
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.ApiURL == "" || req.ApiURL == "auto" {
|
||||||
|
downloader := backend.NewTidalDownloader("")
|
||||||
|
filename, err = downloader.DownloadWithFallback(searchQuery, req.ISRC, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName)
|
||||||
|
} else {
|
||||||
|
downloader := backend.NewTidalDownloader(req.ApiURL)
|
||||||
|
filename, err = downloader.Download(searchQuery, req.ISRC, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName)
|
||||||
|
}
|
||||||
|
} else if req.Service == "qobuz" {
|
||||||
|
downloader := backend.NewQobuzDownloader()
|
||||||
|
err = downloader.DownloadByISRC(req.ISRC, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName)
|
||||||
|
if err == nil {
|
||||||
|
filename = "Downloaded via Qobuz"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
downloader := backend.NewDeezerDownloader()
|
||||||
|
err = downloader.DownloadByISRC(req.ISRC, req.OutputDir, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName)
|
||||||
|
if err == nil {
|
||||||
|
filename = "Downloaded via Deezer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return DownloadResponse{
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("Download failed: %v", err),
|
||||||
|
}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return DownloadResponse{
|
||||||
|
Success: true,
|
||||||
|
Message: "Download completed successfully",
|
||||||
|
File: filename,
|
||||||
|
}, 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
@@ -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,289 @@
|
|||||||
|
package backend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DeezerDownloader struct {
|
||||||
|
client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeezerTrack struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
TitleShort string `json:"title_short"`
|
||||||
|
Duration int `json:"duration"`
|
||||||
|
TrackPos int `json:"track_position"`
|
||||||
|
DiskNumber int `json:"disk_number"`
|
||||||
|
ISRC string `json:"isrc"`
|
||||||
|
ReleaseDate string `json:"release_date"`
|
||||||
|
Artist struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
} `json:"artist"`
|
||||||
|
Album struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
CoverXL string `json:"cover_xl"`
|
||||||
|
CoverBig string `json:"cover_big"`
|
||||||
|
} `json:"album"`
|
||||||
|
Contributors []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
} `json:"contributors"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeezMateResponse struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Links struct {
|
||||||
|
FLAC string `json:"flac"`
|
||||||
|
} `json:"links"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDeezerDownloader() *DeezerDownloader {
|
||||||
|
return &DeezerDownloader{
|
||||||
|
client: &http.Client{
|
||||||
|
Timeout: 60 * time.Second,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DeezerDownloader) GetTrackByISRC(isrc string) (*DeezerTrack, error) {
|
||||||
|
// Decode base64 API URL
|
||||||
|
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuZGVlemVyLmNvbS8yLjAvdHJhY2svaXNyYzo=")
|
||||||
|
url := fmt.Sprintf("%s%s", string(apiBase), isrc)
|
||||||
|
|
||||||
|
resp, err := d.client.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to fetch track: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var track DeezerTrack
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&track); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if track.ID == 0 {
|
||||||
|
return nil, fmt.Errorf("track not found for ISRC: %s", isrc)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &track, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DeezerDownloader) GetDownloadURL(trackID int64) (string, error) {
|
||||||
|
// Decode base64 API URL
|
||||||
|
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuZGVlem1hdGUuY29tL2RsLw==")
|
||||||
|
url := fmt.Sprintf("%s%d", string(apiBase), trackID)
|
||||||
|
|
||||||
|
resp, err := d.client.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to get download URL: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var apiResp DeezMateResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to decode API response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !apiResp.Success || apiResp.Links.FLAC == "" {
|
||||||
|
return "", fmt.Errorf("no FLAC download link available")
|
||||||
|
}
|
||||||
|
|
||||||
|
return apiResp.Links.FLAC, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DeezerDownloader) DownloadFile(url, filepath string) error {
|
||||||
|
resp, err := d.client.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to download file: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return fmt.Errorf("download failed with status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := os.Create(filepath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create file: %w", err)
|
||||||
|
}
|
||||||
|
defer out.Close()
|
||||||
|
|
||||||
|
fmt.Println("Downloading...")
|
||||||
|
// Use progress writer to track download
|
||||||
|
pw := NewProgressWriter(out)
|
||||||
|
_, err = io.Copy(pw, resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to write file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print final size
|
||||||
|
fmt.Printf("\rDownloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DeezerDownloader) DownloadCoverArt(coverURL, filepath string) error {
|
||||||
|
if coverURL == "" {
|
||||||
|
return fmt.Errorf("no cover URL provided")
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := d.client.Get(coverURL)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to download cover: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return fmt.Errorf("cover download failed with status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := os.Create(filepath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create cover file: %w", err)
|
||||||
|
}
|
||||||
|
defer out.Close()
|
||||||
|
|
||||||
|
_, err = io.Copy(out, resp.Body)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeFilename(name string) string {
|
||||||
|
re := regexp.MustCompile(`[<>:"/\\|?*]`)
|
||||||
|
sanitized := re.ReplaceAllString(name, "_")
|
||||||
|
sanitized = strings.TrimSpace(sanitized)
|
||||||
|
if sanitized == "" {
|
||||||
|
return "Unknown"
|
||||||
|
}
|
||||||
|
return sanitized
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildFilename(title, artist string, trackNumber int, format string, includeTrackNumber bool, position int) string {
|
||||||
|
var filename string
|
||||||
|
|
||||||
|
// Build base filename based on format
|
||||||
|
switch format {
|
||||||
|
case "artist-title":
|
||||||
|
filename = fmt.Sprintf("%s - %s", artist, title)
|
||||||
|
case "title":
|
||||||
|
filename = title
|
||||||
|
default: // "title-artist"
|
||||||
|
filename = fmt.Sprintf("%s - %s", title, artist)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add track number prefix if enabled
|
||||||
|
// Only use track number for bulk downloads (when position > 0)
|
||||||
|
if includeTrackNumber && position > 0 {
|
||||||
|
filename = fmt.Sprintf("%02d. %s", position, filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
return filename + ".flac"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DeezerDownloader) DownloadByISRC(isrc, outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName string) error {
|
||||||
|
fmt.Printf("Fetching track info for ISRC: %s\n", isrc)
|
||||||
|
|
||||||
|
track, err := d.GetTrackByISRC(isrc)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use Spotify metadata if provided, otherwise fallback to Deezer metadata
|
||||||
|
artists := spotifyArtistName
|
||||||
|
trackTitle := spotifyTrackName
|
||||||
|
albumTitle := spotifyAlbumName
|
||||||
|
|
||||||
|
if artists == "" {
|
||||||
|
artists = track.Artist.Name
|
||||||
|
if len(track.Contributors) > 0 {
|
||||||
|
var mainArtists []string
|
||||||
|
for _, contrib := range track.Contributors {
|
||||||
|
if contrib.Role == "Main" {
|
||||||
|
mainArtists = append(mainArtists, contrib.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(mainArtists) > 0 {
|
||||||
|
artists = strings.Join(mainArtists, ", ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if trackTitle == "" {
|
||||||
|
trackTitle = track.Title
|
||||||
|
}
|
||||||
|
|
||||||
|
if albumTitle == "" {
|
||||||
|
albumTitle = track.Album.Title
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Found track: %s - %s\n", artists, trackTitle)
|
||||||
|
fmt.Printf("Album: %s\n", albumTitle)
|
||||||
|
|
||||||
|
downloadURL, err := d.GetDownloadURL(track.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
safeArtist := sanitizeFilename(artists)
|
||||||
|
safeTitle := sanitizeFilename(trackTitle)
|
||||||
|
|
||||||
|
// Build filename based on format settings
|
||||||
|
filename := buildFilename(safeTitle, safeArtist, track.TrackPos, filenameFormat, includeTrackNumber, position)
|
||||||
|
filepath := filepath.Join(outputDir, filename)
|
||||||
|
|
||||||
|
fmt.Println("Downloading FLAC file...")
|
||||||
|
if err := d.DownloadFile(downloadURL, filepath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Downloaded: %s\n", filepath)
|
||||||
|
|
||||||
|
coverPath := ""
|
||||||
|
if track.Album.CoverXL != "" {
|
||||||
|
coverPath = filepath + ".cover.jpg"
|
||||||
|
fmt.Println("Downloading cover art...")
|
||||||
|
if err := d.DownloadCoverArt(track.Album.CoverXL, coverPath); err != nil {
|
||||||
|
fmt.Printf("Warning: Failed to download cover art: %v\n", err)
|
||||||
|
} else {
|
||||||
|
defer os.Remove(coverPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Embedding metadata and cover art...")
|
||||||
|
// Only use track number for bulk downloads (when position > 0)
|
||||||
|
trackNumberToEmbed := 0
|
||||||
|
if position > 0 {
|
||||||
|
trackNumberToEmbed = position
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata := Metadata{
|
||||||
|
Title: trackTitle,
|
||||||
|
Artist: artists,
|
||||||
|
Album: albumTitle,
|
||||||
|
Date: track.ReleaseDate,
|
||||||
|
TrackNumber: trackNumberToEmbed,
|
||||||
|
DiscNumber: track.DiskNumber,
|
||||||
|
ISRC: track.ISRC,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := EmbedMetadata(filepath, metadata, coverPath); err != nil {
|
||||||
|
return fmt.Errorf("failed to embed metadata: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Metadata embedded successfully!")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package backend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"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
|
||||||
|
Date string
|
||||||
|
TrackNumber int
|
||||||
|
DiscNumber int
|
||||||
|
ISRC 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.Date != "" {
|
||||||
|
_ = cmt.Add(flacvorbis.FIELD_DATE, metadata.Date)
|
||||||
|
}
|
||||||
|
if metadata.TrackNumber > 0 {
|
||||||
|
_ = cmt.Add(flacvorbis.FIELD_TRACKNUMBER, strconv.Itoa(metadata.TrackNumber))
|
||||||
|
}
|
||||||
|
if metadata.DiscNumber > 0 {
|
||||||
|
_ = cmt.Add("DISCNUMBER", strconv.Itoa(metadata.DiscNumber))
|
||||||
|
}
|
||||||
|
if metadata.ISRC != "" {
|
||||||
|
_ = cmt.Add(flacvorbis.FIELD_ISRC, metadata.ISRC)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package backend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Global progress tracker
|
||||||
|
var (
|
||||||
|
currentProgress float64
|
||||||
|
currentProgressLock sync.RWMutex
|
||||||
|
isDownloading bool
|
||||||
|
downloadingLock sync.RWMutex
|
||||||
|
currentSpeed float64
|
||||||
|
speedLock 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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewProgressWriter(writer io.Writer) *ProgressWriter {
|
||||||
|
now := getCurrentTimeMillis()
|
||||||
|
return &ProgressWriter{
|
||||||
|
writer: writer,
|
||||||
|
total: 0,
|
||||||
|
lastPrinted: 0,
|
||||||
|
startTime: now,
|
||||||
|
lastTime: now,
|
||||||
|
lastBytes: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
pw.lastPrinted = pw.total
|
||||||
|
pw.lastTime = now
|
||||||
|
pw.lastBytes = pw.total
|
||||||
|
}
|
||||||
|
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pw *ProgressWriter) GetTotal() int64 {
|
||||||
|
return pw.total
|
||||||
|
}
|
||||||
@@ -0,0 +1,365 @@
|
|||||||
|
package backend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"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
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 := "27" // Default to Hi-Res
|
||||||
|
|
||||||
|
fmt.Printf("Getting download URL for track ID: %d\n", trackID)
|
||||||
|
|
||||||
|
// Decode base64 API URLs
|
||||||
|
primaryBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9kYWIueWVldC5zdS9hcGkvc3RyZWFtP3RyYWNrSWQ9")
|
||||||
|
|
||||||
|
// Try primary API first
|
||||||
|
primaryURL := fmt.Sprintf("%s%d&quality=%s", string(primaryBase), trackID, qualityCode)
|
||||||
|
|
||||||
|
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, _ := io.ReadAll(resp.Body)
|
||||||
|
fmt.Printf("Fallback API response: %s\n", string(body))
|
||||||
|
|
||||||
|
var streamResp QobuzStreamResponse
|
||||||
|
if err := json.Unmarshal(body, &streamResp); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to decode response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
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...")
|
||||||
|
resp, err := q.client.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to download file: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return fmt.Errorf("download failed with status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 string, trackNumber int, format string, includeTrackNumber bool, position int) string {
|
||||||
|
var filename string
|
||||||
|
|
||||||
|
// Build base filename based on format
|
||||||
|
switch format {
|
||||||
|
case "artist-title":
|
||||||
|
filename = fmt.Sprintf("%s - %s", artist, title)
|
||||||
|
case "title":
|
||||||
|
filename = title
|
||||||
|
default: // "title-artist"
|
||||||
|
filename = fmt.Sprintf("%s - %s", title, artist)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add track number prefix if enabled
|
||||||
|
// Only use track number for bulk downloads (when position > 0)
|
||||||
|
if includeTrackNumber && position > 0 {
|
||||||
|
filename = fmt.Sprintf("%02d. %s", position, filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
return filename + ".flac"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *QobuzDownloader) DownloadByISRC(isrc, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use Spotify metadata if provided, otherwise fallback to Qobuz metadata
|
||||||
|
artists := spotifyArtistName
|
||||||
|
trackTitle := spotifyTrackName
|
||||||
|
albumTitle := spotifyAlbumName
|
||||||
|
|
||||||
|
if artists == "" {
|
||||||
|
artists = track.Performer.Name
|
||||||
|
if track.Album.Artist.Name != "" {
|
||||||
|
artists = track.Album.Artist.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if trackTitle == "" {
|
||||||
|
trackTitle = track.Title
|
||||||
|
if track.Version != "" && track.Version != "null" {
|
||||||
|
trackTitle = fmt.Sprintf("%s (%s)", track.Title, track.Version)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if albumTitle == "" {
|
||||||
|
albumTitle = track.Album.Title
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Found track: %s - %s\n", artists, trackTitle)
|
||||||
|
fmt.Printf("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)
|
||||||
|
|
||||||
|
// Build filename based on format settings
|
||||||
|
filename := buildQobuzFilename(safeTitle, safeArtist, track.TrackNumber, filenameFormat, includeTrackNumber, position)
|
||||||
|
filepath := filepath.Join(outputDir, filename)
|
||||||
|
|
||||||
|
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 := ""
|
||||||
|
if track.Album.Image.Large != "" {
|
||||||
|
coverPath = filepath + ".cover.jpg"
|
||||||
|
fmt.Println("Downloading cover art...")
|
||||||
|
if err := q.DownloadCoverArt(track.Album.Image.Large, coverPath); err != nil {
|
||||||
|
fmt.Printf("Warning: Failed to download cover art: %v\n", err)
|
||||||
|
} else {
|
||||||
|
defer os.Remove(coverPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Embedding metadata and cover art...")
|
||||||
|
|
||||||
|
releaseYear := ""
|
||||||
|
if len(track.ReleaseDateOriginal) >= 4 {
|
||||||
|
releaseYear = track.ReleaseDateOriginal[:4]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only use track number for bulk downloads (when position > 0)
|
||||||
|
trackNumberToEmbed := 0
|
||||||
|
if position > 0 {
|
||||||
|
trackNumberToEmbed = position
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata := Metadata{
|
||||||
|
Title: trackTitle,
|
||||||
|
Artist: artists,
|
||||||
|
Album: albumTitle,
|
||||||
|
Date: releaseYear,
|
||||||
|
TrackNumber: trackNumberToEmbed,
|
||||||
|
DiscNumber: track.MediaNumber,
|
||||||
|
ISRC: track.ISRC,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := EmbedMetadata(filepath, metadata, coverPath); err != nil {
|
||||||
|
return fmt.Errorf("failed to embed metadata: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Metadata embedded successfully!")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,505 @@
|
|||||||
|
package backend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TidalDownloader struct {
|
||||||
|
client *http.Client
|
||||||
|
timeout time.Duration
|
||||||
|
maxRetries int
|
||||||
|
clientID string
|
||||||
|
clientSecret string
|
||||||
|
apiURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
type TidalSearchResponse struct {
|
||||||
|
Limit int `json:"limit"`
|
||||||
|
Offset int `json:"offset"`
|
||||||
|
TotalNumberOfItems int `json:"totalNumberOfItems"`
|
||||||
|
Items []TidalTrack `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TidalTrack struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
ISRC string `json:"isrc"`
|
||||||
|
AudioQuality string `json:"audioQuality"`
|
||||||
|
TrackNumber int `json:"trackNumber"`
|
||||||
|
VolumeNumber int `json:"volumeNumber"`
|
||||||
|
Duration int `json:"duration"`
|
||||||
|
Copyright string `json:"copyright"`
|
||||||
|
Explicit bool `json:"explicit"`
|
||||||
|
Album struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Cover string `json:"cover"`
|
||||||
|
ReleaseDate string `json:"releaseDate"`
|
||||||
|
} `json:"album"`
|
||||||
|
Artists []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"artists"`
|
||||||
|
Artist struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"artist"`
|
||||||
|
MediaMetadata struct {
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
} `json:"mediaMetadata"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TidalAPIResponse struct {
|
||||||
|
OriginalTrackURL string `json:"OriginalTrackUrl"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TidalAPIInfo struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTidalDownloader(apiURL string) *TidalDownloader {
|
||||||
|
clientID, _ := base64.StdEncoding.DecodeString("NkJEU1JkcEs5aHFFQlRnVQ==")
|
||||||
|
clientSecret, _ := base64.StdEncoding.DecodeString("eGV1UG1ZN25icFo5SUliTEFjUTkzc2hrYTFWTmhlVUFxTjZJY3N6alRHOD0=")
|
||||||
|
|
||||||
|
return &TidalDownloader{
|
||||||
|
client: &http.Client{
|
||||||
|
Timeout: 60 * time.Second,
|
||||||
|
},
|
||||||
|
timeout: 30 * time.Second,
|
||||||
|
maxRetries: 3,
|
||||||
|
clientID: string(clientID),
|
||||||
|
clientSecret: string(clientSecret),
|
||||||
|
apiURL: apiURL,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *TidalDownloader) GetAvailableAPIs() ([]string, error) {
|
||||||
|
// Decode base64 API URL
|
||||||
|
apiURL, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9yYXcuZ2l0aHVidXNlcmNvbnRlbnQuY29tL2Fma2FyeHl6L1Nwb3RpRkxBQy9yZWZzL2hlYWRzL21haW4vdGlkYWwuanNvbg==")
|
||||||
|
|
||||||
|
// Add cache-busting parameter with current timestamp
|
||||||
|
urlWithCacheBust := fmt.Sprintf("%s?t=%d", string(apiURL), time.Now().Unix())
|
||||||
|
|
||||||
|
// Create request with cache bypass headers
|
||||||
|
req, err := http.NewRequest("GET", urlWithCacheBust, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add headers to bypass cache
|
||||||
|
req.Header.Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||||
|
req.Header.Set("Pragma", "no-cache")
|
||||||
|
req.Header.Set("Expires", "0")
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to fetch API list: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return nil, fmt.Errorf("failed to fetch API list: HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var apiList []string
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&apiList); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode API list: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var apis []string
|
||||||
|
for _, api := range apiList {
|
||||||
|
apis = append(apis, "https://"+api)
|
||||||
|
}
|
||||||
|
|
||||||
|
return apis, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *TidalDownloader) GetAccessToken() (string, error) {
|
||||||
|
data := fmt.Sprintf("client_id=%s&grant_type=client_credentials", t.clientID)
|
||||||
|
|
||||||
|
// Decode base64 API URL
|
||||||
|
authURL, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hdXRoLnRpZGFsLmNvbS92MS9vYXV0aDIvdG9rZW4=")
|
||||||
|
req, err := http.NewRequest("POST", string(authURL), strings.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
req.SetBasicAuth(t.clientID, t.clientSecret)
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
|
||||||
|
resp, err := t.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return "", fmt.Errorf("failed to get access token: HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.AccessToken, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *TidalDownloader) SearchTracks(query string) (*TidalSearchResponse, error) {
|
||||||
|
token, err := t.GetAccessToken()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get access token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode base64 API URL and encode the query parameter
|
||||||
|
searchBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkudGlkYWwuY29tL3YxL3NlYXJjaC90cmFja3M/cXVlcnk9")
|
||||||
|
searchURL := fmt.Sprintf("%s%s&limit=25&offset=0&countryCode=US", string(searchBase), url.QueryEscape(query))
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", searchURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
|
||||||
|
resp, err := t.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return nil, fmt.Errorf("search failed: HTTP %d - %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var result TidalSearchResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *TidalDownloader) GetTrackInfo(query, isrc string) (*TidalTrack, error) {
|
||||||
|
fmt.Printf("Fetching: %s", query)
|
||||||
|
if isrc != "" {
|
||||||
|
fmt.Printf(" (ISRC: %s)", isrc)
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
result, err := t.SearchTracks(query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result.Items) == 0 {
|
||||||
|
return nil, fmt.Errorf("no tracks found for query: %s", query)
|
||||||
|
}
|
||||||
|
|
||||||
|
var selectedTrack *TidalTrack
|
||||||
|
|
||||||
|
if isrc != "" {
|
||||||
|
var isrcMatches []TidalTrack
|
||||||
|
for _, item := range result.Items {
|
||||||
|
if item.ISRC == isrc {
|
||||||
|
isrcMatches = append(isrcMatches, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(isrcMatches) > 1 {
|
||||||
|
for _, item := range isrcMatches {
|
||||||
|
for _, tag := range item.MediaMetadata.Tags {
|
||||||
|
if tag == "HIRES_LOSSLESS" {
|
||||||
|
selectedTrack = &item
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if selectedTrack != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if selectedTrack == nil {
|
||||||
|
selectedTrack = &isrcMatches[0]
|
||||||
|
}
|
||||||
|
} else if len(isrcMatches) == 1 {
|
||||||
|
selectedTrack = &isrcMatches[0]
|
||||||
|
} else {
|
||||||
|
selectedTrack = &result.Items[0]
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
selectedTrack = &result.Items[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
if selectedTrack == nil {
|
||||||
|
return nil, fmt.Errorf("track not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Found: %s (%s)\n", selectedTrack.Title, selectedTrack.AudioQuality)
|
||||||
|
return selectedTrack, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (string, error) {
|
||||||
|
fmt.Println("Fetching URL...")
|
||||||
|
|
||||||
|
url := fmt.Sprintf("%s/track/?id=%d&quality=%s", t.apiURL, trackID, quality)
|
||||||
|
|
||||||
|
resp, err := t.client.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to get download URL: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return "", fmt.Errorf("API returned status code: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var apiResponses []TidalAPIResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&apiResponses); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to decode response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(apiResponses) == 0 {
|
||||||
|
return "", fmt.Errorf("no download URL in response")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, item := range apiResponses {
|
||||||
|
if item.OriginalTrackURL != "" {
|
||||||
|
fmt.Println("URL found")
|
||||||
|
return item.OriginalTrackURL, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("download URL not found in response")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *TidalDownloader) DownloadAlbumArt(albumID string) ([]byte, error) {
|
||||||
|
albumID = strings.ReplaceAll(albumID, "-", "/")
|
||||||
|
// Decode base64 API URL
|
||||||
|
imageBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9yZXNvdXJjZXMudGlkYWwuY29tL2ltYWdlcy8=")
|
||||||
|
artURL := fmt.Sprintf("%s%s/1280x1280.jpg", string(imageBase), albumID)
|
||||||
|
|
||||||
|
resp, err := t.client.Get(artURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return nil, fmt.Errorf("failed to download album art: HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
return io.ReadAll(resp.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *TidalDownloader) DownloadFile(url, filepath string) error {
|
||||||
|
resp, err := t.client.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to download file: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return fmt.Errorf("download failed with status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := os.Create(filepath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create file: %w", err)
|
||||||
|
}
|
||||||
|
defer out.Close()
|
||||||
|
|
||||||
|
// 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))
|
||||||
|
|
||||||
|
fmt.Println("Download complete")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *TidalDownloader) Download(query, isrc, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName string) (string, error) {
|
||||||
|
if outputDir != "." {
|
||||||
|
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||||
|
return "", fmt.Errorf("directory error: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
trackInfo, err := t.GetTrackInfo(query, isrc)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if trackInfo.ID == 0 {
|
||||||
|
return "", fmt.Errorf("no track ID found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use Spotify metadata if provided, otherwise fallback to Tidal metadata
|
||||||
|
artistName := spotifyArtistName
|
||||||
|
trackTitle := spotifyTrackName
|
||||||
|
albumTitle := spotifyAlbumName
|
||||||
|
|
||||||
|
if artistName == "" {
|
||||||
|
var artists []string
|
||||||
|
if len(trackInfo.Artists) > 0 {
|
||||||
|
for _, artist := range trackInfo.Artists {
|
||||||
|
if artist.Name != "" {
|
||||||
|
artists = append(artists, artist.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if trackInfo.Artist.Name != "" {
|
||||||
|
artists = append(artists, trackInfo.Artist.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
artistName = "Unknown Artist"
|
||||||
|
if len(artists) > 0 {
|
||||||
|
artistName = strings.Join(artists, ", ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
artistName = sanitizeFilename(artistName)
|
||||||
|
|
||||||
|
if trackTitle == "" {
|
||||||
|
trackTitle = trackInfo.Title
|
||||||
|
if trackTitle == "" {
|
||||||
|
trackTitle = fmt.Sprintf("track_%d", trackInfo.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
trackTitle = sanitizeFilename(trackTitle)
|
||||||
|
|
||||||
|
if albumTitle == "" {
|
||||||
|
albumTitle = trackInfo.Album.Title
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build filename based on format settings
|
||||||
|
filename := buildTidalFilename(trackTitle, artistName, trackInfo.TrackNumber, filenameFormat, includeTrackNumber, position)
|
||||||
|
outputFilename := filepath.Join(outputDir, filename)
|
||||||
|
|
||||||
|
if fileInfo, err := os.Stat(outputFilename); err == nil && fileInfo.Size() > 0 {
|
||||||
|
fmt.Printf("File already exists: %s (%.2f MB)\n", outputFilename, float64(fileInfo.Size())/(1024*1024))
|
||||||
|
return outputFilename, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadURL, err := t.GetDownloadURL(trackInfo.ID, quality)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Downloading to: %s\n", outputFilename)
|
||||||
|
if err := t.DownloadFile(downloadURL, outputFilename); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Adding metadata...")
|
||||||
|
|
||||||
|
coverPath := ""
|
||||||
|
if trackInfo.Album.Cover != "" {
|
||||||
|
coverPath = outputFilename + ".cover.jpg"
|
||||||
|
albumArt, err := t.DownloadAlbumArt(trackInfo.Album.Cover)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Warning: Failed to download album art: %v\n", err)
|
||||||
|
} else {
|
||||||
|
if err := os.WriteFile(coverPath, albumArt, 0644); err != nil {
|
||||||
|
fmt.Printf("Warning: Failed to save album art: %v\n", err)
|
||||||
|
} else {
|
||||||
|
defer os.Remove(coverPath)
|
||||||
|
fmt.Println("Album art downloaded")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
releaseYear := ""
|
||||||
|
if len(trackInfo.Album.ReleaseDate) >= 4 {
|
||||||
|
releaseYear = trackInfo.Album.ReleaseDate[:4]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only use track number for bulk downloads (when position > 0)
|
||||||
|
trackNumberToEmbed := 0
|
||||||
|
if position > 0 {
|
||||||
|
trackNumberToEmbed = position
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata := Metadata{
|
||||||
|
Title: trackTitle,
|
||||||
|
Artist: artistName,
|
||||||
|
Album: albumTitle,
|
||||||
|
Date: releaseYear,
|
||||||
|
TrackNumber: trackNumberToEmbed,
|
||||||
|
DiscNumber: trackInfo.VolumeNumber,
|
||||||
|
ISRC: trackInfo.ISRC,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := EmbedMetadata(outputFilename, metadata, coverPath); err != nil {
|
||||||
|
fmt.Printf("Tagging failed: %v\n", err)
|
||||||
|
} else {
|
||||||
|
fmt.Println("Metadata saved")
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Done")
|
||||||
|
return outputFilename, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *TidalDownloader) DownloadWithFallback(query, isrc, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName string) (string, error) {
|
||||||
|
apis, err := t.GetAvailableAPIs()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("no APIs available for fallback: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastError error
|
||||||
|
for i, apiURL := range apis {
|
||||||
|
fmt.Printf("[Auto Fallback %d/%d] Trying: %s\n", i+1, len(apis), apiURL)
|
||||||
|
|
||||||
|
fallbackDownloader := NewTidalDownloader(apiURL)
|
||||||
|
|
||||||
|
result, err := fallbackDownloader.Download(query, isrc, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName)
|
||||||
|
if err == nil {
|
||||||
|
fmt.Printf("✓ Success with: %s\n", apiURL)
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
lastError = err
|
||||||
|
errMsg := err.Error()
|
||||||
|
if len(errMsg) > 80 {
|
||||||
|
errMsg = errMsg[:80]
|
||||||
|
}
|
||||||
|
fmt.Printf("✗ Failed with %s: %s\n", apiURL, errMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("all %d APIs failed. Last error: %v", len(apis), lastError)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildTidalFilename(title, artist string, trackNumber int, format string, includeTrackNumber bool, position int) string {
|
||||||
|
var filename string
|
||||||
|
|
||||||
|
// Build base filename based on format
|
||||||
|
switch format {
|
||||||
|
case "artist-title":
|
||||||
|
filename = fmt.Sprintf("%s - %s", artist, title)
|
||||||
|
case "title":
|
||||||
|
filename = title
|
||||||
|
default: // "title-artist"
|
||||||
|
filename = fmt.Sprintf("%s - %s", title, artist)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add track number prefix if enabled
|
||||||
|
// Only use track number for bulk downloads (when position > 0)
|
||||||
|
if includeTrackNumber && position > 0 {
|
||||||
|
filename = fmt.Sprintf("%02d. %s", position, filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
return filename + ".flac"
|
||||||
|
}
|
||||||
@@ -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,22 @@
|
|||||||
|
{
|
||||||
|
"$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": {}
|
||||||
|
}
|
||||||
@@ -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=Google+Sans+Code:ital,wght@0,300..800;1,300..800&family=Google+Sans+Flex:opsz,wght@6..144,1..1000&display=swap" rel="stylesheet">
|
||||||
|
<title>SpotiFLAC</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"generate-icon": "node scripts/generate-icon.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
|
"@radix-ui/react-progress": "^1.1.8",
|
||||||
|
"@radix-ui/react-radio-group": "^1.3.8",
|
||||||
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
|
"@tailwindcss/vite": "^4.1.17",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.554.0",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-dom": "^19.2.0",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
|
"tailwind-merge": "^3.4.0",
|
||||||
|
"tailwindcss": "^4.1.17"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@types/node": "^24.10.1",
|
||||||
|
"@types/react": "^19.2.6",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
|
"eslint": "^9.39.1",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
|
"globals": "^16.5.0",
|
||||||
|
"sharp": "^0.34.5",
|
||||||
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"typescript-eslint": "^8.47.0",
|
||||||
|
"vite": "^7.2.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
e00813ca84dd3deaade9854c0df093cd
|
||||||
Generated
+3787
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,42 @@
|
|||||||
|
#root {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
height: 6em;
|
||||||
|
padding: 1.5em;
|
||||||
|
will-change: filter;
|
||||||
|
transition: filter 300ms;
|
||||||
|
}
|
||||||
|
.logo:hover {
|
||||||
|
filter: drop-shadow(0 0 2em #646cffaa);
|
||||||
|
}
|
||||||
|
.logo.react:hover {
|
||||||
|
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes logo-spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
a:nth-of-type(2) .logo {
|
||||||
|
animation: logo-spin infinite 20s linear;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.read-the-docs {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
@@ -0,0 +1,358 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Search } from "lucide-react";
|
||||||
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
|
import { getSettings, applyThemeMode } 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 { 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 { DownloadProgressToast } from "@/components/DownloadProgressToast";
|
||||||
|
|
||||||
|
// Hooks
|
||||||
|
import { useDownload } from "@/hooks/useDownload";
|
||||||
|
import { useMetadata } from "@/hooks/useMetadata";
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [spotifyUrl, setSpotifyUrl] = useState("");
|
||||||
|
const [selectedTracks, setSelectedTracks] = useState<string[]>([]);
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [sortBy, setSortBy] = useState<string>("default");
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [hasUpdate, setHasUpdate] = useState(false);
|
||||||
|
|
||||||
|
const ITEMS_PER_PAGE = 50;
|
||||||
|
const CURRENT_VERSION = "5.8";
|
||||||
|
|
||||||
|
const download = useDownload();
|
||||||
|
const metadata = useMetadata();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const settings = getSettings();
|
||||||
|
applyThemeMode(settings.themeMode);
|
||||||
|
applyTheme(settings.theme);
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mediaQuery.removeEventListener("change", handleChange);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedTracks([]);
|
||||||
|
setSearchQuery("");
|
||||||
|
download.resetDownloadedTracks();
|
||||||
|
setSortBy("default");
|
||||||
|
setCurrentPage(1);
|
||||||
|
}, [metadata.metadata]);
|
||||||
|
|
||||||
|
const checkForUpdates = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
"https://raw.githubusercontent.com/afkarxyz/SpotiFLAC/refs/heads/main/version.json"
|
||||||
|
);
|
||||||
|
const data = await response.json();
|
||||||
|
const latestVersion = data.version;
|
||||||
|
|
||||||
|
if (latestVersion > CURRENT_VERSION) {
|
||||||
|
setHasUpdate(true);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to check for updates:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFetchMetadata = async () => {
|
||||||
|
const updatedUrl = await metadata.handleFetchMetadata(spotifyUrl);
|
||||||
|
if (updatedUrl) {
|
||||||
|
setSpotifyUrl(updatedUrl);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearchChange = (value: string) => {
|
||||||
|
setSearchQuery(value);
|
||||||
|
setCurrentPage(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)}
|
||||||
|
onDownload={download.handleDownloadTrack}
|
||||||
|
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}
|
||||||
|
downloadingTrack={download.downloadingTrack}
|
||||||
|
isDownloading={download.isDownloading}
|
||||||
|
bulkDownloadType={download.bulkDownloadType}
|
||||||
|
downloadProgress={download.downloadProgress}
|
||||||
|
currentDownloadInfo={download.currentDownloadInfo}
|
||||||
|
currentPage={currentPage}
|
||||||
|
itemsPerPage={ITEMS_PER_PAGE}
|
||||||
|
onSearchChange={handleSearchChange}
|
||||||
|
onSortChange={setSortBy}
|
||||||
|
onToggleTrack={toggleTrackSelection}
|
||||||
|
onToggleSelectAll={toggleSelectAll}
|
||||||
|
onDownloadTrack={download.handleDownloadTrack}
|
||||||
|
onDownloadAll={() => download.handleDownloadAll(track_list, album_info.name)}
|
||||||
|
onDownloadSelected={() =>
|
||||||
|
download.handleDownloadSelected(selectedTracks, track_list, album_info.name)
|
||||||
|
}
|
||||||
|
onStopDownload={download.handleStopDownload}
|
||||||
|
onOpenFolder={handleOpenFolder}
|
||||||
|
onPageChange={setCurrentPage}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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}
|
||||||
|
downloadingTrack={download.downloadingTrack}
|
||||||
|
isDownloading={download.isDownloading}
|
||||||
|
bulkDownloadType={download.bulkDownloadType}
|
||||||
|
downloadProgress={download.downloadProgress}
|
||||||
|
currentDownloadInfo={download.currentDownloadInfo}
|
||||||
|
currentPage={currentPage}
|
||||||
|
itemsPerPage={ITEMS_PER_PAGE}
|
||||||
|
onSearchChange={handleSearchChange}
|
||||||
|
onSortChange={setSortBy}
|
||||||
|
onToggleTrack={toggleTrackSelection}
|
||||||
|
onToggleSelectAll={toggleSelectAll}
|
||||||
|
onDownloadTrack={download.handleDownloadTrack}
|
||||||
|
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={setCurrentPage}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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}
|
||||||
|
downloadingTrack={download.downloadingTrack}
|
||||||
|
isDownloading={download.isDownloading}
|
||||||
|
bulkDownloadType={download.bulkDownloadType}
|
||||||
|
downloadProgress={download.downloadProgress}
|
||||||
|
currentDownloadInfo={download.currentDownloadInfo}
|
||||||
|
currentPage={currentPage}
|
||||||
|
itemsPerPage={ITEMS_PER_PAGE}
|
||||||
|
onSearchChange={handleSearchChange}
|
||||||
|
onSortChange={setSortBy}
|
||||||
|
onToggleTrack={toggleTrackSelection}
|
||||||
|
onToggleSelectAll={toggleSelectAll}
|
||||||
|
onDownloadTrack={download.handleDownloadTrack}
|
||||||
|
onDownloadAll={() => download.handleDownloadAll(track_list, artist_info.name, true)}
|
||||||
|
onDownloadSelected={() =>
|
||||||
|
download.handleDownloadSelected(selectedTracks, track_list, artist_info.name, true)
|
||||||
|
}
|
||||||
|
onStopDownload={download.handleStopDownload}
|
||||||
|
onOpenFolder={handleOpenFolder}
|
||||||
|
onAlbumClick={metadata.handleAlbumClick}
|
||||||
|
onPageChange={setCurrentPage}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<div className="min-h-screen bg-background flex flex-col">
|
||||||
|
<TitleBar />
|
||||||
|
<div className="flex-1 p-4 md:p-8">
|
||||||
|
<div className="max-w-4xl mx-auto space-y-6">
|
||||||
|
<Header version={CURRENT_VERSION} hasUpdate={hasUpdate} />
|
||||||
|
|
||||||
|
{/* Download Progress Toast */}
|
||||||
|
<DownloadProgressToast />
|
||||||
|
|
||||||
|
{/* Timeout Dialog */}
|
||||||
|
<Dialog
|
||||||
|
open={metadata.showTimeoutDialog}
|
||||||
|
onOpenChange={metadata.setShowTimeoutDialog}
|
||||||
|
>
|
||||||
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Fetch Settings</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Set timeout for fetching metadata. Longer timeout is recommended for artists
|
||||||
|
with large discography.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<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 mr-2" />
|
||||||
|
Fetch
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Album Fetch Dialog */}
|
||||||
|
<Dialog open={metadata.showAlbumDialog} onOpenChange={metadata.setShowAlbumDialog}>
|
||||||
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Fetch Album</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Do you want to fetch metadata for this album?
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{metadata.selectedAlbum && (
|
||||||
|
<div className="py-4">
|
||||||
|
<p className="font-medium">{metadata.selectedAlbum.name}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => metadata.setShowAlbumDialog(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={metadata.handleConfirmAlbumFetch}>
|
||||||
|
<Search className="h-4 w-4 mr-2" />
|
||||||
|
Fetch Album
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<SearchBar
|
||||||
|
url={spotifyUrl}
|
||||||
|
loading={metadata.loading}
|
||||||
|
onUrlChange={setSpotifyUrl}
|
||||||
|
onFetch={handleFetchMetadata}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{metadata.metadata && renderMetadata()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Download, FolderOpen } from "lucide-react";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import { SearchAndSort } from "./SearchAndSort";
|
||||||
|
import { TrackList } from "./TrackList";
|
||||||
|
import { DownloadProgress } from "./DownloadProgress";
|
||||||
|
import type { TrackMetadata } from "@/types/api";
|
||||||
|
|
||||||
|
interface AlbumInfoProps {
|
||||||
|
albumInfo: {
|
||||||
|
name: string;
|
||||||
|
artists: string;
|
||||||
|
images: string;
|
||||||
|
release_date: string;
|
||||||
|
total_tracks: number;
|
||||||
|
};
|
||||||
|
trackList: TrackMetadata[];
|
||||||
|
searchQuery: string;
|
||||||
|
sortBy: string;
|
||||||
|
selectedTracks: string[];
|
||||||
|
downloadedTracks: Set<string>;
|
||||||
|
downloadingTrack: string | null;
|
||||||
|
isDownloading: boolean;
|
||||||
|
bulkDownloadType: "all" | "selected" | null;
|
||||||
|
downloadProgress: number;
|
||||||
|
currentDownloadInfo: { name: string; artists: string } | null;
|
||||||
|
currentPage: number;
|
||||||
|
itemsPerPage: number;
|
||||||
|
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) => void;
|
||||||
|
onDownloadAll: () => void;
|
||||||
|
onDownloadSelected: () => void;
|
||||||
|
onStopDownload: () => void;
|
||||||
|
onOpenFolder: () => void;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AlbumInfo({
|
||||||
|
albumInfo,
|
||||||
|
trackList,
|
||||||
|
searchQuery,
|
||||||
|
sortBy,
|
||||||
|
selectedTracks,
|
||||||
|
downloadedTracks,
|
||||||
|
downloadingTrack,
|
||||||
|
isDownloading,
|
||||||
|
bulkDownloadType,
|
||||||
|
downloadProgress,
|
||||||
|
currentDownloadInfo,
|
||||||
|
currentPage,
|
||||||
|
itemsPerPage,
|
||||||
|
onSearchChange,
|
||||||
|
onSortChange,
|
||||||
|
onToggleTrack,
|
||||||
|
onToggleSelectAll,
|
||||||
|
onDownloadTrack,
|
||||||
|
onDownloadAll,
|
||||||
|
onDownloadSelected,
|
||||||
|
onStopDownload,
|
||||||
|
onOpenFolder,
|
||||||
|
onPageChange,
|
||||||
|
}: 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">
|
||||||
|
<span className="font-medium">{albumInfo.artists}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{albumInfo.release_date}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{albumInfo.total_tracks} songs</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={onDownloadAll}
|
||||||
|
className="gap-2"
|
||||||
|
disabled={isDownloading}
|
||||||
|
>
|
||||||
|
{isDownloading && bulkDownloadType === "all" ? (
|
||||||
|
<Spinner />
|
||||||
|
) : (
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Download All
|
||||||
|
</Button>
|
||||||
|
{selectedTracks.length > 0 && (
|
||||||
|
<Button
|
||||||
|
onClick={onDownloadSelected}
|
||||||
|
variant="secondary"
|
||||||
|
className="gap-2"
|
||||||
|
disabled={isDownloading}
|
||||||
|
>
|
||||||
|
{isDownloading && bulkDownloadType === "selected" ? (
|
||||||
|
<Spinner />
|
||||||
|
) : (
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Download Selected ({selectedTracks.length})
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{downloadedTracks.size > 0 && (
|
||||||
|
<Button onClick={onOpenFolder} variant="outline" className="gap-1.5">
|
||||||
|
<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}
|
||||||
|
downloadingTrack={downloadingTrack}
|
||||||
|
isDownloading={isDownloading}
|
||||||
|
currentPage={currentPage}
|
||||||
|
itemsPerPage={itemsPerPage}
|
||||||
|
showCheckboxes={true}
|
||||||
|
hideAlbumColumn={true}
|
||||||
|
onToggleTrack={onToggleTrack}
|
||||||
|
onToggleSelectAll={onToggleSelectAll}
|
||||||
|
onDownloadTrack={onDownloadTrack}
|
||||||
|
onPageChange={onPageChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Download, FolderOpen } from "lucide-react";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import { SearchAndSort } from "./SearchAndSort";
|
||||||
|
import { TrackList } from "./TrackList";
|
||||||
|
import { DownloadProgress } from "./DownloadProgress";
|
||||||
|
import type { TrackMetadata } 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>;
|
||||||
|
downloadingTrack: string | null;
|
||||||
|
isDownloading: boolean;
|
||||||
|
bulkDownloadType: "all" | "selected" | null;
|
||||||
|
downloadProgress: number;
|
||||||
|
currentDownloadInfo: { name: string; artists: string } | null;
|
||||||
|
currentPage: number;
|
||||||
|
itemsPerPage: number;
|
||||||
|
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) => void;
|
||||||
|
onDownloadAll: () => void;
|
||||||
|
onDownloadSelected: () => void;
|
||||||
|
onStopDownload: () => void;
|
||||||
|
onOpenFolder: () => void;
|
||||||
|
onAlbumClick: (album: { id: string; name: string; external_urls: string }) => void;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ArtistInfo({
|
||||||
|
artistInfo,
|
||||||
|
albumList,
|
||||||
|
trackList,
|
||||||
|
searchQuery,
|
||||||
|
sortBy,
|
||||||
|
selectedTracks,
|
||||||
|
downloadedTracks,
|
||||||
|
downloadingTrack,
|
||||||
|
isDownloading,
|
||||||
|
bulkDownloadType,
|
||||||
|
downloadProgress,
|
||||||
|
currentDownloadInfo,
|
||||||
|
currentPage,
|
||||||
|
itemsPerPage,
|
||||||
|
onSearchChange,
|
||||||
|
onSortChange,
|
||||||
|
onToggleTrack,
|
||||||
|
onToggleSelectAll,
|
||||||
|
onDownloadTrack,
|
||||||
|
onDownloadAll,
|
||||||
|
onDownloadSelected,
|
||||||
|
onStopDownload,
|
||||||
|
onOpenFolder,
|
||||||
|
onAlbumClick,
|
||||||
|
onPageChange,
|
||||||
|
}: 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">
|
||||||
|
<span>{artistInfo.followers.toLocaleString()} followers</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">
|
||||||
|
<h3 className="text-2xl font-bold">Popular Tracks</h3>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={onDownloadAll}
|
||||||
|
size="sm"
|
||||||
|
className="gap-2"
|
||||||
|
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"
|
||||||
|
className="gap-2"
|
||||||
|
disabled={isDownloading}
|
||||||
|
>
|
||||||
|
{isDownloading && bulkDownloadType === "selected" ? (
|
||||||
|
<Spinner />
|
||||||
|
) : (
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Download Selected ({selectedTracks.length})
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{downloadedTracks.size > 0 && (
|
||||||
|
<Button onClick={onOpenFolder} size="sm" variant="outline" className="gap-1.5">
|
||||||
|
<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}
|
||||||
|
downloadingTrack={downloadingTrack}
|
||||||
|
isDownloading={isDownloading}
|
||||||
|
currentPage={currentPage}
|
||||||
|
itemsPerPage={itemsPerPage}
|
||||||
|
showCheckboxes={true}
|
||||||
|
hideAlbumColumn={false}
|
||||||
|
onToggleTrack={onToggleTrack}
|
||||||
|
onToggleSelectAll={onToggleSelectAll}
|
||||||
|
onDownloadTrack={onDownloadTrack}
|
||||||
|
onPageChange={onPageChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
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) {
|
||||||
|
return (
|
||||||
|
<div className="w-full space-y-2 mt-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Progress value={progress} 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">
|
||||||
|
{progress}% -{" "}
|
||||||
|
{currentTrack
|
||||||
|
? `${currentTrack.name} - ${currentTrack.artists}`
|
||||||
|
: "Preparing download..."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { useDownloadProgress } from "@/hooks/useDownloadProgress";
|
||||||
|
import { Download } from "lucide-react";
|
||||||
|
|
||||||
|
export function DownloadProgressToast() {
|
||||||
|
const progress = useDownloadProgress();
|
||||||
|
|
||||||
|
if (!progress.is_downloading) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-4 left-4 z-50 animate-in slide-in-from-bottom-5 data-[state=closed]:animate-out data-[state=closed]:slide-out-to-bottom-5">
|
||||||
|
<div className="bg-background border rounded-lg shadow-lg p-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Download className="h-4 w-4 text-primary animate-bounce" />
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{progress.mb_downloaded.toFixed(2)} MB
|
||||||
|
</p>
|
||||||
|
{progress.speed_mbps > 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{progress.speed_mbps.toFixed(2)} MB/s
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Settings } from "@/components/Settings";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
|
||||||
|
interface HeaderProps {
|
||||||
|
version: string;
|
||||||
|
hasUpdate: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Header({ version, hasUpdate }: 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">
|
||||||
|
<Badge variant="default" asChild>
|
||||||
|
<a
|
||||||
|
href="https://github.com/afkarxyz/SpotiFLAC/releases"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="cursor-pointer hover:opacity-80 transition-opacity"
|
||||||
|
>
|
||||||
|
v{version}
|
||||||
|
</a>
|
||||||
|
</Badge>
|
||||||
|
{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, Deezer & Qobuz — no account required.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="absolute right-0 top-0 flex gap-2">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button variant="outline" size="icon" asChild>
|
||||||
|
<a
|
||||||
|
href="https://github.com/afkarxyz/SpotiFLAC/issues"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
aria-label="GitHub Issues"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="currentColor">
|
||||||
|
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="left">
|
||||||
|
<p>Report bug or request feature</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<Settings />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Download, FolderOpen } from "lucide-react";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import { SearchAndSort } from "./SearchAndSort";
|
||||||
|
import { TrackList } from "./TrackList";
|
||||||
|
import { DownloadProgress } from "./DownloadProgress";
|
||||||
|
import type { TrackMetadata } 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>;
|
||||||
|
downloadingTrack: string | null;
|
||||||
|
isDownloading: boolean;
|
||||||
|
bulkDownloadType: "all" | "selected" | null;
|
||||||
|
downloadProgress: number;
|
||||||
|
currentDownloadInfo: { name: string; artists: string } | null;
|
||||||
|
currentPage: number;
|
||||||
|
itemsPerPage: number;
|
||||||
|
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) => void;
|
||||||
|
onDownloadAll: () => void;
|
||||||
|
onDownloadSelected: () => void;
|
||||||
|
onStopDownload: () => void;
|
||||||
|
onOpenFolder: () => void;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlaylistInfo({
|
||||||
|
playlistInfo,
|
||||||
|
trackList,
|
||||||
|
searchQuery,
|
||||||
|
sortBy,
|
||||||
|
selectedTracks,
|
||||||
|
downloadedTracks,
|
||||||
|
downloadingTrack,
|
||||||
|
isDownloading,
|
||||||
|
bulkDownloadType,
|
||||||
|
downloadProgress,
|
||||||
|
currentDownloadInfo,
|
||||||
|
currentPage,
|
||||||
|
itemsPerPage,
|
||||||
|
onSearchChange,
|
||||||
|
onSortChange,
|
||||||
|
onToggleTrack,
|
||||||
|
onToggleSelectAll,
|
||||||
|
onDownloadTrack,
|
||||||
|
onDownloadAll,
|
||||||
|
onDownloadSelected,
|
||||||
|
onStopDownload,
|
||||||
|
onOpenFolder,
|
||||||
|
onPageChange,
|
||||||
|
}: 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} songs</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{playlistInfo.followers.total.toLocaleString()} followers</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={onDownloadAll}
|
||||||
|
className="gap-2"
|
||||||
|
disabled={isDownloading}
|
||||||
|
>
|
||||||
|
{isDownloading && bulkDownloadType === "all" ? (
|
||||||
|
<Spinner />
|
||||||
|
) : (
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Download All
|
||||||
|
</Button>
|
||||||
|
{selectedTracks.length > 0 && (
|
||||||
|
<Button
|
||||||
|
onClick={onDownloadSelected}
|
||||||
|
variant="secondary"
|
||||||
|
className="gap-2"
|
||||||
|
disabled={isDownloading}
|
||||||
|
>
|
||||||
|
{isDownloading && bulkDownloadType === "selected" ? (
|
||||||
|
<Spinner />
|
||||||
|
) : (
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Download Selected ({selectedTracks.length})
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{downloadedTracks.size > 0 && (
|
||||||
|
<Button onClick={onOpenFolder} variant="outline" className="gap-1.5">
|
||||||
|
<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}
|
||||||
|
downloadingTrack={downloadingTrack}
|
||||||
|
isDownloading={isDownloading}
|
||||||
|
currentPage={currentPage}
|
||||||
|
itemsPerPage={itemsPerPage}
|
||||||
|
showCheckboxes={true}
|
||||||
|
hideAlbumColumn={false}
|
||||||
|
onToggleTrack={onToggleTrack}
|
||||||
|
onToggleSelectAll={onToggleSelectAll}
|
||||||
|
onDownloadTrack={onDownloadTrack}
|
||||||
|
onPageChange={onPageChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Search, ArrowUpDown } 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"
|
||||||
|
/>
|
||||||
|
</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,74 @@
|
|||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Search, Info, XCircle } from "lucide-react";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
|
||||||
|
interface SearchBarProps {
|
||||||
|
url: string;
|
||||||
|
loading: boolean;
|
||||||
|
onUrlChange: (url: string) => void;
|
||||||
|
onFetch: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchBar({ url, loading, onUrlChange, onFetch }: SearchBarProps) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="px-6 py-6 space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label htmlFor="spotify-url">Spotify URL</Label>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Info className="h-4 w-4 text-muted-foreground cursor-help" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right">
|
||||||
|
<p>Supports track, album, playlist, and artist URLs</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Input
|
||||||
|
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"
|
||||||
|
onClick={() => onUrlChange("")}
|
||||||
|
>
|
||||||
|
<XCircle className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button onClick={onFetch} disabled={loading}>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<Spinner />
|
||||||
|
Fetching...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Search className="h-4 w-4" />
|
||||||
|
Fetch
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,374 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import { Settings as SettingsIcon, FolderOpen, Save, RotateCcw, Info } from "lucide-react";
|
||||||
|
import { getSettings, getSettingsWithDefaults, saveSettings, resetToDefaultSettings, applyThemeMode, type Settings as SettingsType } from "@/lib/settings";
|
||||||
|
import { themes, applyTheme } from "@/lib/themes";
|
||||||
|
import { SelectFolder } from "../../wailsjs/go/main/App";
|
||||||
|
|
||||||
|
// Service Icons
|
||||||
|
const TidalIcon = () => (
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor" className="inline-block w-[1.1em] h-[1.1em] mr-2">
|
||||||
|
<path d="M4.022 4.5 0 8.516l3.997 3.99 3.997-3.984L4.022 4.5Zm7.956 0L7.994 8.522l4.003 3.984L16 8.484 11.978 4.5Zm8.007 0L24 8.528l-4.003 3.978L16 8.484 19.985 4.5Z"></path>
|
||||||
|
<path d="m8.012 16.534 3.991 3.966L16 16.49l-4.003-3.984-3.985 4.028Z"></path>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const DeezerIcon = () => (
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor" className="inline-block w-[1.1em] h-[1.1em] mr-2">
|
||||||
|
<path d="M18.77 5.55c.19-1.07.46-1.75.76-1.75.56 0 1.02 2.34 1.02 5.23 0 2.89-.46 5.23-1.02 5.23-.23 0-.44-.4-.61-1.06-.27 2.43-.83 4.11-1.48 4.11-.5 0-.96-1-1.26-2.6-.2 3.03-.73 5.17-1.33 5.17-.39 0-.73-.85-.99-2.23-.31 2.85-1.03 4.85-1.86 4.85-.83 0-1.55-2-1.86-4.85-.25 1.38-.6 2.23-.99 2.23-.6 0-1.12-2.14-1.33-5.16-.3 1.58-.75 2.6-1.26 2.6-.65 0-1.2-1.68-1.48-4.12-.17.66-.38 1.06-.61 1.06-.56 0-1.02-2.34-1.02-5.23 0-2.89.46-5.23 1.02-5.23.3 0 .57.68.76 1.75C5.53 3.7 6 2.5 6.56 2.5c.66 0 1.22 1.7 1.49 4.17.26-1.8.66-2.94 1.1-2.94.63 0 1.16 2.25 1.36 5.4.36-1.62.9-2.63 1.5-2.63.58 0 1.12 1.01 1.49 2.62.2-3.14.72-5.4 1.35-5.4.44 0 .84 1.15 1.1 2.95.27-2.47.84-4.17 1.49-4.17.55 0 1.03 1.2 1.33 3.05ZM2 8.52c0-1.3.26-2.34.58-2.34.32 0 .57 1.05.57 2.34 0 1.29-.25 2.34-.57 2.34-.32 0-.58-1.05-.58-2.34Zm18.85 0c0-1.3.25-2.34.57-2.34.32 0 .58 1.05.58 2.34 0 1.29-.26 2.34-.58 2.34-.32 0-.57-1.05-.57-2.34Z"></path>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const QobuzIcon = () => (
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor" className="inline-block w-[1.1em] h-[1.1em] mr-2">
|
||||||
|
<path d="M21.744 9.815C19.836 1.261 8.393-1 3.55 6.64-.618 13.214 4 22 11.988 22c2.387 0 4.63-.83 6.394-2.304l2.252 2.252 1.224-1.224-2.252-2.253c1.983-2.407 2.823-5.586 2.138-8.656Zm-3.508 7.297L16.4 15.275c-.786-.787-2.017.432-1.224 1.225L17 18.326C10.29 23.656.5 16 5.16 7.667c3.502-6.264 13.172-4.348 14.707 2.574.529 2.385-.06 4.987-1.63 6.87Z"></path>
|
||||||
|
<path d="M13.4 8.684a3.59 3.59 0 0 0-4.712 1.9 3.59 3.59 0 0 0 1.9 4.712 3.594 3.594 0 0 0 4.711-1.89 3.598 3.598 0 0 0-1.9-4.722Zm-.737 3.591a.727.727 0 0 1-.965.384.727.727 0 0 1-.384-.965.727.727 0 0 1 .965-.384.73.73 0 0 1 .384.965Z"></path>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export function Settings() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [savedSettings, setSavedSettings] = useState<SettingsType>(getSettings());
|
||||||
|
const [tempSettings, setTempSettings] = useState<SettingsType>(savedSettings);
|
||||||
|
const [, setIsLoadingDefaults] = useState(false);
|
||||||
|
|
||||||
|
// Apply saved settings
|
||||||
|
useEffect(() => {
|
||||||
|
applyThemeMode(savedSettings.themeMode);
|
||||||
|
applyTheme(savedSettings.theme);
|
||||||
|
|
||||||
|
// Setup listener for system theme changes
|
||||||
|
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||||
|
const handleChange = () => {
|
||||||
|
if (savedSettings.themeMode === "auto") {
|
||||||
|
applyThemeMode("auto");
|
||||||
|
applyTheme(savedSettings.theme);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
mediaQuery.addEventListener("change", handleChange);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mediaQuery.removeEventListener("change", handleChange);
|
||||||
|
};
|
||||||
|
}, [savedSettings.themeMode, savedSettings.theme]);
|
||||||
|
|
||||||
|
// Apply temp settings for preview when dialog is open
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
applyThemeMode(tempSettings.themeMode);
|
||||||
|
applyTheme(tempSettings.theme);
|
||||||
|
|
||||||
|
// Setup listener for system theme changes during preview
|
||||||
|
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||||
|
const handleChange = () => {
|
||||||
|
if (tempSettings.themeMode === "auto") {
|
||||||
|
applyThemeMode("auto");
|
||||||
|
applyTheme(tempSettings.theme);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
mediaQuery.addEventListener("change", handleChange);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mediaQuery.removeEventListener("change", handleChange);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [open, tempSettings.themeMode, tempSettings.theme]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Load settings with defaults from backend on mount
|
||||||
|
const loadDefaults = async () => {
|
||||||
|
if (!savedSettings.downloadPath) {
|
||||||
|
setIsLoadingDefaults(true);
|
||||||
|
const settingsWithDefaults = await getSettingsWithDefaults();
|
||||||
|
setSavedSettings(settingsWithDefaults);
|
||||||
|
setTempSettings(settingsWithDefaults);
|
||||||
|
setIsLoadingDefaults(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadDefaults();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Reset temp settings when dialog opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setTempSettings(savedSettings);
|
||||||
|
}
|
||||||
|
}, [open, savedSettings]);
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
saveSettings(tempSettings);
|
||||||
|
setSavedSettings(tempSettings);
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = async () => {
|
||||||
|
const defaultSettings = await resetToDefaultSettings();
|
||||||
|
setTempSettings(defaultSettings);
|
||||||
|
setSavedSettings(defaultSettings);
|
||||||
|
|
||||||
|
// Apply default theme mode and theme
|
||||||
|
applyThemeMode(defaultSettings.themeMode);
|
||||||
|
applyTheme(defaultSettings.theme);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
// Revert to saved settings
|
||||||
|
applyThemeMode(savedSettings.themeMode);
|
||||||
|
applyTheme(savedSettings.theme);
|
||||||
|
|
||||||
|
setTempSettings(savedSettings);
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenChange = (newOpen: boolean) => {
|
||||||
|
if (!newOpen) {
|
||||||
|
// Dialog is closing, revert to saved settings
|
||||||
|
applyThemeMode(savedSettings.themeMode);
|
||||||
|
applyTheme(savedSettings.theme);
|
||||||
|
setTempSettings(savedSettings);
|
||||||
|
}
|
||||||
|
setOpen(newOpen);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownloadPathChange = (value: string) => {
|
||||||
|
setTempSettings((prev) => ({ ...prev, downloadPath: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownloaderChange = (value: "auto" | "deezer" | "tidal" | "qobuz") => {
|
||||||
|
setTempSettings((prev) => ({ ...prev, downloader: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleThemeChange = (value: string) => {
|
||||||
|
setTempSettings((prev) => ({ ...prev, theme: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleThemeModeChange = (value: "auto" | "light" | "dark") => {
|
||||||
|
setTempSettings((prev) => ({ ...prev, themeMode: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBrowseFolder = async () => {
|
||||||
|
try {
|
||||||
|
// Call backend to open folder selection dialog
|
||||||
|
const selectedPath = await SelectFolder(tempSettings.downloadPath || "");
|
||||||
|
console.log("Selected path:", selectedPath);
|
||||||
|
|
||||||
|
if (selectedPath && selectedPath.trim() !== "") {
|
||||||
|
setTempSettings((prev) => ({ ...prev, downloadPath: selectedPath }));
|
||||||
|
} else {
|
||||||
|
console.log("No folder selected or user cancelled");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error selecting folder:", error);
|
||||||
|
alert(`Error selecting folder: ${error}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="outline" size="icon">
|
||||||
|
<SettingsIcon className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-[700px] flex flex-col" aria-describedby={undefined}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Settings</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid grid-cols-2 gap-6 py-2">
|
||||||
|
{/* Left Column */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Download Path */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="download-path">Download Path</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
id="download-path"
|
||||||
|
value={tempSettings.downloadPath}
|
||||||
|
onChange={(e) => handleDownloadPathChange(e.target.value)}
|
||||||
|
placeholder="C:\Users\YourUsername\Music"
|
||||||
|
/>
|
||||||
|
<Button type="button" onClick={handleBrowseFolder} className="gap-1.5">
|
||||||
|
<FolderOpen className="h-4 w-4" />
|
||||||
|
Browse
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Source Selection */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="downloader">Source</Label>
|
||||||
|
<Select
|
||||||
|
value={tempSettings.downloader}
|
||||||
|
onValueChange={handleDownloaderChange}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="downloader">
|
||||||
|
<SelectValue placeholder="Select a source" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="auto">Auto</SelectItem>
|
||||||
|
<SelectItem value="tidal">
|
||||||
|
<span className="flex items-center">
|
||||||
|
<TidalIcon />
|
||||||
|
Tidal
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="deezer">
|
||||||
|
<span className="flex items-center">
|
||||||
|
<DeezerIcon />
|
||||||
|
Deezer
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="qobuz">
|
||||||
|
<span className="flex items-center">
|
||||||
|
<QobuzIcon />
|
||||||
|
Qobuz
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Theme Mode Selection */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="theme-mode">Theme</Label>
|
||||||
|
<Select value={tempSettings.themeMode} onValueChange={handleThemeModeChange}>
|
||||||
|
<SelectTrigger id="theme-mode">
|
||||||
|
<SelectValue placeholder="Select theme mode" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="auto">Auto</SelectItem>
|
||||||
|
<SelectItem value="light">Light</SelectItem>
|
||||||
|
<SelectItem value="dark">Dark</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Theme Color Selection */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="theme">Theme Color</Label>
|
||||||
|
<Select value={tempSettings.theme} onValueChange={handleThemeChange}>
|
||||||
|
<SelectTrigger id="theme">
|
||||||
|
<SelectValue placeholder="Select a theme" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{themes.map((theme) => (
|
||||||
|
<SelectItem key={theme.name} value={theme.name}>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="w-3 h-3 rounded-full"
|
||||||
|
style={{ backgroundColor: theme.cssVars.light.primary }}
|
||||||
|
/>
|
||||||
|
{theme.label}
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Column */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Filename Format */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm">Filename Format</Label>
|
||||||
|
<RadioGroup
|
||||||
|
value={tempSettings.filenameFormat}
|
||||||
|
onValueChange={(value) => setTempSettings(prev => ({ ...prev, filenameFormat: value as any }))}
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value="title-artist" id="title-artist" />
|
||||||
|
<Label htmlFor="title-artist" className="cursor-pointer font-normal text-sm">Title - Artist</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value="artist-title" id="artist-title" />
|
||||||
|
<Label htmlFor="artist-title" className="cursor-pointer font-normal text-sm">Artist - Title</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value="title" id="title" />
|
||||||
|
<Label htmlFor="title" className="cursor-pointer font-normal text-sm">Title</Label>
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t" />
|
||||||
|
|
||||||
|
{/* Folder Settings */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="font-medium text-sm">Folder Settings</h3>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="track-number"
|
||||||
|
checked={tempSettings.trackNumber}
|
||||||
|
onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, trackNumber: checked as boolean }))}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="track-number" className="cursor-pointer text-sm">Track Number</Label>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top">
|
||||||
|
<p className="text-xs whitespace-nowrap">Adds track numbers based on the order in the album, playlist, or discography list</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="artist-subfolder"
|
||||||
|
checked={tempSettings.artistSubfolder}
|
||||||
|
onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, artistSubfolder: checked as boolean }))}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="artist-subfolder" className="cursor-pointer text-sm">Artist Subfolder (Playlist only)</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="album-subfolder"
|
||||||
|
checked={tempSettings.albumSubfolder}
|
||||||
|
onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, albumSubfolder: checked as boolean }))}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="album-subfolder" className="cursor-pointer text-sm">Album Subfolder (Playlist & Discography)</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter className="gap-2 sm:justify-between">
|
||||||
|
<Button variant="outline" onClick={handleReset} size="sm" className="gap-1.5">
|
||||||
|
<RotateCcw className="h-3.5 w-3.5" />
|
||||||
|
Reset to Default
|
||||||
|
</Button>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={handleCancel} size="sm">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave} size="sm" className="gap-1.5">
|
||||||
|
<Save className="h-3.5 w-3.5" />
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { X, Minus, Square } from "lucide-react";
|
||||||
|
import { WindowMinimise, WindowToggleMaximise, Quit } from "../../wailsjs/runtime/runtime";
|
||||||
|
|
||||||
|
export function TitleBar() {
|
||||||
|
const [hoveredButton, setHoveredButton] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleMinimize = () => {
|
||||||
|
WindowMinimise();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMaximize = () => {
|
||||||
|
WindowToggleMaximise();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
Quit();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute top-4 left-4 z-50 flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
onMouseEnter={() => setHoveredButton("close")}
|
||||||
|
onMouseLeave={() => setHoveredButton(null)}
|
||||||
|
className="w-3 h-3 rounded-full bg-red-500 hover:bg-red-600 transition-colors flex items-center justify-center"
|
||||||
|
style={{ "--wails-draggable": "no-drag" } as React.CSSProperties}
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
{hoveredButton === "close" && (
|
||||||
|
<X className="w-2 h-2 text-red-900" strokeWidth={3} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleMinimize}
|
||||||
|
onMouseEnter={() => setHoveredButton("minimize")}
|
||||||
|
onMouseLeave={() => setHoveredButton(null)}
|
||||||
|
className="w-3 h-3 rounded-full bg-yellow-500 hover:bg-yellow-600 transition-colors flex items-center justify-center"
|
||||||
|
style={{ "--wails-draggable": "no-drag" } as React.CSSProperties}
|
||||||
|
aria-label="Minimize"
|
||||||
|
>
|
||||||
|
{hoveredButton === "minimize" && (
|
||||||
|
<Minus className="w-2 h-2 text-yellow-900" strokeWidth={3} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleMaximize}
|
||||||
|
onMouseEnter={() => setHoveredButton("maximize")}
|
||||||
|
onMouseLeave={() => setHoveredButton(null)}
|
||||||
|
className="w-3 h-3 rounded-full bg-green-500 hover:bg-green-600 transition-colors flex items-center justify-center"
|
||||||
|
style={{ "--wails-draggable": "no-drag" } as React.CSSProperties}
|
||||||
|
aria-label="Maximize"
|
||||||
|
>
|
||||||
|
{hoveredButton === "maximize" && (
|
||||||
|
<Square className="w-1.5 h-1.5 text-green-900" strokeWidth={3} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Download, FolderOpen } from "lucide-react";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import type { TrackMetadata } from "@/types/api";
|
||||||
|
|
||||||
|
interface TrackInfoProps {
|
||||||
|
track: TrackMetadata & { album_name: string; release_date: string };
|
||||||
|
isDownloading: boolean;
|
||||||
|
downloadingTrack: string | null;
|
||||||
|
isDownloaded: boolean;
|
||||||
|
onDownload: (isrc: string, name: string, artists: string) => void;
|
||||||
|
onOpenFolder: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TrackInfo({
|
||||||
|
track,
|
||||||
|
isDownloading,
|
||||||
|
downloadingTrack,
|
||||||
|
isDownloaded,
|
||||||
|
onDownload,
|
||||||
|
onOpenFolder,
|
||||||
|
}: TrackInfoProps) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="px-6">
|
||||||
|
<div className="flex gap-6 items-start">
|
||||||
|
{track.images && (
|
||||||
|
<img
|
||||||
|
src={track.images}
|
||||||
|
alt={track.name}
|
||||||
|
className="w-48 h-48 rounded-md shadow-lg object-cover shrink-0"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 space-y-4 min-w-0">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h1 className="text-3xl font-bold wrap-break-word">{track.name}</h1>
|
||||||
|
<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">
|
||||||
|
<Button
|
||||||
|
onClick={() => onDownload(track.isrc, track.name, track.artists)}
|
||||||
|
className="gap-1.5"
|
||||||
|
disabled={isDownloading || downloadingTrack === track.isrc}
|
||||||
|
>
|
||||||
|
{downloadingTrack === track.isrc ? (
|
||||||
|
<Spinner />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
Download
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
{isDownloaded && (
|
||||||
|
<Button onClick={onOpenFolder} variant="outline" className="gap-1.5">
|
||||||
|
<FolderOpen className="h-4 w-4" />
|
||||||
|
Open Folder
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Download, CheckCircle } from "lucide-react";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import {
|
||||||
|
Pagination,
|
||||||
|
PaginationContent,
|
||||||
|
PaginationItem,
|
||||||
|
PaginationLink,
|
||||||
|
PaginationNext,
|
||||||
|
PaginationPrevious,
|
||||||
|
} from "@/components/ui/pagination";
|
||||||
|
import type { TrackMetadata } from "@/types/api";
|
||||||
|
|
||||||
|
interface TrackListProps {
|
||||||
|
tracks: TrackMetadata[];
|
||||||
|
searchQuery: string;
|
||||||
|
sortBy: string;
|
||||||
|
selectedTracks: string[];
|
||||||
|
downloadedTracks: Set<string>;
|
||||||
|
downloadingTrack: string | null;
|
||||||
|
isDownloading: boolean;
|
||||||
|
currentPage: number;
|
||||||
|
itemsPerPage: number;
|
||||||
|
showCheckboxes?: boolean;
|
||||||
|
hideAlbumColumn?: boolean;
|
||||||
|
onToggleTrack: (isrc: string) => void;
|
||||||
|
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
|
||||||
|
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string) => void;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TrackList({
|
||||||
|
tracks,
|
||||||
|
searchQuery,
|
||||||
|
sortBy,
|
||||||
|
selectedTracks,
|
||||||
|
downloadedTracks,
|
||||||
|
downloadingTrack,
|
||||||
|
isDownloading,
|
||||||
|
currentPage,
|
||||||
|
itemsPerPage,
|
||||||
|
showCheckboxes = false,
|
||||||
|
hideAlbumColumn = false,
|
||||||
|
onToggleTrack,
|
||||||
|
onToggleSelectAll,
|
||||||
|
onDownloadTrack,
|
||||||
|
onPageChange,
|
||||||
|
}: 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">
|
||||||
|
<span className="font-medium">{track.name}</span>
|
||||||
|
{downloadedTracks.has(track.isrc) && (
|
||||||
|
<CheckCircle className="h-4 w-4 text-green-500 shrink-0" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{track.artists}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
{!hideAlbumColumn && (
|
||||||
|
<td className="p-4 align-middle text-sm text-muted-foreground hidden md:table-cell">
|
||||||
|
{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">
|
||||||
|
{track.isrc && (
|
||||||
|
<Button
|
||||||
|
onClick={() =>
|
||||||
|
onDownloadTrack(track.isrc, track.name, track.artists, track.album_name)
|
||||||
|
}
|
||||||
|
size="sm"
|
||||||
|
className="gap-1.5"
|
||||||
|
disabled={isDownloading || downloadingTrack === track.isrc}
|
||||||
|
>
|
||||||
|
{downloadingTrack === track.isrc ? (
|
||||||
|
<Spinner />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
Download
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</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,66 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const alertVariants = cva(
|
||||||
|
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-card text-card-foreground",
|
||||||
|
destructive:
|
||||||
|
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Alert({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert"
|
||||||
|
role="alert"
|
||||||
|
className={cn(alertVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-title"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-description"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Alert, AlertTitle, AlertDescription }
|
||||||
@@ -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,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 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,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 sm:max-w-lg",
|
||||||
|
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,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,45 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
|
||||||
|
import { CircleIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function RadioGroup({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<RadioGroupPrimitive.Root
|
||||||
|
data-slot="radio-group"
|
||||||
|
className={cn("grid gap-3", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RadioGroupItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<RadioGroupPrimitive.Item
|
||||||
|
data-slot="radio-group-item"
|
||||||
|
className={cn(
|
||||||
|
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<RadioGroupPrimitive.Indicator
|
||||||
|
data-slot="radio-group-indicator"
|
||||||
|
className="relative flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
|
||||||
|
</RadioGroupPrimitive.Indicator>
|
||||||
|
</RadioGroupPrimitive.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { RadioGroup, RadioGroupItem }
|
||||||
@@ -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,46 @@
|
|||||||
|
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)",
|
||||||
|
} 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,66 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Tabs({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Root
|
||||||
|
data-slot="tabs"
|
||||||
|
className={cn("flex flex-col gap-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsList({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
data-slot="tabs-list"
|
||||||
|
className={cn(
|
||||||
|
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsTrigger({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Trigger
|
||||||
|
data-slot="tabs-trigger"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Content
|
||||||
|
data-slot="tabs-content"
|
||||||
|
className={cn("flex-1 outline-none", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
data-slot="textarea"
|
||||||
|
className={cn(
|
||||||
|
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Textarea }
|
||||||
@@ -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,328 @@
|
|||||||
|
import { useState, useRef } from "react";
|
||||||
|
import { downloadTrack } from "@/lib/api";
|
||||||
|
import { getSettings } from "@/lib/settings";
|
||||||
|
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||||
|
import { joinPath, sanitizePath } from "@/lib/utils";
|
||||||
|
import type { TrackMetadata } from "@/types/api";
|
||||||
|
|
||||||
|
export function useDownload() {
|
||||||
|
const [downloadProgress, setDownloadProgress] = useState<number>(0);
|
||||||
|
const [isDownloading, setIsDownloading] = useState(false);
|
||||||
|
const [downloadingTrack, setDownloadingTrack] = useState<string | null>(null);
|
||||||
|
const [bulkDownloadType, setBulkDownloadType] = useState<"all" | "selected" | null>(null);
|
||||||
|
const [downloadedTracks, setDownloadedTracks] = useState<Set<string>>(new Set());
|
||||||
|
const [currentDownloadInfo, setCurrentDownloadInfo] = useState<{
|
||||||
|
name: string;
|
||||||
|
artists: string;
|
||||||
|
} | null>(null);
|
||||||
|
const shouldStopDownloadRef = useRef(false);
|
||||||
|
|
||||||
|
const downloadWithAutoFallback = async (
|
||||||
|
isrc: string,
|
||||||
|
settings: any,
|
||||||
|
trackName?: string,
|
||||||
|
artistName?: string,
|
||||||
|
albumName?: string,
|
||||||
|
playlistName?: string,
|
||||||
|
isArtistDiscography?: boolean,
|
||||||
|
position?: number
|
||||||
|
) => {
|
||||||
|
let service = settings.downloader;
|
||||||
|
|
||||||
|
const query = trackName && artistName ? `${trackName} ${artistName}` : undefined;
|
||||||
|
const os = settings.operatingSystem;
|
||||||
|
|
||||||
|
let outputDir = settings.downloadPath;
|
||||||
|
|
||||||
|
if (playlistName) {
|
||||||
|
outputDir = joinPath(os, outputDir, sanitizePath(playlistName, os));
|
||||||
|
|
||||||
|
if (isArtistDiscography) {
|
||||||
|
if (settings.albumSubfolder && albumName) {
|
||||||
|
outputDir = joinPath(os, outputDir, sanitizePath(albumName, os));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (settings.artistSubfolder && artistName) {
|
||||||
|
outputDir = joinPath(os, outputDir, sanitizePath(artistName, os));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings.albumSubfolder && albumName) {
|
||||||
|
outputDir = joinPath(os, outputDir, sanitizePath(albumName, os));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (service === "auto") {
|
||||||
|
// Try Tidal first
|
||||||
|
try {
|
||||||
|
const tidalResponse = await downloadTrack({
|
||||||
|
isrc,
|
||||||
|
service: "tidal",
|
||||||
|
query,
|
||||||
|
track_name: trackName,
|
||||||
|
artist_name: artistName,
|
||||||
|
album_name: albumName,
|
||||||
|
output_dir: outputDir,
|
||||||
|
filename_format: settings.filenameFormat,
|
||||||
|
track_number: settings.trackNumber,
|
||||||
|
position,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (tidalResponse.success) {
|
||||||
|
return tidalResponse;
|
||||||
|
}
|
||||||
|
} catch (tidalErr) {
|
||||||
|
// Tidal failed, continue to Deezer
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try Deezer second
|
||||||
|
try {
|
||||||
|
const deezerResponse = await downloadTrack({
|
||||||
|
isrc,
|
||||||
|
service: "deezer",
|
||||||
|
query,
|
||||||
|
track_name: trackName,
|
||||||
|
artist_name: artistName,
|
||||||
|
album_name: albumName,
|
||||||
|
output_dir: outputDir,
|
||||||
|
filename_format: settings.filenameFormat,
|
||||||
|
track_number: settings.trackNumber,
|
||||||
|
position,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (deezerResponse.success) {
|
||||||
|
return deezerResponse;
|
||||||
|
}
|
||||||
|
} catch (deezerErr) {
|
||||||
|
// Deezer failed, continue to Qobuz
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try Qobuz as last fallback
|
||||||
|
service = "qobuz";
|
||||||
|
}
|
||||||
|
|
||||||
|
return await downloadTrack({
|
||||||
|
isrc,
|
||||||
|
service: service as "deezer" | "tidal" | "qobuz",
|
||||||
|
query,
|
||||||
|
track_name: trackName,
|
||||||
|
artist_name: artistName,
|
||||||
|
album_name: albumName,
|
||||||
|
output_dir: outputDir,
|
||||||
|
filename_format: settings.filenameFormat,
|
||||||
|
track_number: settings.trackNumber,
|
||||||
|
position,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownloadTrack = async (
|
||||||
|
isrc: string,
|
||||||
|
trackName?: string,
|
||||||
|
artistName?: string,
|
||||||
|
albumName?: string
|
||||||
|
) => {
|
||||||
|
if (!isrc) {
|
||||||
|
toast.error("No ISRC found for this track");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = getSettings();
|
||||||
|
setDownloadingTrack(isrc);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Single track download - no position parameter
|
||||||
|
const response = await downloadWithAutoFallback(
|
||||||
|
isrc,
|
||||||
|
settings,
|
||||||
|
trackName,
|
||||||
|
artistName,
|
||||||
|
albumName,
|
||||||
|
undefined,
|
||||||
|
false,
|
||||||
|
undefined // Don't pass position for single track
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
toast.success(response.message);
|
||||||
|
setDownloadedTracks((prev) => new Set(prev).add(isrc));
|
||||||
|
} else {
|
||||||
|
toast.error(response.error || "Download failed");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "Download failed");
|
||||||
|
} finally {
|
||||||
|
setDownloadingTrack(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownloadSelected = async (
|
||||||
|
selectedTracks: string[],
|
||||||
|
allTracks: TrackMetadata[],
|
||||||
|
playlistName?: string,
|
||||||
|
isArtistDiscography?: boolean
|
||||||
|
) => {
|
||||||
|
if (selectedTracks.length === 0) {
|
||||||
|
toast.error("No tracks selected");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = getSettings();
|
||||||
|
setIsDownloading(true);
|
||||||
|
setBulkDownloadType("selected");
|
||||||
|
setDownloadProgress(0);
|
||||||
|
|
||||||
|
let successCount = 0;
|
||||||
|
let errorCount = 0;
|
||||||
|
const total = selectedTracks.length;
|
||||||
|
|
||||||
|
for (let i = 0; i < selectedTracks.length; i++) {
|
||||||
|
if (shouldStopDownloadRef.current) {
|
||||||
|
toast.info(
|
||||||
|
`Download stopped. ${successCount} tracks downloaded, ${selectedTracks.length - i} skipped.`
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isrc = selectedTracks[i];
|
||||||
|
const track = allTracks.find((t) => t.isrc === isrc);
|
||||||
|
|
||||||
|
setDownloadingTrack(isrc);
|
||||||
|
|
||||||
|
if (track) {
|
||||||
|
setCurrentDownloadInfo({ name: track.name, artists: track.artists });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use sequential numbering (1, 2, 3...) for selected tracks
|
||||||
|
const response = await downloadWithAutoFallback(
|
||||||
|
isrc,
|
||||||
|
settings,
|
||||||
|
track?.name,
|
||||||
|
track?.artists,
|
||||||
|
track?.album_name,
|
||||||
|
playlistName,
|
||||||
|
isArtistDiscography,
|
||||||
|
i + 1 // Sequential position based on selection order
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
successCount++;
|
||||||
|
setDownloadedTracks((prev) => new Set(prev).add(isrc));
|
||||||
|
} else {
|
||||||
|
errorCount++;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
errorCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDownloadProgress(Math.round(((i + 1) / total) * 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
setDownloadingTrack(null);
|
||||||
|
setCurrentDownloadInfo(null);
|
||||||
|
setIsDownloading(false);
|
||||||
|
setBulkDownloadType(null);
|
||||||
|
shouldStopDownloadRef.current = false;
|
||||||
|
|
||||||
|
if (errorCount === 0) {
|
||||||
|
toast.success(`Downloaded ${successCount} tracks successfully`);
|
||||||
|
} else {
|
||||||
|
toast.warning(`Downloaded ${successCount} tracks, ${errorCount} failed`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownloadAll = async (
|
||||||
|
tracks: TrackMetadata[],
|
||||||
|
playlistName?: string,
|
||||||
|
isArtistDiscography?: boolean
|
||||||
|
) => {
|
||||||
|
const tracksWithIsrc = tracks.filter((track) => track.isrc);
|
||||||
|
|
||||||
|
if (tracksWithIsrc.length === 0) {
|
||||||
|
toast.error("No tracks available for download");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = getSettings();
|
||||||
|
setIsDownloading(true);
|
||||||
|
setBulkDownloadType("all");
|
||||||
|
setDownloadProgress(0);
|
||||||
|
|
||||||
|
let successCount = 0;
|
||||||
|
let errorCount = 0;
|
||||||
|
const total = tracksWithIsrc.length;
|
||||||
|
|
||||||
|
for (let i = 0; i < tracksWithIsrc.length; i++) {
|
||||||
|
if (shouldStopDownloadRef.current) {
|
||||||
|
toast.info(
|
||||||
|
`Download stopped. ${successCount} tracks downloaded, ${tracksWithIsrc.length - i} skipped.`
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const track = tracksWithIsrc[i];
|
||||||
|
|
||||||
|
setDownloadingTrack(track.isrc);
|
||||||
|
setCurrentDownloadInfo({ name: track.name, artists: track.artists });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await downloadWithAutoFallback(
|
||||||
|
track.isrc,
|
||||||
|
settings,
|
||||||
|
track.name,
|
||||||
|
track.artists,
|
||||||
|
track.album_name,
|
||||||
|
playlistName,
|
||||||
|
isArtistDiscography,
|
||||||
|
i + 1
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
successCount++;
|
||||||
|
setDownloadedTracks((prev) => new Set(prev).add(track.isrc));
|
||||||
|
} else {
|
||||||
|
errorCount++;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
errorCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDownloadProgress(Math.round(((i + 1) / total) * 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
setDownloadingTrack(null);
|
||||||
|
setCurrentDownloadInfo(null);
|
||||||
|
setIsDownloading(false);
|
||||||
|
setBulkDownloadType(null);
|
||||||
|
shouldStopDownloadRef.current = false;
|
||||||
|
|
||||||
|
if (errorCount === 0) {
|
||||||
|
toast.success(`Downloaded ${successCount} tracks successfully`);
|
||||||
|
} else {
|
||||||
|
toast.warning(`Downloaded ${successCount} tracks, ${errorCount} failed`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStopDownload = () => {
|
||||||
|
shouldStopDownloadRef.current = true;
|
||||||
|
toast.info("Stopping download...");
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetDownloadedTracks = () => {
|
||||||
|
setDownloadedTracks(new Set());
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
downloadProgress,
|
||||||
|
isDownloading,
|
||||||
|
downloadingTrack,
|
||||||
|
bulkDownloadType,
|
||||||
|
downloadedTracks,
|
||||||
|
currentDownloadInfo,
|
||||||
|
handleDownloadTrack,
|
||||||
|
handleDownloadSelected,
|
||||||
|
handleDownloadAll,
|
||||||
|
handleStopDownload,
|
||||||
|
resetDownloadedTracks,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,116 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { fetchSpotifyMetadata } from "@/lib/api";
|
||||||
|
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||||
|
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 fetchMetadataDirectly = async (url: string) => {
|
||||||
|
setLoading(true);
|
||||||
|
setMetadata(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await fetchSpotifyMetadata(url);
|
||||||
|
setMetadata(data);
|
||||||
|
toast.success("Metadata fetched successfully");
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "Failed to fetch metadata");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFetchMetadata = async (url: string) => {
|
||||||
|
if (!url.trim()) {
|
||||||
|
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";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isArtistUrl) {
|
||||||
|
setPendingUrl(urlToFetch);
|
||||||
|
setShowTimeoutDialog(true);
|
||||||
|
} else {
|
||||||
|
await fetchMetadataDirectly(urlToFetch);
|
||||||
|
}
|
||||||
|
|
||||||
|
return urlToFetch;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmFetch = async () => {
|
||||||
|
setShowTimeoutDialog(false);
|
||||||
|
setLoading(true);
|
||||||
|
setMetadata(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await fetchSpotifyMetadata(pendingUrl, true, 1.0, timeoutValue);
|
||||||
|
setMetadata(data);
|
||||||
|
toast.success("Metadata fetched successfully");
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "Failed to fetch metadata");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAlbumClick = (album: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
external_urls: string;
|
||||||
|
}) => {
|
||||||
|
setSelectedAlbum(album);
|
||||||
|
setShowAlbumDialog(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmAlbumFetch = async () => {
|
||||||
|
if (!selectedAlbum) return;
|
||||||
|
|
||||||
|
setShowAlbumDialog(false);
|
||||||
|
setLoading(true);
|
||||||
|
setMetadata(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await fetchSpotifyMetadata(selectedAlbum.external_urls);
|
||||||
|
setMetadata(data);
|
||||||
|
toast.success("Album metadata fetched successfully");
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "Failed to fetch album metadata");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
setSelectedAlbum(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
loading,
|
||||||
|
metadata,
|
||||||
|
showTimeoutDialog,
|
||||||
|
setShowTimeoutDialog,
|
||||||
|
timeoutValue,
|
||||||
|
setTimeoutValue,
|
||||||
|
showAlbumDialog,
|
||||||
|
setShowAlbumDialog,
|
||||||
|
selectedAlbum,
|
||||||
|
handleFetchMetadata,
|
||||||
|
handleConfirmFetch,
|
||||||
|
handleAlbumClick,
|
||||||
|
handleConfirmAlbumFetch,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
@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);
|
||||||
|
--color-chart-1: var(--chart-1);
|
||||||
|
--color-chart-2: var(--chart-2);
|
||||||
|
--color-chart-3: var(--chart-3);
|
||||||
|
--color-chart-4: var(--chart-4);
|
||||||
|
--color-chart-5: var(--chart-5);
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--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);
|
||||||
|
--chart-1: oklch(0.646 0.222 41.116);
|
||||||
|
--chart-2: oklch(0.6 0.118 184.704);
|
||||||
|
--chart-3: oklch(0.398 0.07 227.392);
|
||||||
|
--chart-4: oklch(0.828 0.189 84.429);
|
||||||
|
--chart-5: oklch(0.769 0.188 70.08);
|
||||||
|
--sidebar: oklch(0.985 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.145 0 0);
|
||||||
|
--sidebar-primary: oklch(0.205 0 0);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.97 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||||
|
--sidebar-border: oklch(0.922 0 0);
|
||||||
|
--sidebar-ring: oklch(0.708 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--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);
|
||||||
|
--chart-1: oklch(0.488 0.243 264.376);
|
||||||
|
--chart-2: oklch(0.696 0.17 162.48);
|
||||||
|
--chart-3: oklch(0.769 0.188 70.08);
|
||||||
|
--chart-4: oklch(0.627 0.265 303.9);
|
||||||
|
--chart-5: oklch(0.645 0.246 16.439);
|
||||||
|
--sidebar: oklch(0.205 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.269 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
|
--sidebar-ring: oklch(0.556 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border outline-ring/50;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
font-family: "Google Sans Flex", 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import type {
|
||||||
|
SpotifyMetadataResponse,
|
||||||
|
DownloadRequest,
|
||||||
|
DownloadResponse,
|
||||||
|
HealthResponse,
|
||||||
|
} from "@/types/api";
|
||||||
|
import { GetSpotifyMetadata, DownloadTrack } 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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
// Audio utility for toast notifications using Web Audio API
|
||||||
|
|
||||||
|
class AudioManager {
|
||||||
|
private audioContext: AudioContext | null = null;
|
||||||
|
|
||||||
|
private getAudioContext(): AudioContext {
|
||||||
|
if (!this.audioContext) {
|
||||||
|
this.audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
|
||||||
|
}
|
||||||
|
return this.audioContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a simple tone using oscillator
|
||||||
|
private playTone(frequency: number, duration: number, type: OscillatorType = 'sine', volume: number = 0.3) {
|
||||||
|
try {
|
||||||
|
const ctx = this.getAudioContext();
|
||||||
|
const oscillator = ctx.createOscillator();
|
||||||
|
const gainNode = ctx.createGain();
|
||||||
|
|
||||||
|
oscillator.connect(gainNode);
|
||||||
|
gainNode.connect(ctx.destination);
|
||||||
|
|
||||||
|
oscillator.frequency.value = frequency;
|
||||||
|
oscillator.type = type;
|
||||||
|
|
||||||
|
gainNode.gain.setValueAtTime(volume, ctx.currentTime);
|
||||||
|
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + duration);
|
||||||
|
|
||||||
|
oscillator.start(ctx.currentTime);
|
||||||
|
oscillator.stop(ctx.currentTime + duration);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error playing audio:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success sound - pleasant ascending tones
|
||||||
|
playSuccess() {
|
||||||
|
const ctx = this.getAudioContext();
|
||||||
|
const now = ctx.currentTime;
|
||||||
|
|
||||||
|
// First tone
|
||||||
|
this.playToneAt(523.25, 0.08, 'sine', 0.2, now); // C5
|
||||||
|
// Second tone
|
||||||
|
this.playToneAt(659.25, 0.08, 'sine', 0.2, now + 0.08); // E5
|
||||||
|
// Third tone
|
||||||
|
this.playToneAt(783.99, 0.15, 'sine', 0.25, now + 0.16); // G5
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error sound - descending tones
|
||||||
|
playError() {
|
||||||
|
const ctx = this.getAudioContext();
|
||||||
|
const now = ctx.currentTime;
|
||||||
|
|
||||||
|
// First tone
|
||||||
|
this.playToneAt(392.00, 0.1, 'square', 0.15, now); // G4
|
||||||
|
// Second tone
|
||||||
|
this.playToneAt(329.63, 0.2, 'square', 0.2, now + 0.1); // E4
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warning sound - alternating tones
|
||||||
|
playWarning() {
|
||||||
|
const ctx = this.getAudioContext();
|
||||||
|
const now = ctx.currentTime;
|
||||||
|
|
||||||
|
// First tone
|
||||||
|
this.playToneAt(440.00, 0.1, 'triangle', 0.2, now); // A4
|
||||||
|
// Second tone
|
||||||
|
this.playToneAt(493.88, 0.1, 'triangle', 0.2, now + 0.12); // B4
|
||||||
|
}
|
||||||
|
|
||||||
|
// Info sound - single soft tone
|
||||||
|
playInfo() {
|
||||||
|
this.playTone(523.25, 0.15, 'sine', 0.15); // C5
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper method to play tone at specific time
|
||||||
|
private playToneAt(frequency: number, duration: number, type: OscillatorType, volume: number, startTime: number) {
|
||||||
|
try {
|
||||||
|
const ctx = this.getAudioContext();
|
||||||
|
const oscillator = ctx.createOscillator();
|
||||||
|
const gainNode = ctx.createGain();
|
||||||
|
|
||||||
|
oscillator.connect(gainNode);
|
||||||
|
gainNode.connect(ctx.destination);
|
||||||
|
|
||||||
|
oscillator.frequency.value = frequency;
|
||||||
|
oscillator.type = type;
|
||||||
|
|
||||||
|
gainNode.gain.setValueAtTime(volume, startTime);
|
||||||
|
gainNode.gain.exponentialRampToValueAtTime(0.01, startTime + duration);
|
||||||
|
|
||||||
|
oscillator.start(startTime);
|
||||||
|
oscillator.stop(startTime + duration);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error playing audio:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export singleton instance
|
||||||
|
export const audioManager = new AudioManager();
|
||||||
|
|
||||||
|
// Helper functions for easy use
|
||||||
|
export const playSuccessSound = () => audioManager.playSuccess();
|
||||||
|
export const playErrorSound = () => audioManager.playError();
|
||||||
|
export const playWarningSound = () => audioManager.playWarning();
|
||||||
|
export const playInfoSound = () => audioManager.playInfo();
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { GetDefaults } from "../../wailsjs/go/main/App";
|
||||||
|
|
||||||
|
export interface Settings {
|
||||||
|
downloadPath: string;
|
||||||
|
downloader: "auto" | "deezer" | "tidal" | "qobuz";
|
||||||
|
theme: string;
|
||||||
|
themeMode: "auto" | "light" | "dark";
|
||||||
|
filenameFormat: "title-artist" | "artist-title" | "title";
|
||||||
|
artistSubfolder: boolean;
|
||||||
|
albumSubfolder: boolean;
|
||||||
|
trackNumber: boolean;
|
||||||
|
operatingSystem: "Windows" | "linux/MacOS"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-detect operating system
|
||||||
|
function detectOS(): "Windows" | "linux/MacOS" {
|
||||||
|
const platform = window.navigator.platform.toLowerCase();
|
||||||
|
if (platform.includes('win')) {
|
||||||
|
return "Windows";
|
||||||
|
}
|
||||||
|
return "linux/MacOS";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_SETTINGS: Settings = {
|
||||||
|
downloadPath: "",
|
||||||
|
downloader: "auto",
|
||||||
|
theme: "yellow",
|
||||||
|
themeMode: "auto",
|
||||||
|
filenameFormat: "title-artist",
|
||||||
|
artistSubfolder: false,
|
||||||
|
albumSubfolder: false,
|
||||||
|
trackNumber: false,
|
||||||
|
operatingSystem: detectOS()
|
||||||
|
};
|
||||||
|
|
||||||
|
async function fetchDefaultPath(): Promise<string> {
|
||||||
|
try {
|
||||||
|
const data = await GetDefaults();
|
||||||
|
return data.downloadPath || "";
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch default path:", error);
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const SETTINGS_KEY = "spotiflac-settings";
|
||||||
|
|
||||||
|
export function getSettings(): Settings {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(SETTINGS_KEY);
|
||||||
|
if (stored) {
|
||||||
|
const parsed = JSON.parse(stored);
|
||||||
|
// Migrate old darkMode to themeMode
|
||||||
|
if ('darkMode' in parsed && !('themeMode' in parsed)) {
|
||||||
|
parsed.themeMode = parsed.darkMode ? 'dark' : 'light';
|
||||||
|
delete parsed.darkMode;
|
||||||
|
}
|
||||||
|
// Always use detected OS (don't persist it)
|
||||||
|
parsed.operatingSystem = detectOS();
|
||||||
|
return { ...DEFAULT_SETTINGS, ...parsed };
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load settings:", error);
|
||||||
|
}
|
||||||
|
return DEFAULT_SETTINGS;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSettingsWithDefaults(): Promise<Settings> {
|
||||||
|
const settings = getSettings();
|
||||||
|
|
||||||
|
// If downloadPath is empty, fetch from backend
|
||||||
|
if (!settings.downloadPath) {
|
||||||
|
settings.downloadPath = await fetchDefaultPath();
|
||||||
|
}
|
||||||
|
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveSettings(settings: Settings): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to save settings:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateSettings(partial: Partial<Settings>): Settings {
|
||||||
|
const current = getSettings();
|
||||||
|
const updated = { ...current, ...partial };
|
||||||
|
saveSettings(updated);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetToDefaultSettings(): Promise<Settings> {
|
||||||
|
const defaultPath = await fetchDefaultPath();
|
||||||
|
const defaultSettings = { ...DEFAULT_SETTINGS, downloadPath: defaultPath };
|
||||||
|
saveSettings(defaultSettings);
|
||||||
|
return defaultSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyThemeMode(mode: "auto" | "light" | "dark"): void {
|
||||||
|
if (mode === "auto") {
|
||||||
|
// Check system preference
|
||||||
|
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||||
|
if (prefersDark) {
|
||||||
|
document.documentElement.classList.add("dark");
|
||||||
|
} else {
|
||||||
|
document.documentElement.classList.remove("dark");
|
||||||
|
}
|
||||||
|
} else if (mode === "dark") {
|
||||||
|
document.documentElement.classList.add("dark");
|
||||||
|
} else {
|
||||||
|
document.documentElement.classList.remove("dark");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,470 @@
|
|||||||
|
export interface Theme {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
cssVars: {
|
||||||
|
light: Record<string, string>;
|
||||||
|
dark: Record<string, string>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const themes: Theme[] = [
|
||||||
|
{
|
||||||
|
name: "neutral",
|
||||||
|
label: "Default",
|
||||||
|
cssVars: {
|
||||||
|
light: {
|
||||||
|
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)",
|
||||||
|
"chart-1": "oklch(0.646 0.222 41.116)",
|
||||||
|
"chart-2": "oklch(0.6 0.118 184.704)",
|
||||||
|
"chart-3": "oklch(0.398 0.07 227.392)",
|
||||||
|
"chart-4": "oklch(0.828 0.189 84.429)",
|
||||||
|
"chart-5": "oklch(0.769 0.188 70.08)",
|
||||||
|
},
|
||||||
|
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)",
|
||||||
|
"chart-1": "oklch(0.488 0.243 264.376)",
|
||||||
|
"chart-2": "oklch(0.696 0.17 162.48)",
|
||||||
|
"chart-3": "oklch(0.769 0.188 70.08)",
|
||||||
|
"chart-4": "oklch(0.627 0.265 303.9)",
|
||||||
|
"chart-5": "oklch(0.645 0.246 16.439)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "blue",
|
||||||
|
label: "Blue",
|
||||||
|
cssVars: {
|
||||||
|
light: {
|
||||||
|
background: "oklch(1 0 0)",
|
||||||
|
foreground: "oklch(0.141 0.005 285.823)",
|
||||||
|
card: "oklch(1 0 0)",
|
||||||
|
"card-foreground": "oklch(0.141 0.005 285.823)",
|
||||||
|
popover: "oklch(1 0 0)",
|
||||||
|
"popover-foreground": "oklch(0.141 0.005 285.823)",
|
||||||
|
primary: "oklch(0.488 0.243 264.376)",
|
||||||
|
"primary-foreground": "oklch(0.97 0.014 254.604)",
|
||||||
|
secondary: "oklch(0.967 0.001 286.375)",
|
||||||
|
"secondary-foreground": "oklch(0.21 0.006 285.885)",
|
||||||
|
muted: "oklch(0.967 0.001 286.375)",
|
||||||
|
"muted-foreground": "oklch(0.552 0.016 285.938)",
|
||||||
|
accent: "oklch(0.967 0.001 286.375)",
|
||||||
|
"accent-foreground": "oklch(0.21 0.006 285.885)",
|
||||||
|
destructive: "oklch(0.577 0.245 27.325)",
|
||||||
|
border: "oklch(0.92 0.004 286.32)",
|
||||||
|
input: "oklch(0.92 0.004 286.32)",
|
||||||
|
ring: "oklch(0.708 0 0)",
|
||||||
|
"chart-1": "oklch(0.809 0.105 251.813)",
|
||||||
|
"chart-2": "oklch(0.623 0.214 259.815)",
|
||||||
|
"chart-3": "oklch(0.546 0.245 262.881)",
|
||||||
|
"chart-4": "oklch(0.488 0.243 264.376)",
|
||||||
|
"chart-5": "oklch(0.424 0.199 265.638)",
|
||||||
|
},
|
||||||
|
dark: {
|
||||||
|
background: "oklch(0.141 0.005 285.823)",
|
||||||
|
foreground: "oklch(0.985 0 0)",
|
||||||
|
card: "oklch(0.21 0.006 285.885)",
|
||||||
|
"card-foreground": "oklch(0.985 0 0)",
|
||||||
|
popover: "oklch(0.21 0.006 285.885)",
|
||||||
|
"popover-foreground": "oklch(0.985 0 0)",
|
||||||
|
primary: "oklch(0.488 0.243 264.376)",
|
||||||
|
"primary-foreground": "oklch(0.97 0.014 254.604)",
|
||||||
|
secondary: "oklch(0.274 0.006 286.033)",
|
||||||
|
"secondary-foreground": "oklch(0.985 0 0)",
|
||||||
|
muted: "oklch(0.274 0.006 286.033)",
|
||||||
|
"muted-foreground": "oklch(0.705 0.015 286.067)",
|
||||||
|
accent: "oklch(0.274 0.006 286.033)",
|
||||||
|
"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)",
|
||||||
|
"chart-1": "oklch(0.809 0.105 251.813)",
|
||||||
|
"chart-2": "oklch(0.623 0.214 259.815)",
|
||||||
|
"chart-3": "oklch(0.546 0.245 262.881)",
|
||||||
|
"chart-4": "oklch(0.488 0.243 264.376)",
|
||||||
|
"chart-5": "oklch(0.424 0.199 265.638)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "green",
|
||||||
|
label: "Green",
|
||||||
|
cssVars: {
|
||||||
|
light: {
|
||||||
|
background: "oklch(1 0 0)",
|
||||||
|
foreground: "oklch(0.141 0.005 285.823)",
|
||||||
|
card: "oklch(1 0 0)",
|
||||||
|
"card-foreground": "oklch(0.141 0.005 285.823)",
|
||||||
|
popover: "oklch(1 0 0)",
|
||||||
|
"popover-foreground": "oklch(0.141 0.005 285.823)",
|
||||||
|
primary: "oklch(0.648 0.2 131.684)",
|
||||||
|
"primary-foreground": "oklch(0.986 0.031 120.757)",
|
||||||
|
secondary: "oklch(0.967 0.001 286.375)",
|
||||||
|
"secondary-foreground": "oklch(0.21 0.006 285.885)",
|
||||||
|
muted: "oklch(0.967 0.001 286.375)",
|
||||||
|
"muted-foreground": "oklch(0.552 0.016 285.938)",
|
||||||
|
accent: "oklch(0.967 0.001 286.375)",
|
||||||
|
"accent-foreground": "oklch(0.21 0.006 285.885)",
|
||||||
|
destructive: "oklch(0.577 0.245 27.325)",
|
||||||
|
border: "oklch(0.92 0.004 286.32)",
|
||||||
|
input: "oklch(0.92 0.004 286.32)",
|
||||||
|
ring: "oklch(0.841 0.238 128.85)",
|
||||||
|
"chart-1": "oklch(0.871 0.15 154.449)",
|
||||||
|
"chart-2": "oklch(0.723 0.219 149.579)",
|
||||||
|
"chart-3": "oklch(0.627 0.194 149.214)",
|
||||||
|
"chart-4": "oklch(0.527 0.154 150.069)",
|
||||||
|
"chart-5": "oklch(0.448 0.119 151.328)",
|
||||||
|
},
|
||||||
|
dark: {
|
||||||
|
background: "oklch(0.141 0.005 285.823)",
|
||||||
|
foreground: "oklch(0.985 0 0)",
|
||||||
|
card: "oklch(0.21 0.006 285.885)",
|
||||||
|
"card-foreground": "oklch(0.985 0 0)",
|
||||||
|
popover: "oklch(0.21 0.006 285.885)",
|
||||||
|
"popover-foreground": "oklch(0.985 0 0)",
|
||||||
|
primary: "oklch(0.648 0.2 131.684)",
|
||||||
|
"primary-foreground": "oklch(0.986 0.031 120.757)",
|
||||||
|
secondary: "oklch(0.274 0.006 286.033)",
|
||||||
|
"secondary-foreground": "oklch(0.985 0 0)",
|
||||||
|
muted: "oklch(0.274 0.006 286.033)",
|
||||||
|
"muted-foreground": "oklch(0.705 0.015 286.067)",
|
||||||
|
accent: "oklch(0.274 0.006 286.033)",
|
||||||
|
"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.405 0.101 131.063)",
|
||||||
|
"chart-1": "oklch(0.871 0.15 154.449)",
|
||||||
|
"chart-2": "oklch(0.723 0.219 149.579)",
|
||||||
|
"chart-3": "oklch(0.627 0.194 149.214)",
|
||||||
|
"chart-4": "oklch(0.527 0.154 150.069)",
|
||||||
|
"chart-5": "oklch(0.448 0.119 151.328)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "orange",
|
||||||
|
label: "Orange",
|
||||||
|
cssVars: {
|
||||||
|
light: {
|
||||||
|
background: "oklch(1 0 0)",
|
||||||
|
foreground: "oklch(0.141 0.005 285.823)",
|
||||||
|
card: "oklch(1 0 0)",
|
||||||
|
"card-foreground": "oklch(0.141 0.005 285.823)",
|
||||||
|
popover: "oklch(1 0 0)",
|
||||||
|
"popover-foreground": "oklch(0.141 0.005 285.823)",
|
||||||
|
primary: "oklch(0.646 0.222 41.116)",
|
||||||
|
"primary-foreground": "oklch(0.98 0.016 73.684)",
|
||||||
|
secondary: "oklch(0.967 0.001 286.375)",
|
||||||
|
"secondary-foreground": "oklch(0.21 0.006 285.885)",
|
||||||
|
muted: "oklch(0.967 0.001 286.375)",
|
||||||
|
"muted-foreground": "oklch(0.552 0.016 285.938)",
|
||||||
|
accent: "oklch(0.967 0.001 286.375)",
|
||||||
|
"accent-foreground": "oklch(0.21 0.006 285.885)",
|
||||||
|
destructive: "oklch(0.577 0.245 27.325)",
|
||||||
|
border: "oklch(0.92 0.004 286.32)",
|
||||||
|
input: "oklch(0.92 0.004 286.32)",
|
||||||
|
ring: "oklch(0.75 0.183 55.934)",
|
||||||
|
"chart-1": "oklch(0.837 0.128 66.29)",
|
||||||
|
"chart-2": "oklch(0.705 0.213 47.604)",
|
||||||
|
"chart-3": "oklch(0.646 0.222 41.116)",
|
||||||
|
"chart-4": "oklch(0.553 0.195 38.402)",
|
||||||
|
"chart-5": "oklch(0.47 0.157 37.304)",
|
||||||
|
},
|
||||||
|
dark: {
|
||||||
|
background: "oklch(0.141 0.005 285.823)",
|
||||||
|
foreground: "oklch(0.985 0 0)",
|
||||||
|
card: "oklch(0.21 0.006 285.885)",
|
||||||
|
"card-foreground": "oklch(0.985 0 0)",
|
||||||
|
popover: "oklch(0.21 0.006 285.885)",
|
||||||
|
"popover-foreground": "oklch(0.985 0 0)",
|
||||||
|
primary: "oklch(0.705 0.213 47.604)",
|
||||||
|
"primary-foreground": "oklch(0.98 0.016 73.684)",
|
||||||
|
secondary: "oklch(0.274 0.006 286.033)",
|
||||||
|
"secondary-foreground": "oklch(0.985 0 0)",
|
||||||
|
muted: "oklch(0.274 0.006 286.033)",
|
||||||
|
"muted-foreground": "oklch(0.705 0.015 286.067)",
|
||||||
|
accent: "oklch(0.274 0.006 286.033)",
|
||||||
|
"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.408 0.123 38.172)",
|
||||||
|
"chart-1": "oklch(0.837 0.128 66.29)",
|
||||||
|
"chart-2": "oklch(0.705 0.213 47.604)",
|
||||||
|
"chart-3": "oklch(0.646 0.222 41.116)",
|
||||||
|
"chart-4": "oklch(0.553 0.195 38.402)",
|
||||||
|
"chart-5": "oklch(0.47 0.157 37.304)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "red",
|
||||||
|
label: "Red",
|
||||||
|
cssVars: {
|
||||||
|
light: {
|
||||||
|
background: "oklch(1 0 0)",
|
||||||
|
foreground: "oklch(0.141 0.005 285.823)",
|
||||||
|
card: "oklch(1 0 0)",
|
||||||
|
"card-foreground": "oklch(0.141 0.005 285.823)",
|
||||||
|
popover: "oklch(1 0 0)",
|
||||||
|
"popover-foreground": "oklch(0.141 0.005 285.823)",
|
||||||
|
primary: "oklch(0.577 0.245 27.325)",
|
||||||
|
"primary-foreground": "oklch(0.971 0.013 17.38)",
|
||||||
|
secondary: "oklch(0.967 0.001 286.375)",
|
||||||
|
"secondary-foreground": "oklch(0.21 0.006 285.885)",
|
||||||
|
muted: "oklch(0.967 0.001 286.375)",
|
||||||
|
"muted-foreground": "oklch(0.552 0.016 285.938)",
|
||||||
|
accent: "oklch(0.967 0.001 286.375)",
|
||||||
|
"accent-foreground": "oklch(0.21 0.006 285.885)",
|
||||||
|
destructive: "oklch(0.577 0.245 27.325)",
|
||||||
|
border: "oklch(0.92 0.004 286.32)",
|
||||||
|
input: "oklch(0.92 0.004 286.32)",
|
||||||
|
ring: "oklch(0.704 0.191 22.216)",
|
||||||
|
"chart-1": "oklch(0.808 0.114 19.571)",
|
||||||
|
"chart-2": "oklch(0.637 0.237 25.331)",
|
||||||
|
"chart-3": "oklch(0.577 0.245 27.325)",
|
||||||
|
"chart-4": "oklch(0.505 0.213 27.518)",
|
||||||
|
"chart-5": "oklch(0.444 0.177 26.899)",
|
||||||
|
},
|
||||||
|
dark: {
|
||||||
|
background: "oklch(0.141 0.005 285.823)",
|
||||||
|
foreground: "oklch(0.985 0 0)",
|
||||||
|
card: "oklch(0.21 0.006 285.885)",
|
||||||
|
"card-foreground": "oklch(0.985 0 0)",
|
||||||
|
popover: "oklch(0.21 0.006 285.885)",
|
||||||
|
"popover-foreground": "oklch(0.985 0 0)",
|
||||||
|
primary: "oklch(0.637 0.237 25.331)",
|
||||||
|
"primary-foreground": "oklch(0.971 0.013 17.38)",
|
||||||
|
secondary: "oklch(0.274 0.006 286.033)",
|
||||||
|
"secondary-foreground": "oklch(0.985 0 0)",
|
||||||
|
muted: "oklch(0.274 0.006 286.033)",
|
||||||
|
"muted-foreground": "oklch(0.705 0.015 286.067)",
|
||||||
|
accent: "oklch(0.274 0.006 286.033)",
|
||||||
|
"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.396 0.141 25.723)",
|
||||||
|
"chart-1": "oklch(0.808 0.114 19.571)",
|
||||||
|
"chart-2": "oklch(0.637 0.237 25.331)",
|
||||||
|
"chart-3": "oklch(0.577 0.245 27.325)",
|
||||||
|
"chart-4": "oklch(0.505 0.213 27.518)",
|
||||||
|
"chart-5": "oklch(0.444 0.177 26.899)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "rose",
|
||||||
|
label: "Rose",
|
||||||
|
cssVars: {
|
||||||
|
light: {
|
||||||
|
background: "oklch(1 0 0)",
|
||||||
|
foreground: "oklch(0.141 0.005 285.823)",
|
||||||
|
card: "oklch(1 0 0)",
|
||||||
|
"card-foreground": "oklch(0.141 0.005 285.823)",
|
||||||
|
popover: "oklch(1 0 0)",
|
||||||
|
"popover-foreground": "oklch(0.141 0.005 285.823)",
|
||||||
|
primary: "oklch(0.586 0.253 17.585)",
|
||||||
|
"primary-foreground": "oklch(0.969 0.015 12.422)",
|
||||||
|
secondary: "oklch(0.967 0.001 286.375)",
|
||||||
|
"secondary-foreground": "oklch(0.21 0.006 285.885)",
|
||||||
|
muted: "oklch(0.967 0.001 286.375)",
|
||||||
|
"muted-foreground": "oklch(0.552 0.016 285.938)",
|
||||||
|
accent: "oklch(0.967 0.001 286.375)",
|
||||||
|
"accent-foreground": "oklch(0.21 0.006 285.885)",
|
||||||
|
destructive: "oklch(0.577 0.245 27.325)",
|
||||||
|
border: "oklch(0.92 0.004 286.32)",
|
||||||
|
input: "oklch(0.92 0.004 286.32)",
|
||||||
|
ring: "oklch(0.712 0.194 13.428)",
|
||||||
|
"chart-1": "oklch(0.81 0.117 11.638)",
|
||||||
|
"chart-2": "oklch(0.645 0.246 16.439)",
|
||||||
|
"chart-3": "oklch(0.586 0.253 17.585)",
|
||||||
|
"chart-4": "oklch(0.514 0.222 16.935)",
|
||||||
|
"chart-5": "oklch(0.455 0.188 13.697)",
|
||||||
|
},
|
||||||
|
dark: {
|
||||||
|
background: "oklch(0.141 0.005 285.823)",
|
||||||
|
foreground: "oklch(0.985 0 0)",
|
||||||
|
card: "oklch(0.21 0.006 285.885)",
|
||||||
|
"card-foreground": "oklch(0.985 0 0)",
|
||||||
|
popover: "oklch(0.21 0.006 285.885)",
|
||||||
|
"popover-foreground": "oklch(0.985 0 0)",
|
||||||
|
primary: "oklch(0.645 0.246 16.439)",
|
||||||
|
"primary-foreground": "oklch(0.969 0.015 12.422)",
|
||||||
|
secondary: "oklch(0.274 0.006 286.033)",
|
||||||
|
"secondary-foreground": "oklch(0.985 0 0)",
|
||||||
|
muted: "oklch(0.274 0.006 286.033)",
|
||||||
|
"muted-foreground": "oklch(0.705 0.015 286.067)",
|
||||||
|
accent: "oklch(0.274 0.006 286.033)",
|
||||||
|
"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.41 0.159 10.272)",
|
||||||
|
"chart-1": "oklch(0.81 0.117 11.638)",
|
||||||
|
"chart-2": "oklch(0.645 0.246 16.439)",
|
||||||
|
"chart-3": "oklch(0.586 0.253 17.585)",
|
||||||
|
"chart-4": "oklch(0.514 0.222 16.935)",
|
||||||
|
"chart-5": "oklch(0.455 0.188 13.697)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "violet",
|
||||||
|
label: "Violet",
|
||||||
|
cssVars: {
|
||||||
|
light: {
|
||||||
|
background: "oklch(1 0 0)",
|
||||||
|
foreground: "oklch(0.141 0.005 285.823)",
|
||||||
|
card: "oklch(1 0 0)",
|
||||||
|
"card-foreground": "oklch(0.141 0.005 285.823)",
|
||||||
|
popover: "oklch(1 0 0)",
|
||||||
|
"popover-foreground": "oklch(0.141 0.005 285.823)",
|
||||||
|
primary: "oklch(0.541 0.281 293.009)",
|
||||||
|
"primary-foreground": "oklch(0.969 0.016 293.756)",
|
||||||
|
secondary: "oklch(0.967 0.001 286.375)",
|
||||||
|
"secondary-foreground": "oklch(0.21 0.006 285.885)",
|
||||||
|
muted: "oklch(0.967 0.001 286.375)",
|
||||||
|
"muted-foreground": "oklch(0.552 0.016 285.938)",
|
||||||
|
accent: "oklch(0.967 0.001 286.375)",
|
||||||
|
"accent-foreground": "oklch(0.21 0.006 285.885)",
|
||||||
|
destructive: "oklch(0.577 0.245 27.325)",
|
||||||
|
border: "oklch(0.92 0.004 286.32)",
|
||||||
|
input: "oklch(0.92 0.004 286.32)",
|
||||||
|
ring: "oklch(0.702 0.183 293.541)",
|
||||||
|
"chart-1": "oklch(0.811 0.111 293.571)",
|
||||||
|
"chart-2": "oklch(0.606 0.25 292.717)",
|
||||||
|
"chart-3": "oklch(0.541 0.281 293.009)",
|
||||||
|
"chart-4": "oklch(0.491 0.27 292.581)",
|
||||||
|
"chart-5": "oklch(0.432 0.232 292.759)",
|
||||||
|
},
|
||||||
|
dark: {
|
||||||
|
background: "oklch(0.141 0.005 285.823)",
|
||||||
|
foreground: "oklch(0.985 0 0)",
|
||||||
|
card: "oklch(0.21 0.006 285.885)",
|
||||||
|
"card-foreground": "oklch(0.985 0 0)",
|
||||||
|
popover: "oklch(0.21 0.006 285.885)",
|
||||||
|
"popover-foreground": "oklch(0.985 0 0)",
|
||||||
|
primary: "oklch(0.606 0.25 292.717)",
|
||||||
|
"primary-foreground": "oklch(0.969 0.016 293.756)",
|
||||||
|
secondary: "oklch(0.274 0.006 286.033)",
|
||||||
|
"secondary-foreground": "oklch(0.985 0 0)",
|
||||||
|
muted: "oklch(0.274 0.006 286.033)",
|
||||||
|
"muted-foreground": "oklch(0.705 0.015 286.067)",
|
||||||
|
accent: "oklch(0.274 0.006 286.033)",
|
||||||
|
"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.38 0.189 293.745)",
|
||||||
|
"chart-1": "oklch(0.811 0.111 293.571)",
|
||||||
|
"chart-2": "oklch(0.606 0.25 292.717)",
|
||||||
|
"chart-3": "oklch(0.541 0.281 293.009)",
|
||||||
|
"chart-4": "oklch(0.491 0.27 292.581)",
|
||||||
|
"chart-5": "oklch(0.432 0.232 292.759)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "yellow",
|
||||||
|
label: "Yellow",
|
||||||
|
cssVars: {
|
||||||
|
light: {
|
||||||
|
background: "oklch(1 0 0)",
|
||||||
|
foreground: "oklch(0.141 0.005 285.823)",
|
||||||
|
card: "oklch(1 0 0)",
|
||||||
|
"card-foreground": "oklch(0.141 0.005 285.823)",
|
||||||
|
popover: "oklch(1 0 0)",
|
||||||
|
"popover-foreground": "oklch(0.141 0.005 285.823)",
|
||||||
|
primary: "oklch(0.852 0.199 91.936)",
|
||||||
|
"primary-foreground": "oklch(0.421 0.095 57.708)",
|
||||||
|
secondary: "oklch(0.967 0.001 286.375)",
|
||||||
|
"secondary-foreground": "oklch(0.21 0.006 285.885)",
|
||||||
|
muted: "oklch(0.967 0.001 286.375)",
|
||||||
|
"muted-foreground": "oklch(0.552 0.016 285.938)",
|
||||||
|
accent: "oklch(0.967 0.001 286.375)",
|
||||||
|
"accent-foreground": "oklch(0.21 0.006 285.885)",
|
||||||
|
destructive: "oklch(0.577 0.245 27.325)",
|
||||||
|
border: "oklch(0.92 0.004 286.32)",
|
||||||
|
input: "oklch(0.92 0.004 286.32)",
|
||||||
|
ring: "oklch(0.852 0.199 91.936)",
|
||||||
|
"chart-1": "oklch(0.905 0.182 98.111)",
|
||||||
|
"chart-2": "oklch(0.795 0.184 86.047)",
|
||||||
|
"chart-3": "oklch(0.681 0.162 75.834)",
|
||||||
|
"chart-4": "oklch(0.554 0.135 66.442)",
|
||||||
|
"chart-5": "oklch(0.476 0.114 61.907)",
|
||||||
|
},
|
||||||
|
dark: {
|
||||||
|
background: "oklch(0.141 0.005 285.823)",
|
||||||
|
foreground: "oklch(0.985 0 0)",
|
||||||
|
card: "oklch(0.21 0.006 285.885)",
|
||||||
|
"card-foreground": "oklch(0.985 0 0)",
|
||||||
|
popover: "oklch(0.21 0.006 285.885)",
|
||||||
|
"popover-foreground": "oklch(0.985 0 0)",
|
||||||
|
primary: "oklch(0.795 0.184 86.047)",
|
||||||
|
"primary-foreground": "oklch(0.421 0.095 57.708)",
|
||||||
|
secondary: "oklch(0.274 0.006 286.033)",
|
||||||
|
"secondary-foreground": "oklch(0.985 0 0)",
|
||||||
|
muted: "oklch(0.274 0.006 286.033)",
|
||||||
|
"muted-foreground": "oklch(0.705 0.015 286.067)",
|
||||||
|
accent: "oklch(0.274 0.006 286.033)",
|
||||||
|
"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.421 0.095 57.708)",
|
||||||
|
"chart-1": "oklch(0.905 0.182 98.111)",
|
||||||
|
"chart-2": "oklch(0.795 0.184 86.047)",
|
||||||
|
"chart-3": "oklch(0.681 0.162 75.834)",
|
||||||
|
"chart-4": "oklch(0.554 0.135 66.442)",
|
||||||
|
"chart-5": "oklch(0.476 0.114 61.907)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function applyTheme(themeName: string) {
|
||||||
|
const theme = themes.find((t) => t.name === themeName) || themes[0];
|
||||||
|
const root = document.documentElement;
|
||||||
|
const isDark = root.classList.contains("dark");
|
||||||
|
const vars = isDark ? theme.cssVars.dark : theme.cssVars.light;
|
||||||
|
|
||||||
|
Object.entries(vars).forEach(([key, value]) => {
|
||||||
|
root.style.setProperty(`--${key}`, value);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { toast } from 'sonner';
|
||||||
|
import { playSuccessSound, playErrorSound, playWarningSound, playInfoSound } from './audio';
|
||||||
|
|
||||||
|
// Wrapper functions for toast with sound effects
|
||||||
|
export const toastWithSound = {
|
||||||
|
success: (message: string, data?: any) => {
|
||||||
|
playSuccessSound();
|
||||||
|
return toast.success(message, data);
|
||||||
|
},
|
||||||
|
|
||||||
|
error: (message: string, data?: any) => {
|
||||||
|
playErrorSound();
|
||||||
|
return toast.error(message, data);
|
||||||
|
},
|
||||||
|
|
||||||
|
warning: (message: string, data?: any) => {
|
||||||
|
playWarningSound();
|
||||||
|
return toast.warning(message, data);
|
||||||
|
},
|
||||||
|
|
||||||
|
info: (message: string, data?: any) => {
|
||||||
|
playInfoSound();
|
||||||
|
return toast.info(message, data);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Default toast without specific type
|
||||||
|
message: (message: string, data?: any) => {
|
||||||
|
playInfoSound();
|
||||||
|
return toast(message, data);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
import type { Settings } from "./settings";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function sanitizePath(input: string, os: string): string {
|
||||||
|
if (os === "Windows") {
|
||||||
|
return input.replace(/[<>:"/\\|?*]/g, "_");
|
||||||
|
}
|
||||||
|
|
||||||
|
// unix-based OS
|
||||||
|
return input.replace(/\//g, "_");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function joinPath(os: string, ...parts: string[]): string {
|
||||||
|
const sep = os === "Windows" ? "\\" : "/";
|
||||||
|
|
||||||
|
const filtered = parts.filter(Boolean);
|
||||||
|
if (filtered.length === 0) return "";
|
||||||
|
|
||||||
|
const joined = filtered
|
||||||
|
.map((p, i) => {
|
||||||
|
// For first part, only remove trailing slashes (preserve leading slash for absolute paths)
|
||||||
|
if (i === 0) {
|
||||||
|
return p.replace(/[/\\]+$/g, "");
|
||||||
|
}
|
||||||
|
// For other parts, remove both leading and trailing slashes
|
||||||
|
return p.replace(/^[/\\]+|[/\\]+$/g, "");
|
||||||
|
})
|
||||||
|
.filter(Boolean) // Remove empty strings after trimming
|
||||||
|
.join(sep);
|
||||||
|
|
||||||
|
return joined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildOutputPath(settings: Settings, folder?: string) {
|
||||||
|
const os = settings.operatingSystem;
|
||||||
|
|
||||||
|
const base = settings.downloadPath || "";
|
||||||
|
const sanitized = folder ? sanitizePath(folder, os) : undefined;
|
||||||
|
|
||||||
|
return sanitized ? joinPath(os, base, sanitized) : base;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App.tsx'
|
||||||
|
import { Toaster } from '@/components/ui/sonner'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
<Toaster position="bottom-left" />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
export interface TrackMetadata {
|
||||||
|
artists: string;
|
||||||
|
name: string;
|
||||||
|
album_name: string;
|
||||||
|
duration_ms: number;
|
||||||
|
images: string;
|
||||||
|
release_date: string;
|
||||||
|
track_number: number;
|
||||||
|
external_urls: string;
|
||||||
|
isrc: string;
|
||||||
|
album_type?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrackResponse {
|
||||||
|
track: TrackMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AlbumInfo {
|
||||||
|
total_tracks: number;
|
||||||
|
name: string;
|
||||||
|
release_date: string;
|
||||||
|
artists: string;
|
||||||
|
images: string;
|
||||||
|
batch?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AlbumResponse {
|
||||||
|
album_info: AlbumInfo;
|
||||||
|
track_list: TrackMetadata[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlaylistInfo {
|
||||||
|
tracks: {
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
followers: {
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
owner: {
|
||||||
|
display_name: string;
|
||||||
|
name: string;
|
||||||
|
images: string;
|
||||||
|
};
|
||||||
|
batch?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlaylistResponse {
|
||||||
|
playlist_info: PlaylistInfo;
|
||||||
|
track_list: TrackMetadata[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ArtistInfo {
|
||||||
|
name: string;
|
||||||
|
followers: number;
|
||||||
|
genres: string[];
|
||||||
|
images: string;
|
||||||
|
external_urls: string;
|
||||||
|
discography_type: string;
|
||||||
|
total_albums: number;
|
||||||
|
batch?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiscographyAlbum {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
album_type: string;
|
||||||
|
release_date: string;
|
||||||
|
total_tracks: number;
|
||||||
|
artists: string;
|
||||||
|
images: string;
|
||||||
|
external_urls: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ArtistDiscographyResponse {
|
||||||
|
artist_info: ArtistInfo;
|
||||||
|
album_list: DiscographyAlbum[];
|
||||||
|
track_list: TrackMetadata[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ArtistResponse {
|
||||||
|
artist: {
|
||||||
|
name: string;
|
||||||
|
followers: number;
|
||||||
|
genres: string[];
|
||||||
|
images: string;
|
||||||
|
external_urls: string;
|
||||||
|
popularity: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SpotifyMetadataResponse =
|
||||||
|
| TrackResponse
|
||||||
|
| AlbumResponse
|
||||||
|
| PlaylistResponse
|
||||||
|
| ArtistDiscographyResponse
|
||||||
|
| ArtistResponse;
|
||||||
|
|
||||||
|
export interface DownloadRequest {
|
||||||
|
isrc: string;
|
||||||
|
service: "deezer" | "tidal" | "qobuz";
|
||||||
|
query?: string;
|
||||||
|
track_name?: string;
|
||||||
|
artist_name?: string;
|
||||||
|
album_name?: string;
|
||||||
|
api_url?: string;
|
||||||
|
output_dir?: string;
|
||||||
|
audio_format?: string;
|
||||||
|
folder_name?: string;
|
||||||
|
filename_format?: string;
|
||||||
|
track_number?: boolean;
|
||||||
|
position?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DownloadResponse {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
file?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HealthResponse {
|
||||||
|
status: string;
|
||||||
|
time: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": false,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true,
|
||||||
|
|
||||||
|
/* Path Mapping */
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"./src/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
],
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import path from "path"
|
||||||
|
import tailwindcss from "@tailwindcss/vite"
|
||||||
|
import react from "@vitejs/plugin-react"
|
||||||
|
import { defineConfig } from "vite"
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(__dirname, "./src"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
-505
@@ -1,505 +0,0 @@
|
|||||||
from time import sleep
|
|
||||||
from urllib.parse import urlparse, parse_qs
|
|
||||||
import requests
|
|
||||||
import json
|
|
||||||
import time
|
|
||||||
import pyotp
|
|
||||||
import base64
|
|
||||||
from random import randrange
|
|
||||||
from typing import Dict, Any, List, Tuple
|
|
||||||
|
|
||||||
# https://github.com/visagenull/Spotify-Free
|
|
||||||
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 generate_totp():
|
|
||||||
url = "https://raw.githubusercontent.com/Thereallo1026/spotify-secrets/refs/heads/main/secrets/secretBytes.json"
|
|
||||||
|
|
||||||
try:
|
|
||||||
resp = requests.get(url, timeout=10)
|
|
||||||
if resp.status_code != 200:
|
|
||||||
raise Exception(f"Failed to fetch TOTP secrets from GitHub. Status: {resp.status_code}")
|
|
||||||
secrets_list = resp.json()
|
|
||||||
|
|
||||||
latest_entry = max(secrets_list, key=lambda x: x["version"])
|
|
||||||
version = latest_entry["version"]
|
|
||||||
secret_cipher = latest_entry["secret"]
|
|
||||||
except Exception as e:
|
|
||||||
raise Exception(f"Failed to fetch secrets from GitHub: {str(e)}")
|
|
||||||
|
|
||||||
processed = [byte ^ ((i % 33) + 9) for i, byte in enumerate(secret_cipher)]
|
|
||||||
processed_str = "".join(map(str, processed))
|
|
||||||
utf8_bytes = processed_str.encode('utf-8')
|
|
||||||
hex_str = utf8_bytes.hex()
|
|
||||||
secret_bytes = bytes.fromhex(hex_str)
|
|
||||||
b32_secret = base64.b32encode(secret_bytes).decode('utf-8')
|
|
||||||
totp = pyotp.TOTP(b32_secret)
|
|
||||||
|
|
||||||
headers = {
|
|
||||||
"Host": "open.spotify.com",
|
|
||||||
"User-Agent": get_random_user_agent(),
|
|
||||||
"Accept": "*/*",
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
resp = requests.get("https://open.spotify.com/api/server-time", headers=headers, timeout=10)
|
|
||||||
if resp.status_code != 200:
|
|
||||||
raise Exception(f"Failed to get server time. Status code: {resp.status_code}")
|
|
||||||
data = resp.json()
|
|
||||||
server_time = data.get("serverTime")
|
|
||||||
if server_time is None:
|
|
||||||
raise Exception("Failed to fetch server time from Spotify")
|
|
||||||
return totp, server_time, version
|
|
||||||
except Exception as e:
|
|
||||||
raise Exception(f"Error getting server time: {str(e)}")
|
|
||||||
|
|
||||||
token_url = 'https://open.spotify.com/api/token'
|
|
||||||
playlist_base_url = 'https://api.spotify.com/v1/playlists/{}'
|
|
||||||
album_base_url = 'https://api.spotify.com/v1/albums/{}'
|
|
||||||
track_base_url = 'https://api.spotify.com/v1/tracks/{}'
|
|
||||||
headers = {
|
|
||||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
|
|
||||||
'Accept': 'application/json',
|
|
||||||
'Accept-Language': 'en-US,en;q=0.9',
|
|
||||||
'Accept-Encoding': 'gzip, deflate, br',
|
|
||||||
'sec-ch-ua-platform': '"Windows"',
|
|
||||||
'sec-fetch-dest': 'empty',
|
|
||||||
'sec-fetch-mode': 'cors',
|
|
||||||
'sec-fetch-site': 'same-origin',
|
|
||||||
'Referer': 'https://open.spotify.com/',
|
|
||||||
'Origin': 'https://open.spotify.com'
|
|
||||||
}
|
|
||||||
|
|
||||||
class SpotifyInvalidUrlException(Exception):
|
|
||||||
pass
|
|
||||||
|
|
||||||
class SpotifyWebsiteParserException(Exception):
|
|
||||||
pass
|
|
||||||
|
|
||||||
def parse_uri(uri):
|
|
||||||
u = urlparse(uri)
|
|
||||||
if u.netloc == "embed.spotify.com":
|
|
||||||
if not u.query:
|
|
||||||
raise SpotifyInvalidUrlException("ERROR: url {} is not supported".format(uri))
|
|
||||||
qs = parse_qs(u.query)
|
|
||||||
return parse_uri(qs['uri'][0])
|
|
||||||
|
|
||||||
if not u.scheme and not u.netloc:
|
|
||||||
return {"type": "playlist", "id": u.path}
|
|
||||||
|
|
||||||
if u.scheme == "spotify":
|
|
||||||
parts = uri.split(":")
|
|
||||||
else:
|
|
||||||
if u.netloc != "open.spotify.com" and u.netloc != "play.spotify.com":
|
|
||||||
raise SpotifyInvalidUrlException("ERROR: url {} is not supported".format(uri))
|
|
||||||
parts = u.path.split("/")
|
|
||||||
|
|
||||||
if parts[1] == "embed":
|
|
||||||
parts = parts[1:]
|
|
||||||
|
|
||||||
l = len(parts)
|
|
||||||
if l == 3 and parts[1] in ["album", "track", "playlist"]:
|
|
||||||
return {"type": parts[1], "id": parts[2]}
|
|
||||||
if l == 5 and parts[3] == "playlist":
|
|
||||||
return {"type": parts[3], "id": parts[4]}
|
|
||||||
|
|
||||||
raise SpotifyInvalidUrlException("ERROR: unable to determine Spotify URL type or type is unsupported.")
|
|
||||||
|
|
||||||
def get_json_from_api(api_url, access_token):
|
|
||||||
headers.update({'Authorization': 'Bearer {}'.format(access_token)})
|
|
||||||
|
|
||||||
req = requests.get(api_url, headers=headers, timeout=10)
|
|
||||||
|
|
||||||
if req.status_code == 429:
|
|
||||||
seconds = int(req.headers.get("Retry-After", "5")) + 1
|
|
||||||
print(f"INFO: rate limited! Sleeping for {seconds} seconds")
|
|
||||||
sleep(seconds)
|
|
||||||
return None
|
|
||||||
|
|
||||||
if req.status_code != 200:
|
|
||||||
raise SpotifyWebsiteParserException(f"ERROR: {api_url} gave us not a 200. Instead: {req.status_code}")
|
|
||||||
|
|
||||||
return req.json()
|
|
||||||
|
|
||||||
def get_access_token():
|
|
||||||
try:
|
|
||||||
totp, server_time, totp_version = generate_totp()
|
|
||||||
otp_code = totp.at(int(server_time))
|
|
||||||
timestamp_ms = int(time.time() * 1000)
|
|
||||||
|
|
||||||
params = {
|
|
||||||
'reason': 'init',
|
|
||||||
'productType': 'web-player',
|
|
||||||
'totp': otp_code,
|
|
||||||
'totpServerTime': server_time,
|
|
||||||
'totpVer': str(totp_version),
|
|
||||||
'sTime': server_time,
|
|
||||||
'cTime': timestamp_ms,
|
|
||||||
'buildVer': 'web-player_2025-07-02_1720000000000_12345678',
|
|
||||||
'buildDate': '2025-07-02'
|
|
||||||
}
|
|
||||||
|
|
||||||
req = requests.get(token_url, headers=headers, params=params, timeout=10)
|
|
||||||
if req.status_code != 200:
|
|
||||||
return {"error": f"Failed to get access token. Status code: {req.status_code}"}
|
|
||||||
return req.json()
|
|
||||||
except Exception as e:
|
|
||||||
return {"error": f"Failed to get access token: {str(e)}"}
|
|
||||||
|
|
||||||
def fetch_tracks_in_batches(url: str, access_token: str, batch_size: int = 100, delay: float = 1.0) -> Tuple[List[Dict[str, Any]], int]:
|
|
||||||
all_tracks = []
|
|
||||||
current_batch = 0
|
|
||||||
|
|
||||||
while url:
|
|
||||||
print(f"Batch : {current_batch}")
|
|
||||||
|
|
||||||
url_parts = url.split("offset=")
|
|
||||||
if len(url_parts) > 1:
|
|
||||||
offset_part = url_parts[1].split("&")[0]
|
|
||||||
print(f"Offset : {offset_part}")
|
|
||||||
print("-------------")
|
|
||||||
|
|
||||||
track_data = get_json_from_api(url, access_token)
|
|
||||||
if not track_data:
|
|
||||||
break
|
|
||||||
|
|
||||||
items = track_data.get('items', [])
|
|
||||||
all_tracks.extend(items)
|
|
||||||
|
|
||||||
url = track_data.get('next')
|
|
||||||
if url and "&locale=" in url:
|
|
||||||
url = url.split("&locale=")[0]
|
|
||||||
|
|
||||||
if url and delay > 0:
|
|
||||||
sleep(delay)
|
|
||||||
|
|
||||||
current_batch += 1
|
|
||||||
|
|
||||||
return all_tracks, current_batch
|
|
||||||
|
|
||||||
def get_raw_spotify_data(spotify_url, batch: bool = False, delay: float = 1.0):
|
|
||||||
url_info = parse_uri(spotify_url)
|
|
||||||
token = get_access_token()
|
|
||||||
|
|
||||||
if "error" in token:
|
|
||||||
return token
|
|
||||||
|
|
||||||
access_token = token["accessToken"]
|
|
||||||
raw_data = {}
|
|
||||||
|
|
||||||
if url_info['type'] == "playlist":
|
|
||||||
try:
|
|
||||||
playlist_data = get_json_from_api(
|
|
||||||
playlist_base_url.format(url_info["id"]),
|
|
||||||
access_token
|
|
||||||
)
|
|
||||||
if not playlist_data:
|
|
||||||
return {"error": "Failed to get playlist data"}
|
|
||||||
|
|
||||||
raw_data = playlist_data
|
|
||||||
total_tracks = playlist_data.get('tracks', {}).get('total', 0)
|
|
||||||
|
|
||||||
if batch:
|
|
||||||
tracks_url = f'https://api.spotify.com/v1/playlists/{url_info["id"]}/tracks?limit=100'
|
|
||||||
tracks, num_batches = fetch_tracks_in_batches(tracks_url, access_token, 100, delay)
|
|
||||||
raw_data['tracks']['items'] = tracks
|
|
||||||
raw_data['_batch_count'] = num_batches
|
|
||||||
raw_data['_batch_enabled'] = True
|
|
||||||
|
|
||||||
if len(tracks) < total_tracks:
|
|
||||||
last_offset = len(tracks)
|
|
||||||
remaining_tracks = []
|
|
||||||
|
|
||||||
while last_offset < total_tracks:
|
|
||||||
print(f"Batch : {num_batches}")
|
|
||||||
print(f"Offset : {last_offset}")
|
|
||||||
print("-------------")
|
|
||||||
|
|
||||||
remainder_url = f'https://api.spotify.com/v1/playlists/{url_info["id"]}/tracks?offset={last_offset}&limit=100'
|
|
||||||
track_data = get_json_from_api(remainder_url, access_token)
|
|
||||||
|
|
||||||
if not track_data or not track_data.get('items'):
|
|
||||||
break
|
|
||||||
|
|
||||||
items = track_data.get('items', [])
|
|
||||||
remaining_tracks.extend(items)
|
|
||||||
|
|
||||||
if len(items) < 100:
|
|
||||||
break
|
|
||||||
|
|
||||||
last_offset += len(items)
|
|
||||||
num_batches += 1
|
|
||||||
|
|
||||||
if delay > 0:
|
|
||||||
sleep(delay)
|
|
||||||
|
|
||||||
tracks.extend(remaining_tracks)
|
|
||||||
raw_data['tracks']['items'] = tracks
|
|
||||||
raw_data['_batch_count'] = num_batches
|
|
||||||
else:
|
|
||||||
tracks = []
|
|
||||||
tracks_url = f'https://api.spotify.com/v1/playlists/{url_info["id"]}/tracks?limit=100'
|
|
||||||
while tracks_url:
|
|
||||||
track_data = get_json_from_api(tracks_url, access_token)
|
|
||||||
if not track_data:
|
|
||||||
break
|
|
||||||
|
|
||||||
tracks.extend(track_data['items'])
|
|
||||||
tracks_url = track_data.get('next')
|
|
||||||
if tracks_url and "&locale=" in tracks_url:
|
|
||||||
tracks_url = tracks_url.split("&locale=")[0]
|
|
||||||
|
|
||||||
raw_data['tracks']['items'] = tracks
|
|
||||||
raw_data['_batch_enabled'] = False
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
return {"error": f"Failed to get playlist data: {str(e)}"}
|
|
||||||
|
|
||||||
elif url_info["type"] == "album":
|
|
||||||
try:
|
|
||||||
album_data = get_json_from_api(
|
|
||||||
album_base_url.format(url_info["id"]),
|
|
||||||
access_token
|
|
||||||
)
|
|
||||||
if not album_data:
|
|
||||||
return {"error": "Failed to get album data"}
|
|
||||||
|
|
||||||
album_data['_token'] = access_token
|
|
||||||
raw_data = album_data
|
|
||||||
total_tracks = album_data.get('total_tracks', 0)
|
|
||||||
|
|
||||||
if batch:
|
|
||||||
tracks_url = f'{album_base_url.format(url_info["id"])}/tracks?limit=50'
|
|
||||||
tracks, num_batches = fetch_tracks_in_batches(tracks_url, access_token, 50, delay)
|
|
||||||
raw_data['tracks']['items'] = tracks
|
|
||||||
raw_data['_batch_count'] = num_batches
|
|
||||||
raw_data['_batch_enabled'] = True
|
|
||||||
|
|
||||||
if len(tracks) < total_tracks:
|
|
||||||
last_offset = len(tracks)
|
|
||||||
remaining_tracks = []
|
|
||||||
|
|
||||||
while last_offset < total_tracks:
|
|
||||||
print(f"Batch : {num_batches}")
|
|
||||||
print(f"Offset : {last_offset}")
|
|
||||||
print("-------------")
|
|
||||||
|
|
||||||
remainder_url = f'{album_base_url.format(url_info["id"])}/tracks?offset={last_offset}&limit=50'
|
|
||||||
track_data = get_json_from_api(remainder_url, access_token)
|
|
||||||
|
|
||||||
if not track_data or not track_data.get('items'):
|
|
||||||
break
|
|
||||||
|
|
||||||
items = track_data.get('items', [])
|
|
||||||
remaining_tracks.extend(items)
|
|
||||||
|
|
||||||
if len(items) < 50:
|
|
||||||
break
|
|
||||||
|
|
||||||
last_offset += len(items)
|
|
||||||
num_batches += 1
|
|
||||||
|
|
||||||
if delay > 0:
|
|
||||||
sleep(delay)
|
|
||||||
|
|
||||||
tracks.extend(remaining_tracks)
|
|
||||||
raw_data['tracks']['items'] = tracks
|
|
||||||
raw_data['_batch_count'] = num_batches
|
|
||||||
else:
|
|
||||||
tracks = []
|
|
||||||
tracks_url = f'{album_base_url.format(url_info["id"])}/tracks?limit=50'
|
|
||||||
while tracks_url:
|
|
||||||
track_data = get_json_from_api(tracks_url, access_token)
|
|
||||||
if not track_data:
|
|
||||||
break
|
|
||||||
|
|
||||||
tracks.extend(track_data['items'])
|
|
||||||
tracks_url = track_data.get('next')
|
|
||||||
if tracks_url and "&locale=" in tracks_url:
|
|
||||||
tracks_url = tracks_url.split("&locale=")[0]
|
|
||||||
|
|
||||||
raw_data['tracks']['items'] = tracks
|
|
||||||
raw_data['_batch_enabled'] = False
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
return {"error": f"Failed to get album data: {str(e)}"}
|
|
||||||
|
|
||||||
elif url_info["type"] == "track":
|
|
||||||
try:
|
|
||||||
track_data = get_json_from_api(
|
|
||||||
track_base_url.format(url_info["id"]),
|
|
||||||
access_token
|
|
||||||
)
|
|
||||||
if not track_data:
|
|
||||||
return {"error": "Failed to get track data"}
|
|
||||||
|
|
||||||
raw_data = track_data
|
|
||||||
except Exception as e:
|
|
||||||
return {"error": f"Failed to get track data: {str(e)}"}
|
|
||||||
|
|
||||||
return raw_data
|
|
||||||
|
|
||||||
def format_track_data(track_data):
|
|
||||||
artists = []
|
|
||||||
for artist in track_data.get('artists', []):
|
|
||||||
artists.append(artist['name'])
|
|
||||||
|
|
||||||
image_url = track_data.get('album', {}).get('images', [{}])[0].get('url', '') if track_data.get('album', {}).get('images') else ''
|
|
||||||
|
|
||||||
isrc = track_data.get('external_ids', {}).get('isrc', '')
|
|
||||||
|
|
||||||
return {
|
|
||||||
"track": {
|
|
||||||
"artists": ", ".join(artists),
|
|
||||||
"name": track_data.get('name', ''),
|
|
||||||
"album_name": track_data.get('album', {}).get('name', ''),
|
|
||||||
"duration_ms": track_data.get('duration_ms', 0),
|
|
||||||
"images": image_url,
|
|
||||||
"release_date": track_data.get('album', {}).get('release_date', ''),
|
|
||||||
"track_number": track_data.get('track_number', 0),
|
|
||||||
"external_urls": track_data.get('external_urls', {}).get('spotify', ''),
|
|
||||||
"isrc": isrc
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
def format_album_data(album_data):
|
|
||||||
artists = []
|
|
||||||
for artist in album_data.get('artists', []):
|
|
||||||
artists.append(artist['name'])
|
|
||||||
|
|
||||||
image_url = album_data.get('images', [{}])[0].get('url', '') if album_data.get('images') else ''
|
|
||||||
|
|
||||||
track_list = []
|
|
||||||
for track in album_data.get('tracks', {}).get('items', []):
|
|
||||||
track_artists = []
|
|
||||||
for artist in track.get('artists', []):
|
|
||||||
track_artists.append(artist['name'])
|
|
||||||
|
|
||||||
track_id = track.get('id', '')
|
|
||||||
track_isrc = ''
|
|
||||||
|
|
||||||
if track_id and album_data.get('_token'):
|
|
||||||
try:
|
|
||||||
full_track_data = get_json_from_api(
|
|
||||||
track_base_url.format(track_id),
|
|
||||||
album_data.get('_token')
|
|
||||||
)
|
|
||||||
if full_track_data:
|
|
||||||
track_isrc = full_track_data.get('external_ids', {}).get('isrc', '')
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
track_list.append({
|
|
||||||
"artists": ", ".join(track_artists),
|
|
||||||
"name": track.get('name', ''),
|
|
||||||
"album_name": album_data.get('name', ''),
|
|
||||||
"duration_ms": track.get('duration_ms', 0),
|
|
||||||
"images": image_url,
|
|
||||||
"release_date": album_data.get('release_date', ''),
|
|
||||||
"track_number": track.get('track_number', 0),
|
|
||||||
"external_urls": track.get('external_urls', {}).get('spotify', ''),
|
|
||||||
"isrc": track_isrc
|
|
||||||
})
|
|
||||||
|
|
||||||
album_info = {
|
|
||||||
"total_tracks": album_data.get('total_tracks', 0),
|
|
||||||
"name": album_data.get('name', ''),
|
|
||||||
"release_date": album_data.get('release_date', ''),
|
|
||||||
"artists": ", ".join(artists),
|
|
||||||
"images": image_url
|
|
||||||
}
|
|
||||||
|
|
||||||
if album_data.get('_batch_enabled', False):
|
|
||||||
album_info["batch"] = f"{album_data.get('_batch_count', 1)}"
|
|
||||||
|
|
||||||
return {
|
|
||||||
"album_info": album_info,
|
|
||||||
"track_list": track_list
|
|
||||||
}
|
|
||||||
|
|
||||||
def format_playlist_data(playlist_data):
|
|
||||||
image_url = playlist_data.get('images', [{}])[0].get('url', '') if playlist_data.get('images') else ''
|
|
||||||
|
|
||||||
track_list = []
|
|
||||||
for item in playlist_data.get('tracks', {}).get('items', []):
|
|
||||||
track = item.get('track', {})
|
|
||||||
if not track:
|
|
||||||
continue
|
|
||||||
|
|
||||||
artists = []
|
|
||||||
for artist in track.get('artists', []):
|
|
||||||
artists.append(artist['name'])
|
|
||||||
|
|
||||||
track_image = ''
|
|
||||||
if track.get('album', {}).get('images'):
|
|
||||||
track_image = track.get('album', {}).get('images', [{}])[0].get('url', '')
|
|
||||||
|
|
||||||
track_isrc = track.get('external_ids', {}).get('isrc', '')
|
|
||||||
|
|
||||||
track_list.append({
|
|
||||||
"artists": ", ".join(artists),
|
|
||||||
"name": track.get('name', ''),
|
|
||||||
"album_name": track.get('album', {}).get('name', ''),
|
|
||||||
"duration_ms": track.get('duration_ms', 0),
|
|
||||||
"images": track_image,
|
|
||||||
"release_date": track.get('album', {}).get('release_date', ''),
|
|
||||||
"track_number": track.get('track_number', 0),
|
|
||||||
"external_urls": track.get('external_urls', {}).get('spotify', ''),
|
|
||||||
"isrc": track_isrc
|
|
||||||
})
|
|
||||||
|
|
||||||
playlist_info = {
|
|
||||||
"tracks": {"total": playlist_data.get('tracks', {}).get('total', 0)},
|
|
||||||
"followers": {"total": playlist_data.get('followers', {}).get('total', 0)},
|
|
||||||
"owner": {
|
|
||||||
"display_name": playlist_data.get('owner', {}).get('display_name', ''),
|
|
||||||
"name": playlist_data.get('name', ''),
|
|
||||||
"images": image_url
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if playlist_data.get('_batch_enabled', False):
|
|
||||||
playlist_info["batch"] = f"{playlist_data.get('_batch_count', 1)}"
|
|
||||||
|
|
||||||
return {
|
|
||||||
"playlist_info": playlist_info,
|
|
||||||
"track_list": track_list
|
|
||||||
}
|
|
||||||
|
|
||||||
def process_spotify_data(raw_data, data_type):
|
|
||||||
if not raw_data or "error" in raw_data:
|
|
||||||
return {"error": "Invalid data provided"}
|
|
||||||
|
|
||||||
try:
|
|
||||||
if data_type == "track":
|
|
||||||
return format_track_data(raw_data)
|
|
||||||
elif data_type == "album":
|
|
||||||
return format_album_data(raw_data)
|
|
||||||
elif data_type == "playlist":
|
|
||||||
return format_playlist_data(raw_data)
|
|
||||||
else:
|
|
||||||
return {"error": "Invalid data type"}
|
|
||||||
except Exception as e:
|
|
||||||
return {"error": f"Error processing data: {str(e)}"}
|
|
||||||
|
|
||||||
def get_filtered_data(spotify_url, batch=False, delay=1.0):
|
|
||||||
raw_data = get_raw_spotify_data(spotify_url, batch=batch, delay=delay)
|
|
||||||
if raw_data and "error" not in raw_data:
|
|
||||||
url_info = parse_uri(spotify_url)
|
|
||||||
filtered_data = process_spotify_data(raw_data, url_info['type'])
|
|
||||||
return filtered_data
|
|
||||||
return {"error": "Failed to get raw data"}
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
playlist = "https://open.spotify.com/playlist/37i9dQZEVXbNG2KDcFcKOF"
|
|
||||||
album = "https://open.spotify.com/album/6J84szYCnMfzEcvIcfWMFL"
|
|
||||||
song = "https://open.spotify.com/track/7so0lgd0zP2Sbgs2d7a1SZ"
|
|
||||||
|
|
||||||
filtered_playlist = get_filtered_data(playlist, batch=True, delay=0.1)
|
|
||||||
print(json.dumps(filtered_playlist, indent=2))
|
|
||||||
|
|
||||||
filtered_album = get_filtered_data(album)
|
|
||||||
print(json.dumps(filtered_album, indent=2))
|
|
||||||
|
|
||||||
filtered_track = get_filtered_data(song)
|
|
||||||
print(json.dumps(filtered_track, indent=2))
|
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
module spotiflac
|
||||||
|
|
||||||
|
go 1.25.4
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/go-flac/flacpicture v0.3.0
|
||||||
|
github.com/go-flac/flacvorbis v0.2.0
|
||||||
|
github.com/go-flac/go-flac v1.0.0
|
||||||
|
github.com/wailsapp/wails/v2 v2.11.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/bep/debounce v1.2.1 // indirect
|
||||||
|
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||||
|
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/gorilla/websocket v1.5.3 // indirect
|
||||||
|
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
|
||||||
|
github.com/labstack/echo/v4 v4.13.3 // indirect
|
||||||
|
github.com/labstack/gommon v0.4.2 // indirect
|
||||||
|
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
|
||||||
|
github.com/leaanthony/gosod v1.0.4 // indirect
|
||||||
|
github.com/leaanthony/slicer v1.6.0 // indirect
|
||||||
|
github.com/leaanthony/u v1.1.1 // indirect
|
||||||
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
||||||
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
|
github.com/samber/lo v1.49.1 // indirect
|
||||||
|
github.com/tkrajina/go-reflector v0.5.8 // indirect
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
|
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||||
|
github.com/wailsapp/go-webview2 v1.0.22 // indirect
|
||||||
|
github.com/wailsapp/mimetype v1.4.1 // indirect
|
||||||
|
golang.org/x/crypto v0.33.0 // indirect
|
||||||
|
golang.org/x/net v0.35.0 // indirect
|
||||||
|
golang.org/x/sys v0.30.0 // indirect
|
||||||
|
golang.org/x/text v0.22.0 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||||
|
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/go-flac/flacpicture v0.3.0 h1:LkmTxzFLIynwfhHiZsX0s8xcr3/u33MzvV89u+zOT8I=
|
||||||
|
github.com/go-flac/flacpicture v0.3.0/go.mod h1:DPbrzVYQ3fJcvSgLFp9HXIrEQEdfdk/+m0nQCzwodZI=
|
||||||
|
github.com/go-flac/flacvorbis v0.2.0 h1:KH0xjpkNTXFER4cszH4zeJxYcrHbUobz/RticWGOESs=
|
||||||
|
github.com/go-flac/flacvorbis v0.2.0/go.mod h1:uIysHOtuU7OLGoCRG92bvnkg7QEqHx19qKRV6K1pBrI=
|
||||||
|
github.com/go-flac/go-flac v1.0.0 h1:6qI9XOVLcO50xpzm3nXvO31BgDgHhnr/p/rER/K/doY=
|
||||||
|
github.com/go-flac/go-flac v1.0.0/go.mod h1:WnZhcpmq4u1UdZMNn9LYSoASpWOCMOoxXxcWEHSzkW8=
|
||||||
|
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||||
|
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||||
|
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
||||||
|
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
|
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
|
||||||
|
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
|
||||||
|
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
|
||||||
|
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
|
||||||
|
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
|
||||||
|
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
|
||||||
|
github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc=
|
||||||
|
github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=
|
||||||
|
github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
|
||||||
|
github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
|
||||||
|
github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI=
|
||||||
|
github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw=
|
||||||
|
github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js=
|
||||||
|
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
|
||||||
|
github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
|
||||||
|
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
|
||||||
|
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||||
|
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
|
||||||
|
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||||
|
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||||
|
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||||
|
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||||
|
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||||
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
|
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
|
||||||
|
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
|
||||||
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
|
||||||
|
github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||||
|
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
||||||
|
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||||
|
github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58=
|
||||||
|
github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
|
||||||
|
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
|
||||||
|
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
|
||||||
|
github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ=
|
||||||
|
github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k=
|
||||||
|
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
|
||||||
|
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
|
||||||
|
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
|
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
||||||
|
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
||||||
|
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||||
|
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||||
|
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/wailsapp/wails/v2"
|
||||||
|
"github.com/wailsapp/wails/v2/pkg/options"
|
||||||
|
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
|
||||||
|
"github.com/wailsapp/wails/v2/pkg/options/windows"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed all:frontend/dist
|
||||||
|
var assets embed.FS
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Create an instance of the app structure
|
||||||
|
app := NewApp()
|
||||||
|
|
||||||
|
// Create application with options
|
||||||
|
err := wails.Run(&options.App{
|
||||||
|
Title: "SpotiFLAC",
|
||||||
|
Width: 1024,
|
||||||
|
Height: 600,
|
||||||
|
Frameless: true,
|
||||||
|
AssetServer: &assetserver.Options{
|
||||||
|
Assets: assets,
|
||||||
|
},
|
||||||
|
BackgroundColour: &options.RGBA{R: 0, G: 0, B: 0, A: 255},
|
||||||
|
OnStartup: app.startup,
|
||||||
|
Bind: []interface{}{
|
||||||
|
app,
|
||||||
|
},
|
||||||
|
Windows: &windows.Options{
|
||||||
|
WebviewIsTransparent: false,
|
||||||
|
WindowIsTranslucent: false,
|
||||||
|
DisableWindowIcon: false,
|
||||||
|
DisableFramelessWindowDecorations: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("Error:", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
-271
@@ -1,271 +0,0 @@
|
|||||||
import requests
|
|
||||||
import time
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
from datetime import datetime
|
|
||||||
from mutagen.flac import FLAC, Picture
|
|
||||||
from mutagen.id3 import PictureType
|
|
||||||
|
|
||||||
class ProgressCallback:
|
|
||||||
def __call__(self, current, total):
|
|
||||||
if total > 0:
|
|
||||||
percent = (current / total) * 100
|
|
||||||
print(f"\r{percent:.2f}% ({current}/{total})", end="")
|
|
||||||
else:
|
|
||||||
print(f"\r{current / (1024 * 1024):.2f} MB", end="")
|
|
||||||
|
|
||||||
class QobuzDownloader:
|
|
||||||
def __init__(self, region="us", timeout=30):
|
|
||||||
if region not in ["eu", "us"]:
|
|
||||||
raise ValueError("Region must be either 'us' or 'eu'")
|
|
||||||
|
|
||||||
self.region = region
|
|
||||||
self.timeout = timeout
|
|
||||||
self.session = requests.Session()
|
|
||||||
self.headers = {
|
|
||||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
|
|
||||||
}
|
|
||||||
self.base_api_url = f"https://{region}.qobuz.squid.wtf/api"
|
|
||||||
self.download_chunk_size = 256 * 1024
|
|
||||||
self.progress_callback = ProgressCallback()
|
|
||||||
|
|
||||||
def set_progress_callback(self, callback):
|
|
||||||
self.progress_callback = callback
|
|
||||||
|
|
||||||
def sanitize_filename(self, filename):
|
|
||||||
if not filename:
|
|
||||||
return "Unknown Track"
|
|
||||||
sanitized = re.sub(r'[\\/*?:"<>|]', "", str(filename))
|
|
||||||
return re.sub(r'\s+', ' ', sanitized).strip() or "Unnamed Track"
|
|
||||||
|
|
||||||
def get_track_info(self, isrc):
|
|
||||||
print(f"Fetching: {isrc}")
|
|
||||||
search_url = f"{self.base_api_url}/get-music"
|
|
||||||
params = {'q': isrc, 'offset': 0, 'limit': 10}
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = self.session.get(search_url, params=params, timeout=self.timeout)
|
|
||||||
response.raise_for_status()
|
|
||||||
data = response.json()
|
|
||||||
|
|
||||||
selected_track = None
|
|
||||||
if data and data.get("success"):
|
|
||||||
items = data.get("data", {}).get("tracks", {}).get("items", [])
|
|
||||||
priority = {24: 1, 16: 2}
|
|
||||||
for track in items:
|
|
||||||
if track.get("isrc") == isrc:
|
|
||||||
current_prio = priority.get(track.get("maximum_bit_depth"), 3)
|
|
||||||
if selected_track is None or current_prio < priority.get(selected_track.get("maximum_bit_depth"), 3):
|
|
||||||
selected_track = track
|
|
||||||
if current_prio == 1:
|
|
||||||
break
|
|
||||||
|
|
||||||
if not selected_track:
|
|
||||||
raise Exception(f"Track not found: {isrc}")
|
|
||||||
|
|
||||||
title = selected_track.get('title', 'Unknown')
|
|
||||||
bit_depth = selected_track.get('maximum_bit_depth', 'Unknown')
|
|
||||||
print(f"Found: {title} ({bit_depth}b)")
|
|
||||||
return selected_track
|
|
||||||
|
|
||||||
except requests.exceptions.RequestException as e:
|
|
||||||
raise Exception(f"Request error: {e}")
|
|
||||||
except Exception as e:
|
|
||||||
raise Exception(f"Error: {e}")
|
|
||||||
|
|
||||||
def get_download_url(self, track_id):
|
|
||||||
print("Fetching URL...")
|
|
||||||
download_api_url = f"{self.base_api_url}/download-music"
|
|
||||||
params = {'track_id': track_id, 'quality': 27}
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = self.session.get(download_api_url, params=params, timeout=self.timeout)
|
|
||||||
response.raise_for_status()
|
|
||||||
data = response.json()
|
|
||||||
|
|
||||||
if data and data.get("success") and data.get("data", {}).get("url"):
|
|
||||||
download_url = data["data"]["url"]
|
|
||||||
print("URL found")
|
|
||||||
return download_url
|
|
||||||
else:
|
|
||||||
error_msg = data.get('error', {}).get('message', 'Unknown API error')
|
|
||||||
raise Exception(f"API error: {error_msg}")
|
|
||||||
|
|
||||||
except requests.exceptions.RequestException as e:
|
|
||||||
raise Exception(f"Request error: {e}")
|
|
||||||
except Exception as e:
|
|
||||||
raise Exception(f"Error: {e}")
|
|
||||||
|
|
||||||
def download(self, isrc, output_dir=".", is_paused_callback=None, is_stopped_callback=None):
|
|
||||||
if output_dir != ".":
|
|
||||||
try:
|
|
||||||
os.makedirs(output_dir, exist_ok=True)
|
|
||||||
except OSError as e:
|
|
||||||
raise Exception(f"Directory error: {e}")
|
|
||||||
|
|
||||||
track_info = self.get_track_info(isrc)
|
|
||||||
track_id = track_info.get("id")
|
|
||||||
|
|
||||||
if not track_id:
|
|
||||||
raise Exception("No track ID found")
|
|
||||||
|
|
||||||
artist_name = self.sanitize_filename(track_info.get('performer', {}).get('name'))
|
|
||||||
track_title = self.sanitize_filename(track_info.get('title'))
|
|
||||||
output_filename = os.path.join(output_dir, f"{artist_name} - {track_title}.flac")
|
|
||||||
|
|
||||||
if os.path.exists(output_filename):
|
|
||||||
file_size = os.path.getsize(output_filename)
|
|
||||||
if file_size > 0:
|
|
||||||
print(f"File already exists: {output_filename} ({file_size / (1024 * 1024):.2f} MB)")
|
|
||||||
return output_filename
|
|
||||||
|
|
||||||
download_url = self.get_download_url(track_id)
|
|
||||||
temp_filename = output_filename + ".part"
|
|
||||||
|
|
||||||
print(f"Downloading...")
|
|
||||||
try:
|
|
||||||
with self.session.get(download_url, stream=True, timeout=900) as response, \
|
|
||||||
open(temp_filename, 'wb') as f:
|
|
||||||
response.raise_for_status()
|
|
||||||
total_size = int(response.headers.get('content-length', 0))
|
|
||||||
downloaded_size = 0
|
|
||||||
start_time = time.time()
|
|
||||||
last_update_time = start_time
|
|
||||||
|
|
||||||
for chunk in response.iter_content(chunk_size=self.download_chunk_size):
|
|
||||||
if is_stopped_callback and is_stopped_callback():
|
|
||||||
f.close()
|
|
||||||
if os.path.exists(temp_filename):
|
|
||||||
os.remove(temp_filename)
|
|
||||||
raise Exception("Download stopped")
|
|
||||||
|
|
||||||
while is_paused_callback and is_paused_callback():
|
|
||||||
time.sleep(0.1)
|
|
||||||
if is_stopped_callback and is_stopped_callback():
|
|
||||||
f.close()
|
|
||||||
if os.path.exists(temp_filename):
|
|
||||||
os.remove(temp_filename)
|
|
||||||
raise Exception("Download stopped")
|
|
||||||
f.write(chunk)
|
|
||||||
downloaded_size += len(chunk)
|
|
||||||
|
|
||||||
current_time = time.time()
|
|
||||||
if current_time - last_update_time >= 1:
|
|
||||||
if total_size > 0:
|
|
||||||
progress_percent = (downloaded_size / total_size) * 100
|
|
||||||
elapsed_time = current_time - start_time
|
|
||||||
speed = downloaded_size / (1024 * 1024 * elapsed_time) if elapsed_time > 0 else 0
|
|
||||||
print(f"{progress_percent:.2f}% - {speed:.2f} MB/s")
|
|
||||||
else:
|
|
||||||
print(f"{downloaded_size / (1024 * 1024):.2f} MB")
|
|
||||||
|
|
||||||
last_update_time = current_time
|
|
||||||
|
|
||||||
if self.progress_callback:
|
|
||||||
self.progress_callback(downloaded_size, total_size)
|
|
||||||
|
|
||||||
os.rename(temp_filename, output_filename)
|
|
||||||
print("Download complete")
|
|
||||||
|
|
||||||
except requests.exceptions.RequestException as e:
|
|
||||||
if os.path.exists(temp_filename):
|
|
||||||
os.remove(temp_filename)
|
|
||||||
raise Exception(f"Download failed: {e}")
|
|
||||||
except Exception as e:
|
|
||||||
if os.path.exists(temp_filename):
|
|
||||||
os.remove(temp_filename)
|
|
||||||
raise Exception(f"File error: {e}")
|
|
||||||
|
|
||||||
print("Adding metadata...")
|
|
||||||
try:
|
|
||||||
self._embed_metadata(output_filename, track_info)
|
|
||||||
print("Metadata saved")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Tagging failed: {e}")
|
|
||||||
|
|
||||||
print(f"Done")
|
|
||||||
return output_filename
|
|
||||||
|
|
||||||
def _embed_metadata(self, filename, track_info):
|
|
||||||
try:
|
|
||||||
audio = FLAC(filename)
|
|
||||||
audio.delete()
|
|
||||||
audio.clear_pictures()
|
|
||||||
|
|
||||||
album_info = track_info.get('album', {})
|
|
||||||
artist = track_info.get('performer', {}).get('name')
|
|
||||||
|
|
||||||
if track_info.get('title'):
|
|
||||||
audio['TITLE'] = track_info['title']
|
|
||||||
if artist:
|
|
||||||
audio['ARTIST'] = artist
|
|
||||||
if album_info.get('title'):
|
|
||||||
audio['ALBUM'] = album_info['title']
|
|
||||||
if album_info.get('artist', {}).get('name', artist):
|
|
||||||
audio['ALBUMARTIST'] = album_info.get('artist', {}).get('name', artist)
|
|
||||||
if track_info.get('track_number'):
|
|
||||||
audio['TRACKNUMBER'] = str(track_info['track_number'])
|
|
||||||
if track_info.get('release_date_original'):
|
|
||||||
audio['DATE'] = track_info['release_date_original']
|
|
||||||
try:
|
|
||||||
audio['YEAR'] = str(datetime.strptime(track_info['release_date_original'], '%Y-%m-%d').year)
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
if album_info.get('genre', {}).get('name'):
|
|
||||||
audio['GENRE'] = album_info['genre']['name']
|
|
||||||
if track_info.get('copyright'):
|
|
||||||
audio['COPYRIGHT'] = track_info['copyright']
|
|
||||||
if track_info.get('isrc'):
|
|
||||||
audio['ISRC'] = track_info['isrc']
|
|
||||||
if album_info.get('label', {}).get('name'):
|
|
||||||
audio['ORGANIZATION'] = album_info['label']['name']
|
|
||||||
|
|
||||||
img_info = album_info.get('image', {})
|
|
||||||
cover_url = img_info.get('large') or img_info.get('small') or img_info.get('thumbnail')
|
|
||||||
if cover_url:
|
|
||||||
try:
|
|
||||||
img_response = self.session.get(cover_url, timeout=30)
|
|
||||||
img_response.raise_for_status()
|
|
||||||
mime_type = img_response.headers.get('Content-Type', 'image/jpeg').lower()
|
|
||||||
if mime_type in ['image/jpeg', 'image/png']:
|
|
||||||
picture = Picture()
|
|
||||||
picture.data = img_response.content
|
|
||||||
picture.type = PictureType.COVER_FRONT
|
|
||||||
picture.mime = mime_type
|
|
||||||
audio.add_picture(picture)
|
|
||||||
print("Cover added")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Cover error: {str(e)}")
|
|
||||||
|
|
||||||
audio.save()
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
raise Exception(f"Metadata error: {e}")
|
|
||||||
|
|
||||||
def main():
|
|
||||||
print("=== QobuzDL - Qobuz Downloader ===")
|
|
||||||
downloader = QobuzDownloader(region="us")
|
|
||||||
|
|
||||||
isrc = "USAT22409172"
|
|
||||||
output_dir = "."
|
|
||||||
|
|
||||||
try:
|
|
||||||
downloaded_file = downloader.download(isrc, output_dir)
|
|
||||||
print(f"Success: File saved as {downloaded_file}")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error: {str(e)}")
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
main()
|
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
[
|
||||||
|
"vogel.qqdl.site",
|
||||||
|
"maus.qqdl.site",
|
||||||
|
"hund.qqdl.site",
|
||||||
|
"eu-maus.qqdl.site",
|
||||||
|
"eu-katze.qqdl.site",
|
||||||
|
"katze.qqdl.site",
|
||||||
|
"wolf.qqdl.site",
|
||||||
|
"tidal.kinoplus.online",
|
||||||
|
"jakarta.monochrome.tf"
|
||||||
|
]
|
||||||
-454
@@ -1,454 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import tempfile
|
|
||||||
import time
|
|
||||||
import httpx
|
|
||||||
from mutagen.flac import FLAC, Picture
|
|
||||||
from mutagen.id3 import PictureType
|
|
||||||
|
|
||||||
class ProgressCallback:
|
|
||||||
def __call__(self, current, total):
|
|
||||||
if total > 0:
|
|
||||||
percent = (current / total) * 100
|
|
||||||
print(f"\r{percent:.2f}% ({current}/{total})", end="")
|
|
||||||
else:
|
|
||||||
print(f"\r{current / (1024 * 1024):.2f} MB", end="")
|
|
||||||
|
|
||||||
class TidalDownloader:
|
|
||||||
def __init__(self, timeout=30, max_retries=3):
|
|
||||||
self.timeout = timeout
|
|
||||||
self.max_retries = max_retries
|
|
||||||
self.download_chunk_size = 256 * 1024
|
|
||||||
self.progress_callback = ProgressCallback()
|
|
||||||
self.client_id = "zU4XHVVkc2tDPo4t"
|
|
||||||
self.client_secret = "VJKhDFqJPqvsPVNBV6ukXTJmwlvbttP7wlMlrc72se4="
|
|
||||||
self.temp_dir = tempfile.gettempdir()
|
|
||||||
self.token_path = os.path.join(self.temp_dir, "tidal_token.json")
|
|
||||||
self.access_token = None
|
|
||||||
self._load_token()
|
|
||||||
|
|
||||||
def set_progress_callback(self, callback):
|
|
||||||
self.progress_callback = callback
|
|
||||||
|
|
||||||
def _load_token(self):
|
|
||||||
if os.path.exists(self.token_path):
|
|
||||||
try:
|
|
||||||
with open(self.token_path, "r") as tok:
|
|
||||||
token = json.loads(tok.read())
|
|
||||||
self.access_token = token.get("access_token")
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def sanitize_filename(self, filename):
|
|
||||||
if not filename:
|
|
||||||
return "Unknown Track"
|
|
||||||
sanitized = re.sub(r'[\\/*?:"<>|]', "", str(filename))
|
|
||||||
return re.sub(r'\s+', ' ', sanitized).strip() or "Unnamed Track"
|
|
||||||
|
|
||||||
async def get_access_token(self):
|
|
||||||
if self.access_token:
|
|
||||||
return self.access_token
|
|
||||||
|
|
||||||
refresh_url = "https://auth.tidal.com/v1/oauth2/token"
|
|
||||||
|
|
||||||
payload = {
|
|
||||||
"client_id": self.client_id,
|
|
||||||
"grant_type": "client_credentials",
|
|
||||||
}
|
|
||||||
|
|
||||||
async with httpx.AsyncClient(http2=True) as client:
|
|
||||||
try:
|
|
||||||
response = await client.post(
|
|
||||||
url=refresh_url,
|
|
||||||
data=payload,
|
|
||||||
auth=(self.client_id, self.client_secret),
|
|
||||||
)
|
|
||||||
|
|
||||||
if response.status_code == 200:
|
|
||||||
token_data = response.json()
|
|
||||||
new_token = token_data.get("access_token")
|
|
||||||
|
|
||||||
try:
|
|
||||||
with open(self.token_path, "w") as f:
|
|
||||||
json.dump({
|
|
||||||
"access_token": new_token
|
|
||||||
}, f)
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
self.access_token = new_token
|
|
||||||
return new_token
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
|
|
||||||
except:
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def search_tracks(self, query):
|
|
||||||
try:
|
|
||||||
tidal_token = await self.get_access_token()
|
|
||||||
if not tidal_token:
|
|
||||||
raise Exception("Failed to get access token")
|
|
||||||
|
|
||||||
search_url = f"https://api.tidal.com/v1/search/tracks?query={query}&limit=25&offset=0&countryCode=US"
|
|
||||||
header = {"authorization": f"Bearer {tidal_token}"}
|
|
||||||
|
|
||||||
async with httpx.AsyncClient(http2=True) as client:
|
|
||||||
search_data = await client.get(url=search_url, headers=header)
|
|
||||||
response_data = search_data.json()
|
|
||||||
|
|
||||||
filtered_items = [{
|
|
||||||
"id": item.get("id"),
|
|
||||||
"title": item.get("title"),
|
|
||||||
"url": item.get("url"),
|
|
||||||
"isrc": item.get("isrc"),
|
|
||||||
"audioQuality": item.get("audioQuality"),
|
|
||||||
"mediaMetadata": item.get("mediaMetadata"),
|
|
||||||
"album": item.get("album", {}),
|
|
||||||
"artists": item.get("artists", []),
|
|
||||||
"artist": item.get("artist", {}),
|
|
||||||
"trackNumber": item.get("trackNumber"),
|
|
||||||
"volumeNumber": item.get("volumeNumber"),
|
|
||||||
"duration": item.get("duration"),
|
|
||||||
"copyright": item.get("copyright"),
|
|
||||||
"explicit": item.get("explicit")
|
|
||||||
} for item in response_data.get("items", [])]
|
|
||||||
|
|
||||||
return {
|
|
||||||
"limit": response_data.get("limit"),
|
|
||||||
"offset": response_data.get("offset"),
|
|
||||||
"totalNumberOfItems": response_data.get("totalNumberOfItems"),
|
|
||||||
"items": filtered_items
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
raise Exception(f"Search error: {str(e)}")
|
|
||||||
|
|
||||||
async def get_track_info(self, query, isrc=None):
|
|
||||||
print(f"Fetching: {query}" + (f" (ISRC: {isrc})" if isrc else ""))
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = await self.search_tracks(query)
|
|
||||||
|
|
||||||
if not result or not result.get("items"):
|
|
||||||
raise Exception(f"No tracks found for query: {query}")
|
|
||||||
|
|
||||||
selected_track = None
|
|
||||||
if isrc:
|
|
||||||
isrc_items = [item for item in result["items"] if item.get("isrc") == isrc]
|
|
||||||
|
|
||||||
if len(isrc_items) > 1:
|
|
||||||
hires_items = []
|
|
||||||
for item in isrc_items:
|
|
||||||
media_metadata = item.get("mediaMetadata", {})
|
|
||||||
tags = media_metadata.get("tags", []) if media_metadata else []
|
|
||||||
if "HIRES_LOSSLESS" in tags:
|
|
||||||
hires_items.append(item)
|
|
||||||
|
|
||||||
if hires_items:
|
|
||||||
selected_track = hires_items[0]
|
|
||||||
else:
|
|
||||||
selected_track = isrc_items[0]
|
|
||||||
elif len(isrc_items) == 1:
|
|
||||||
selected_track = isrc_items[0]
|
|
||||||
else:
|
|
||||||
selected_track = result["items"][0]
|
|
||||||
else:
|
|
||||||
selected_track = result["items"][0]
|
|
||||||
|
|
||||||
if not selected_track:
|
|
||||||
raise Exception(f"Track not found: {query}" + (f" (ISRC: {isrc})" if isrc else ""))
|
|
||||||
|
|
||||||
title = selected_track.get('title', 'Unknown')
|
|
||||||
quality = selected_track.get('audioQuality', 'Unknown')
|
|
||||||
print(f"Found: {title} ({quality})")
|
|
||||||
return selected_track
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
raise Exception(f"Error getting track info: {str(e)}")
|
|
||||||
|
|
||||||
async def get_download_url(self, track_id, quality="LOSSLESS"):
|
|
||||||
print("Fetching URL...")
|
|
||||||
download_api_url = f"https://hifi.401658.xyz/track/?id={track_id}&quality={quality}"
|
|
||||||
|
|
||||||
async with httpx.AsyncClient(http2=True, timeout=self.timeout) as client:
|
|
||||||
try:
|
|
||||||
response = await client.get(download_api_url)
|
|
||||||
|
|
||||||
if response.status_code == 200:
|
|
||||||
data = response.json()
|
|
||||||
|
|
||||||
for item in data:
|
|
||||||
if "OriginalTrackUrl" in item:
|
|
||||||
print("URL found")
|
|
||||||
return {
|
|
||||||
"download_url": item["OriginalTrackUrl"],
|
|
||||||
"track_info": data[0] if data else {}
|
|
||||||
}
|
|
||||||
|
|
||||||
raise Exception("Download URL not found in response")
|
|
||||||
else:
|
|
||||||
raise Exception(f"API returned status code: {response.status_code}")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
raise Exception(f"Error getting download URL: {str(e)}")
|
|
||||||
|
|
||||||
async def download_album_art(self, album_id, size="1280x1280"):
|
|
||||||
try:
|
|
||||||
art_url = f"https://resources.tidal.com/images/{album_id.replace('-', '/')}/{size}.jpg"
|
|
||||||
|
|
||||||
async with httpx.AsyncClient(http2=True, timeout=self.timeout) as client:
|
|
||||||
response = await client.get(art_url)
|
|
||||||
|
|
||||||
if response.status_code == 200:
|
|
||||||
return response.content
|
|
||||||
else:
|
|
||||||
print(f"Failed to download album art: HTTP {response.status_code}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error downloading album art: {str(e)}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def download_file(self, url, filepath, is_paused_callback=None, is_stopped_callback=None):
|
|
||||||
temp_filepath = filepath + ".part"
|
|
||||||
retry_count = 0
|
|
||||||
|
|
||||||
while retry_count <= self.max_retries:
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(http2=True, timeout=60.0) as client:
|
|
||||||
async with client.stream('GET', url) as response:
|
|
||||||
if response.status_code != 200:
|
|
||||||
raise Exception(f"HTTP {response.status_code}")
|
|
||||||
|
|
||||||
total_size = int(response.headers.get('content-length', 0))
|
|
||||||
downloaded_size = 0
|
|
||||||
start_time = time.time()
|
|
||||||
last_update_time = start_time
|
|
||||||
|
|
||||||
with open(temp_filepath, 'wb') as f:
|
|
||||||
async for chunk in response.aiter_bytes(chunk_size=self.download_chunk_size):
|
|
||||||
if is_stopped_callback and is_stopped_callback():
|
|
||||||
f.close()
|
|
||||||
if os.path.exists(temp_filepath):
|
|
||||||
os.remove(temp_filepath)
|
|
||||||
raise Exception("Download stopped")
|
|
||||||
|
|
||||||
while is_paused_callback and is_paused_callback():
|
|
||||||
await asyncio.sleep(0.1)
|
|
||||||
if is_stopped_callback and is_stopped_callback():
|
|
||||||
f.close()
|
|
||||||
if os.path.exists(temp_filepath):
|
|
||||||
os.remove(temp_filepath)
|
|
||||||
raise Exception("Download stopped")
|
|
||||||
|
|
||||||
f.write(chunk)
|
|
||||||
downloaded_size += len(chunk)
|
|
||||||
|
|
||||||
current_time = time.time()
|
|
||||||
if current_time - last_update_time >= 1:
|
|
||||||
if total_size > 0:
|
|
||||||
progress_percent = (downloaded_size / total_size) * 100
|
|
||||||
elapsed_time = current_time - start_time
|
|
||||||
speed = downloaded_size / (1024 * 1024 * elapsed_time) if elapsed_time > 0 else 0
|
|
||||||
print(f"{progress_percent:.2f}% - {speed:.2f} MB/s")
|
|
||||||
else:
|
|
||||||
print(f"{downloaded_size / (1024 * 1024):.2f} MB")
|
|
||||||
|
|
||||||
last_update_time = current_time
|
|
||||||
|
|
||||||
if self.progress_callback:
|
|
||||||
self.progress_callback(downloaded_size, total_size)
|
|
||||||
|
|
||||||
os.rename(temp_filepath, filepath)
|
|
||||||
print("Download complete")
|
|
||||||
return {"success": True, "size": downloaded_size}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
retry_count += 1
|
|
||||||
if retry_count > self.max_retries:
|
|
||||||
if os.path.exists(temp_filepath):
|
|
||||||
try:
|
|
||||||
os.remove(temp_filepath)
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
raise Exception(f"Download error after {self.max_retries} retries: {str(e)}")
|
|
||||||
|
|
||||||
print(f"Download error (attempt {retry_count}/{self.max_retries}): {str(e)}")
|
|
||||||
print(f"Retrying in {retry_count * 2} seconds...")
|
|
||||||
await asyncio.sleep(retry_count * 2)
|
|
||||||
|
|
||||||
async def embed_metadata(self, filepath, track_info, search_info=None):
|
|
||||||
try:
|
|
||||||
print("Embedding metadata...")
|
|
||||||
audio = FLAC(filepath)
|
|
||||||
audio.clear()
|
|
||||||
audio.clear_pictures()
|
|
||||||
|
|
||||||
if track_info.get("title"):
|
|
||||||
audio["TITLE"] = track_info["title"]
|
|
||||||
|
|
||||||
artists_list = []
|
|
||||||
if search_info and search_info.get("artists"):
|
|
||||||
for artist in search_info["artists"]:
|
|
||||||
if artist.get("name"):
|
|
||||||
artists_list.append(artist["name"])
|
|
||||||
elif search_info and search_info.get("artist") and search_info["artist"].get("name"):
|
|
||||||
artists_list.append(search_info["artist"]["name"])
|
|
||||||
elif track_info.get("artists"):
|
|
||||||
for artist in track_info["artists"]:
|
|
||||||
if artist.get("name"):
|
|
||||||
artists_list.append(artist["name"])
|
|
||||||
elif track_info.get("artist") and track_info["artist"].get("name"):
|
|
||||||
artists_list.append(track_info["artist"]["name"])
|
|
||||||
|
|
||||||
if artists_list:
|
|
||||||
audio["ARTIST"] = artists_list[0]
|
|
||||||
if len(artists_list) > 1:
|
|
||||||
audio["ALBUMARTIST"] = "; ".join(artists_list)
|
|
||||||
else:
|
|
||||||
audio["ALBUMARTIST"] = artists_list[0]
|
|
||||||
|
|
||||||
album_info = search_info.get("album", {}) if search_info else track_info.get("album", {})
|
|
||||||
if album_info.get("title"):
|
|
||||||
audio["ALBUM"] = album_info["title"]
|
|
||||||
|
|
||||||
if search_info and search_info.get("trackNumber"):
|
|
||||||
audio["TRACKNUMBER"] = str(search_info["trackNumber"])
|
|
||||||
elif track_info.get("trackNumber"):
|
|
||||||
audio["TRACKNUMBER"] = str(track_info["trackNumber"])
|
|
||||||
|
|
||||||
if search_info and search_info.get("volumeNumber"):
|
|
||||||
audio["DISCNUMBER"] = str(search_info["volumeNumber"])
|
|
||||||
elif track_info.get("volumeNumber"):
|
|
||||||
audio["DISCNUMBER"] = str(track_info["volumeNumber"])
|
|
||||||
|
|
||||||
duration = search_info.get("duration") if search_info else track_info.get("duration")
|
|
||||||
if duration:
|
|
||||||
audio["LENGTH"] = str(duration)
|
|
||||||
|
|
||||||
isrc = search_info.get("isrc") if search_info else track_info.get("isrc")
|
|
||||||
if isrc:
|
|
||||||
audio["ISRC"] = isrc
|
|
||||||
|
|
||||||
copyright_info = search_info.get("copyright") if search_info else track_info.get("copyright")
|
|
||||||
if copyright_info:
|
|
||||||
audio["COPYRIGHT"] = copyright_info
|
|
||||||
|
|
||||||
if album_info.get("releaseDate"):
|
|
||||||
audio["DATE"] = album_info["releaseDate"][:4]
|
|
||||||
try:
|
|
||||||
audio["YEAR"] = album_info["releaseDate"][:4]
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if track_info.get("genre"):
|
|
||||||
audio["GENRE"] = track_info["genre"]
|
|
||||||
|
|
||||||
if track_info.get("audioQuality"):
|
|
||||||
audio["COMMENT"] = f"Tidal {track_info['audioQuality']}"
|
|
||||||
|
|
||||||
if album_info.get("cover"):
|
|
||||||
album_art = await self.download_album_art(album_info["cover"])
|
|
||||||
if album_art:
|
|
||||||
picture = Picture()
|
|
||||||
picture.data = album_art
|
|
||||||
picture.type = PictureType.COVER_FRONT
|
|
||||||
picture.mime = "image/jpeg"
|
|
||||||
picture.desc = "Cover"
|
|
||||||
audio.add_picture(picture)
|
|
||||||
print("Album art embedded")
|
|
||||||
|
|
||||||
audio.save()
|
|
||||||
print(f"Metadata embedded successfully for: {track_info.get('title', 'Unknown')}")
|
|
||||||
return True
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error embedding metadata: {str(e)}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def download(self, query, isrc=None, output_dir=".", quality="LOSSLESS", is_paused_callback=None, is_stopped_callback=None):
|
|
||||||
if output_dir != ".":
|
|
||||||
try:
|
|
||||||
os.makedirs(output_dir, exist_ok=True)
|
|
||||||
except OSError as e:
|
|
||||||
raise Exception(f"Directory error: {e}")
|
|
||||||
|
|
||||||
track_info = await self.get_track_info(query, isrc)
|
|
||||||
track_id = track_info.get("id")
|
|
||||||
|
|
||||||
if not track_id:
|
|
||||||
raise Exception("No track ID found")
|
|
||||||
|
|
||||||
artists_list = []
|
|
||||||
if track_info.get("artists"):
|
|
||||||
for artist in track_info["artists"]:
|
|
||||||
if artist.get("name"):
|
|
||||||
artists_list.append(artist["name"])
|
|
||||||
elif track_info.get("artist") and track_info["artist"].get("name"):
|
|
||||||
artists_list.append(track_info["artist"]["name"])
|
|
||||||
|
|
||||||
artist_name = ", ".join(artists_list) if artists_list else "Unknown Artist"
|
|
||||||
artist_name = self.sanitize_filename(artist_name)
|
|
||||||
track_title = self.sanitize_filename(track_info.get("title", f"track_{track_id}"))
|
|
||||||
|
|
||||||
output_filename = os.path.join(output_dir, f"{artist_name} - {track_title}.flac")
|
|
||||||
|
|
||||||
if os.path.exists(output_filename):
|
|
||||||
file_size = os.path.getsize(output_filename)
|
|
||||||
if file_size > 0:
|
|
||||||
print(f"File already exists: {output_filename} ({file_size / (1024 * 1024):.2f} MB)")
|
|
||||||
return output_filename
|
|
||||||
|
|
||||||
download_info = await self.get_download_url(track_id, quality)
|
|
||||||
download_url = download_info["download_url"]
|
|
||||||
download_track_info = download_info["track_info"]
|
|
||||||
|
|
||||||
print(f"Downloading to: {output_filename}")
|
|
||||||
await self.download_file(
|
|
||||||
download_url,
|
|
||||||
output_filename,
|
|
||||||
is_paused_callback=is_paused_callback,
|
|
||||||
is_stopped_callback=is_stopped_callback
|
|
||||||
)
|
|
||||||
|
|
||||||
print("Adding metadata...")
|
|
||||||
try:
|
|
||||||
await self.embed_metadata(output_filename, download_track_info, track_info)
|
|
||||||
print("Metadata saved")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Tagging failed: {e}")
|
|
||||||
|
|
||||||
print("Done")
|
|
||||||
return output_filename
|
|
||||||
|
|
||||||
async def main():
|
|
||||||
print("=== TidalDL - Tidal Downloader ===")
|
|
||||||
downloader = TidalDownloader(timeout=30, max_retries=3)
|
|
||||||
|
|
||||||
query = "APT."
|
|
||||||
isrc = "USAT22409172"
|
|
||||||
output_dir = "."
|
|
||||||
|
|
||||||
try:
|
|
||||||
downloaded_file = await downloader.download(query, isrc, output_dir)
|
|
||||||
print(f"Success: File saved as {downloaded_file}")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error: {str(e)}")
|
|
||||||
|
|
||||||
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
-1
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"version": "3.6"
|
"version": "5.7"
|
||||||
}
|
}
|
||||||
|
|||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://wails.io/schemas/config.v2.json",
|
||||||
|
"name": "SpotiFLAC",
|
||||||
|
"outputfilename": "SpotiFLAC",
|
||||||
|
"frontend:install": "pnpm install && pnpm run generate-icon",
|
||||||
|
"frontend:build": "pnpm run build",
|
||||||
|
"frontend:dev:watcher": "pnpm run dev",
|
||||||
|
"frontend:dev:serverUrl": "auto",
|
||||||
|
"author": {
|
||||||
|
"name": "afkarxyz"
|
||||||
|
},
|
||||||
|
"info": {
|
||||||
|
"productName": "SpotiFLAC",
|
||||||
|
"productVersion": "5.8"
|
||||||
|
},
|
||||||
|
"wailsjsdir": "./frontend",
|
||||||
|
"assetdir": "./frontend/dist",
|
||||||
|
"reloaddirs": "./frontend/src"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user