Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0c3a7b70af | |||
| 254022d81d | |||
| b3ebef5ab9 | |||
| 0093df6016 | |||
| 30cbcf8ab1 |
@@ -1 +1,2 @@
|
|||||||
ko_fi: afkarxyz
|
ko_fi: afkarxyz
|
||||||
|
patreon: afkarxyz
|
||||||
+60
-52
@@ -81,13 +81,13 @@ jobs:
|
|||||||
- name: Prepare artifacts
|
- name: Prepare artifacts
|
||||||
run: |
|
run: |
|
||||||
mkdir -p dist
|
mkdir -p dist
|
||||||
Compress-Archive -Path "build\bin\SpotiFLAC.exe" -DestinationPath "dist\spotiflac-windows.zip" -Force
|
Copy-Item -Path "build\bin\SpotiFLAC.exe" -Destination "dist\SpotiFLAC.exe"
|
||||||
|
|
||||||
- name: Upload artifacts
|
- name: Upload artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: windows-bundle
|
name: windows-portable
|
||||||
path: dist/spotiflac-windows.zip
|
path: dist/SpotiFLAC.exe
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
build-macos:
|
build-macos:
|
||||||
@@ -147,33 +147,56 @@ jobs:
|
|||||||
- name: Build application
|
- name: Build application
|
||||||
run: wails build -platform darwin/universal
|
run: wails build -platform darwin/universal
|
||||||
|
|
||||||
- name: Create macOS bundle
|
- name: Create DMG
|
||||||
run: |
|
run: |
|
||||||
mkdir -p dist
|
mkdir -p dist
|
||||||
ditto -c -k --sequesterRsrc --keepParent "build/bin/SpotiFLAC.app" "dist/spotiflac-macos-bundle.zip"
|
# Install create-dmg if not available
|
||||||
|
brew install create-dmg || true
|
||||||
|
|
||||||
|
# Create DMG
|
||||||
|
create-dmg \
|
||||||
|
--volname "SpotiFLAC" \
|
||||||
|
--window-pos 200 120 \
|
||||||
|
--window-size 600 400 \
|
||||||
|
--icon-size 100 \
|
||||||
|
--icon "SpotiFLAC.app" 175 120 \
|
||||||
|
--hide-extension "SpotiFLAC.app" \
|
||||||
|
--app-drop-link 425 120 \
|
||||||
|
"dist/SpotiFLAC.dmg" \
|
||||||
|
"build/bin/SpotiFLAC.app" || \
|
||||||
|
# Fallback to hdiutil if create-dmg fails
|
||||||
|
hdiutil create -volname SpotiFLAC -srcfolder build/bin/SpotiFLAC.app -ov -format UDZO dist/SpotiFLAC.dmg
|
||||||
|
|
||||||
- name: Upload artifacts
|
- name: Upload artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: macos-bundle
|
name: macos-portable
|
||||||
path: dist/spotiflac-macos-bundle.zip
|
path: dist/SpotiFLAC.dmg
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
build-linux:
|
build-linux:
|
||||||
name: Build Linux (${{ matrix.arch }})
|
name: Build Linux (${{ matrix.display_name }})
|
||||||
runs-on: ${{ matrix.runner }}
|
runs-on: ${{ matrix.runner }}
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- arch: amd64
|
- display_name: amd64
|
||||||
goarch: amd64
|
|
||||||
runner: ubuntu-24.04
|
runner: ubuntu-24.04
|
||||||
|
wails_platform: linux/amd64
|
||||||
|
artifact_name: linux-portable
|
||||||
|
output_name: SpotiFLAC.AppImage
|
||||||
appimage_arch: x86_64
|
appimage_arch: x86_64
|
||||||
- arch: arm64
|
appimagetool_arch: x86_64
|
||||||
goarch: arm64
|
pkgconfig_dir: /usr/lib/x86_64-linux-gnu/pkgconfig
|
||||||
|
- display_name: arm64
|
||||||
runner: ubuntu-24.04-arm
|
runner: ubuntu-24.04-arm
|
||||||
|
wails_platform: linux/arm64
|
||||||
|
artifact_name: linux-portable-arm
|
||||||
|
output_name: SpotiFLAC-ARM.AppImage
|
||||||
appimage_arch: aarch64
|
appimage_arch: aarch64
|
||||||
|
appimagetool_arch: aarch64
|
||||||
|
pkgconfig_dir: /usr/lib/aarch64-linux-gnu/pkgconfig
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -219,15 +242,10 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
PACKAGES="libgtk-3-dev libwebkit2gtk-4.1-dev libfuse2 imagemagick"
|
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libfuse2 imagemagick upx-ucl
|
||||||
if [ "${{ matrix.goarch }}" = "amd64" ]; then
|
|
||||||
PACKAGES="$PACKAGES upx-ucl"
|
|
||||||
fi
|
|
||||||
sudo apt-get install -y $PACKAGES
|
|
||||||
|
|
||||||
# Create symlink for webkit2gtk-4.0 -> webkit2gtk-4.1 (Ubuntu 24.04 compatibility)
|
# Create symlink for webkit2gtk-4.0 -> webkit2gtk-4.1 (Ubuntu 24.04 compatibility)
|
||||||
MULTIARCH="$(dpkg-architecture -qDEB_HOST_MULTIARCH)"
|
sudo ln -sf "${{ matrix.pkgconfig_dir }}/webkit2gtk-4.1.pc" "${{ matrix.pkgconfig_dir }}/webkit2gtk-4.0.pc"
|
||||||
sudo ln -sf "/usr/lib/${MULTIARCH}/pkgconfig/webkit2gtk-4.1.pc" "/usr/lib/${MULTIARCH}/pkgconfig/webkit2gtk-4.0.pc"
|
|
||||||
|
|
||||||
- name: Install Wails CLI
|
- name: Install Wails CLI
|
||||||
run: go install github.com/wailsapp/wails/v2/cmd/wails@latest
|
run: go install github.com/wailsapp/wails/v2/cmd/wails@latest
|
||||||
@@ -239,10 +257,9 @@ jobs:
|
|||||||
pnpm run generate-icon
|
pnpm run generate-icon
|
||||||
|
|
||||||
- name: Build application
|
- name: Build application
|
||||||
run: wails build -platform linux/${{ matrix.goarch }}
|
run: wails build -platform ${{ matrix.wails_platform }}
|
||||||
|
|
||||||
- name: Compress with UPX
|
- name: Compress with UPX
|
||||||
if: matrix.goarch == 'amd64'
|
|
||||||
run: |
|
run: |
|
||||||
upx --best --lzma build/bin/SpotiFLAC
|
upx --best --lzma build/bin/SpotiFLAC
|
||||||
|
|
||||||
@@ -251,13 +268,13 @@ jobs:
|
|||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: appimagetool
|
path: appimagetool
|
||||||
key: appimagetool-${{ matrix.appimage_arch }}-v1
|
key: appimagetool-${{ matrix.appimagetool_arch }}-v2
|
||||||
|
|
||||||
- name: Download appimagetool
|
- name: Download appimagetool
|
||||||
if: steps.cache-appimagetool.outputs.cache-hit != 'true'
|
if: steps.cache-appimagetool.outputs.cache-hit != 'true'
|
||||||
run: |
|
run: |
|
||||||
wget --timeout=30 --tries=5 --retry-connrefused --waitretry=5 -O appimagetool https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-${{ matrix.appimage_arch }}.AppImage || \
|
wget --timeout=30 --tries=5 --retry-connrefused --waitretry=5 -O appimagetool "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-${{ matrix.appimagetool_arch }}.AppImage" || \
|
||||||
wget --timeout=30 --tries=5 --retry-connrefused --waitretry=5 -O appimagetool https://github.com/AppImage/appimagetool/releases/download/1.9.1/appimagetool-${{ matrix.appimage_arch }}.AppImage
|
wget --timeout=30 --tries=5 --retry-connrefused --waitretry=5 -O appimagetool "https://github.com/AppImage/appimagetool/releases/download/1.9.1/appimagetool-${{ matrix.appimagetool_arch }}.AppImage"
|
||||||
|
|
||||||
- name: Make appimagetool executable
|
- name: Make appimagetool executable
|
||||||
run: chmod +x appimagetool
|
run: chmod +x appimagetool
|
||||||
@@ -312,18 +329,13 @@ jobs:
|
|||||||
|
|
||||||
# Create AppImage
|
# Create AppImage
|
||||||
mkdir -p dist
|
mkdir -p dist
|
||||||
if [ "${{ matrix.goarch }}" = "arm64" ]; then
|
ARCH=${{ matrix.appimage_arch }} ./appimagetool --no-appstream AppDir "dist/${{ matrix.output_name }}"
|
||||||
RELEASE_ARCH="arm64v8"
|
|
||||||
else
|
|
||||||
RELEASE_ARCH="amd64"
|
|
||||||
fi
|
|
||||||
ARCH=${{ matrix.appimage_arch }} ./appimagetool --no-appstream AppDir "dist/SpotiFLAC-${RELEASE_ARCH}.AppImage"
|
|
||||||
|
|
||||||
- name: Upload artifacts
|
- name: Upload artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: linux-appimage-${{ matrix.arch }}
|
name: ${{ matrix.artifact_name }}
|
||||||
path: dist/*.AppImage
|
path: dist/${{ matrix.output_name }}
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
create-release:
|
create-release:
|
||||||
@@ -351,13 +363,6 @@ jobs:
|
|||||||
- name: Display structure of downloaded files
|
- name: Display structure of downloaded files
|
||||||
run: ls -R artifacts
|
run: ls -R artifacts
|
||||||
|
|
||||||
- name: Create Linux bundle
|
|
||||||
run: |
|
|
||||||
mkdir -p release/SpotiFLAC-linux-bundle
|
|
||||||
cp artifacts/linux-appimage-amd64/*.AppImage release/SpotiFLAC-linux-bundle/
|
|
||||||
cp artifacts/linux-appimage-arm64/*.AppImage release/SpotiFLAC-linux-bundle/
|
|
||||||
tar -czf release/spotiflac-linux-bundle.tar.gz -C release SpotiFLAC-linux-bundle
|
|
||||||
|
|
||||||
- name: Create Release
|
- name: Create Release
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
@@ -369,20 +374,16 @@ jobs:
|
|||||||
|
|
||||||
## Downloads
|
## Downloads
|
||||||
|
|
||||||
- `spotiflac-windows.zip` - amd64
|
- `SpotiFLAC.exe` - Windows
|
||||||
- `spotiflac-macos-bundle.zip` - amd64 + arm64
|
- `SpotiFLAC.dmg` - macOS
|
||||||
- `spotiflac-linux-bundle.tar.gz` - amd64 + arm64v8
|
- `SpotiFLAC.AppImage` - Linux AMD64
|
||||||
|
- `SpotiFLAC-ARM.AppImage` - Linux ARM64
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>Linux Requirements</b></summary>
|
<summary><b>Linux Requirements</b></summary>
|
||||||
|
|
||||||
The AppImage requires `webkit2gtk-4.1` to be installed on your system:
|
The AppImage requires `webkit2gtk-4.1` to be installed on your system:
|
||||||
|
|
||||||
Choose the correct AppImage after extracting the bundle:
|
|
||||||
|
|
||||||
- `SpotiFLAC-amd64.AppImage` - amd64
|
|
||||||
- `SpotiFLAC-arm64v8.AppImage` - arm64v8
|
|
||||||
|
|
||||||
**Ubuntu/Debian:**
|
**Ubuntu/Debian:**
|
||||||
```bash
|
```bash
|
||||||
sudo apt install libwebkit2gtk-4.1-0
|
sudo apt install libwebkit2gtk-4.1-0
|
||||||
@@ -400,14 +401,21 @@ jobs:
|
|||||||
|
|
||||||
After installing the dependency, make the AppImage executable:
|
After installing the dependency, make the AppImage executable:
|
||||||
```bash
|
```bash
|
||||||
tar -xzf spotiflac-linux-bundle.tar.gz
|
chmod +x SpotiFLAC.AppImage
|
||||||
chmod +x SpotiFLAC-*.AppImage
|
./SpotiFLAC.AppImage
|
||||||
|
```
|
||||||
|
|
||||||
|
For ARM64:
|
||||||
|
```bash
|
||||||
|
chmod +x SpotiFLAC-ARM.AppImage
|
||||||
|
./SpotiFLAC-ARM.AppImage
|
||||||
```
|
```
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
files: |
|
files: |
|
||||||
artifacts/windows-bundle/*.zip
|
artifacts/windows-portable/SpotiFLAC.exe
|
||||||
artifacts/macos-bundle/*.zip
|
artifacts/macos-portable/SpotiFLAC.dmg
|
||||||
release/spotiflac-linux-bundle.tar.gz
|
artifacts/linux-portable/SpotiFLAC.AppImage
|
||||||
|
artifacts/linux-portable-arm/SpotiFLAC-ARM.AppImage
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
@@ -20,22 +20,16 @@ Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account
|
|||||||
|
|
||||||
Get Spotify tracks in true Lossless from Tidal, Qobuz, Amazon Music, Deezer & Apple Music — no account required.
|
Get Spotify tracks in true Lossless from Tidal, Qobuz, Amazon Music, Deezer & Apple Music — no account required.
|
||||||
|
|
||||||
### [SpotiDownloader](https://github.com/spotbye/SpotiDownloader)
|
|
||||||
|
|
||||||
Get Spotify tracks, albums, playlists and discography in MP3 and FLAC.
|
|
||||||
|
|
||||||
### [SpotubeDL.com](https://spotubedl.com)
|
### [SpotubeDL.com](https://spotubedl.com)
|
||||||
|
|
||||||
Download Spotify Tracks, Albums, Playlists & Discography as MP3/OGG/Opus.
|
Download Spotify Tracks, Albums, Playlists & Discography as MP3/OGG/Opus.
|
||||||
|
|
||||||
|
## Related projects
|
||||||
|
|
||||||
### [SpotiFLAC (Mobile)](https://github.com/zarzet/SpotiFLAC-Mobile)
|
### [SpotiFLAC (Mobile)](https://github.com/zarzet/SpotiFLAC-Mobile)
|
||||||
|
|
||||||
SpotiFLAC for Android & iOS — maintained by [@zarzet](https://github.com/zarzet)
|
SpotiFLAC for Android & iOS — maintained by [@zarzet](https://github.com/zarzet)
|
||||||
|
|
||||||
### [SpotiFLAC (CLI)](https://github.com/Nizarberyan/SpotiFLAC)
|
|
||||||
|
|
||||||
SpotiFLAC for command-line environments — maintained by [@Nizarberyan](https://github.com/Nizarberyan)
|
|
||||||
|
|
||||||
### [SpotiFLAC (Python Module)](https://github.com/ShuShuzinhuu/SpotiFLAC-Module-Version)
|
### [SpotiFLAC (Python Module)](https://github.com/ShuShuzinhuu/SpotiFLAC-Module-Version)
|
||||||
|
|
||||||
SpotiFLAC Python library for SpotiFLAC integration — maintained by [@ShuShuzinhuu](https://github.com/ShuShuzinhuu)
|
SpotiFLAC Python library for SpotiFLAC integration — maintained by [@ShuShuzinhuu](https://github.com/ShuShuzinhuu)
|
||||||
@@ -112,7 +106,7 @@ The software is provided "as is", without warranty of any kind. The author assum
|
|||||||
|
|
||||||
## API Credits
|
## API Credits
|
||||||
|
|
||||||
[MusicBrainz](https://musicbrainz.org) · [LRCLIB](https://lrclib.net) · [Songlink/Odesli](https://song.link) · [hifi-api](https://github.com/binimum/hifi-api) · [dabmusic.xyz](https://dabmusic.xyz)
|
[MusicBrainz](https://musicbrainz.org) · [LRCLIB](https://lrclib.net) · [Songlink/Odesli](https://song.link) · [hifi-api](https://github.com/binimum/hifi-api) · [WJHE](https://music.wjhe.top) · [GDStudio](https://music.gdstudio.xyz) · [MusicDL](https://musicdl.me)
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
>
|
>
|
||||||
|
|||||||
+97
-5
@@ -1,6 +1,9 @@
|
|||||||
package backend
|
package backend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/sha256"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -10,6 +13,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -23,6 +27,76 @@ type AmazonStreamResponse struct {
|
|||||||
DecryptionKey string `json:"decryptionKey"`
|
DecryptionKey string `json:"decryptionKey"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
amazonMusicDebugKeyOnce sync.Once
|
||||||
|
amazonMusicDebugKey string
|
||||||
|
amazonMusicDebugKeyErr error
|
||||||
|
)
|
||||||
|
|
||||||
|
var amazonMusicDebugKeySeedParts = [][]byte{
|
||||||
|
[]byte("spotif"),
|
||||||
|
[]byte("lac:am"),
|
||||||
|
[]byte("azon:spotbye:api:v1"),
|
||||||
|
}
|
||||||
|
|
||||||
|
var amazonMusicDebugKeyAAD = []byte{
|
||||||
|
0x61, 0x6d, 0x61, 0x7a, 0x6f, 0x6e, 0x7c, 0x73, 0x70, 0x6f, 0x74, 0x62,
|
||||||
|
0x79, 0x65, 0x7c, 0x64, 0x65, 0x62, 0x75, 0x67, 0x7c, 0x76, 0x31,
|
||||||
|
}
|
||||||
|
|
||||||
|
var amazonMusicDebugKeyNonce = []byte{
|
||||||
|
0x52, 0x1f, 0xa4, 0x9c, 0x13, 0x77, 0x5b, 0xe2, 0x81, 0x44, 0x90, 0x6d,
|
||||||
|
}
|
||||||
|
|
||||||
|
var amazonMusicDebugKeyCiphertext = []byte{
|
||||||
|
0x5b, 0xf9, 0xc1, 0x2e, 0x58, 0xf8, 0x5b, 0xc0, 0x04, 0x68, 0x7e, 0xff,
|
||||||
|
0x3d, 0xd6, 0x8b, 0xe3, 0x86, 0x49, 0x6c, 0xfd, 0xc1, 0x49, 0x0b, 0xfb,
|
||||||
|
}
|
||||||
|
|
||||||
|
var amazonMusicDebugKeyTag = []byte{
|
||||||
|
0x6c, 0x21, 0x98, 0x51, 0xf2, 0x38, 0x4b, 0x4a, 0x23, 0xe1, 0xc6, 0xd7,
|
||||||
|
0x65, 0x7f, 0xfb, 0xa1,
|
||||||
|
}
|
||||||
|
|
||||||
|
func getAmazonMusicDebugKey() (string, error) {
|
||||||
|
amazonMusicDebugKeyOnce.Do(func() {
|
||||||
|
hasher := sha256.New()
|
||||||
|
for _, part := range amazonMusicDebugKeySeedParts {
|
||||||
|
hasher.Write(part)
|
||||||
|
}
|
||||||
|
|
||||||
|
block, err := aes.NewCipher(hasher.Sum(nil))
|
||||||
|
if err != nil {
|
||||||
|
amazonMusicDebugKeyErr = err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
amazonMusicDebugKeyErr = err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed := make([]byte, 0, len(amazonMusicDebugKeyCiphertext)+len(amazonMusicDebugKeyTag))
|
||||||
|
sealed = append(sealed, amazonMusicDebugKeyCiphertext...)
|
||||||
|
sealed = append(sealed, amazonMusicDebugKeyTag...)
|
||||||
|
|
||||||
|
plaintext, err := gcm.Open(nil, amazonMusicDebugKeyNonce, sealed, amazonMusicDebugKeyAAD)
|
||||||
|
if err != nil {
|
||||||
|
amazonMusicDebugKeyErr = err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
amazonMusicDebugKey = string(plaintext)
|
||||||
|
})
|
||||||
|
|
||||||
|
if amazonMusicDebugKeyErr != nil {
|
||||||
|
return "", amazonMusicDebugKeyErr
|
||||||
|
}
|
||||||
|
|
||||||
|
return amazonMusicDebugKey, nil
|
||||||
|
}
|
||||||
|
|
||||||
func NewAmazonDownloader() *AmazonDownloader {
|
func NewAmazonDownloader() *AmazonDownloader {
|
||||||
return &AmazonDownloader{
|
return &AmazonDownloader{
|
||||||
client: &http.Client{
|
client: &http.Client{
|
||||||
@@ -56,12 +130,17 @@ func (a *AmazonDownloader) DownloadFromAfkarXYZ(amazonURL, outputDir, quality st
|
|||||||
return "", fmt.Errorf("failed to extract ASIN from URL: %s", amazonURL)
|
return "", fmt.Errorf("failed to extract ASIN from URL: %s", amazonURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
apiURL := fmt.Sprintf("https://amzn.afkarxyz.qzz.io/api/track/%s", asin)
|
apiURL := fmt.Sprintf("%s/api/track/%s", amazonMusicAPIBaseURL, asin)
|
||||||
req, err := http.NewRequest("GET", apiURL, nil)
|
req, err := NewRequestWithDefaultHeaders(http.MethodGet, apiURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36")
|
|
||||||
|
debugKey, err := getAmazonMusicDebugKey()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to decrypt Amazon debug key: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("X-Debug-Key", debugKey)
|
||||||
|
|
||||||
fmt.Printf("Fetching from Amazon API (ASIN: %s)...\n", asin)
|
fmt.Printf("Fetching from Amazon API (ASIN: %s)...\n", asin)
|
||||||
resp, err := a.client.Do(req)
|
resp, err := a.client.Do(req)
|
||||||
@@ -98,8 +177,10 @@ func (a *AmazonDownloader) DownloadFromAfkarXYZ(amazonURL, outputDir, quality st
|
|||||||
}
|
}
|
||||||
defer out.Close()
|
defer out.Close()
|
||||||
|
|
||||||
dlReq, _ := http.NewRequest("GET", downloadURL, nil)
|
dlReq, err := NewRequestWithDefaultHeaders(http.MethodGet, downloadURL, nil)
|
||||||
dlReq.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36")
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
dlResp, err := a.client.Do(dlReq)
|
dlResp, err := a.client.Do(dlReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -287,6 +368,16 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename
|
|||||||
mbMeta = result.Metadata
|
mbMeta = result.Metadata
|
||||||
}
|
}
|
||||||
|
|
||||||
|
upc := ""
|
||||||
|
if spotifyURL != "" {
|
||||||
|
if identifiers, err := GetSpotifyTrackIdentifiersDirect(spotifyURL); err == nil || identifiers.ISRC != "" || identifiers.UPC != "" {
|
||||||
|
if strings.TrimSpace(isrc) == "" && strings.TrimSpace(identifiers.ISRC) != "" {
|
||||||
|
isrc = strings.TrimSpace(identifiers.ISRC)
|
||||||
|
}
|
||||||
|
upc = strings.TrimSpace(identifiers.UPC)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
originalFileDir := filepath.Dir(filePath)
|
originalFileDir := filepath.Dir(filePath)
|
||||||
originalFileBase := strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
|
originalFileBase := strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
|
||||||
|
|
||||||
@@ -406,6 +497,7 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename
|
|||||||
Separator: metadataSeparator,
|
Separator: metadataSeparator,
|
||||||
Description: "https://github.com/spotbye/SpotiFLAC",
|
Description: "https://github.com/spotbye/SpotiFLAC",
|
||||||
ISRC: isrc,
|
ISRC: isrc,
|
||||||
|
UPC: upc,
|
||||||
Genre: mbMeta.Genre,
|
Genre: mbMeta.Genre,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+156
-1
@@ -2,11 +2,138 @@ package backend
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const legacyTidalAPICacheFile = "tidal-api-urls.json"
|
||||||
|
|
||||||
|
func normalizeCustomTidalAPIValue(value interface{}) string {
|
||||||
|
customAPI, _ := value.(string)
|
||||||
|
customAPI = strings.TrimRight(strings.TrimSpace(customAPI), "/")
|
||||||
|
if strings.HasPrefix(customAPI, "https://") {
|
||||||
|
return customAPI
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeDownloaderValue(value interface{}, allowTidal bool) string {
|
||||||
|
downloader, _ := value.(string)
|
||||||
|
switch strings.TrimSpace(strings.ToLower(downloader)) {
|
||||||
|
case "tidal":
|
||||||
|
if allowTidal {
|
||||||
|
return "tidal"
|
||||||
|
}
|
||||||
|
return "auto"
|
||||||
|
case "qobuz":
|
||||||
|
return "qobuz"
|
||||||
|
case "amazon":
|
||||||
|
return "amazon"
|
||||||
|
default:
|
||||||
|
return "auto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeAutoOrderValue(value interface{}, allowTidal bool) string {
|
||||||
|
autoOrder, _ := value.(string)
|
||||||
|
allowed := map[string]struct{}{
|
||||||
|
"qobuz": {},
|
||||||
|
"amazon": {},
|
||||||
|
}
|
||||||
|
fallback := "qobuz-amazon"
|
||||||
|
if allowTidal {
|
||||||
|
allowed["tidal"] = struct{}{}
|
||||||
|
fallback = "tidal-qobuz-amazon"
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := make(map[string]struct{})
|
||||||
|
parts := make([]string, 0, 3)
|
||||||
|
for _, rawPart := range strings.Split(strings.TrimSpace(strings.ToLower(autoOrder)), "-") {
|
||||||
|
part := strings.TrimSpace(rawPart)
|
||||||
|
if part == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := allowed[part]; !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[part]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[part] = struct{}{}
|
||||||
|
parts = append(parts, part)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(parts, "-")
|
||||||
|
}
|
||||||
|
|
||||||
|
func SanitizeSettingsMap(settings map[string]interface{}) map[string]interface{} {
|
||||||
|
if settings == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
sanitized := make(map[string]interface{}, len(settings))
|
||||||
|
for key, value := range settings {
|
||||||
|
sanitized[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
customAPI := normalizeCustomTidalAPIValue(sanitized["customTidalApi"])
|
||||||
|
sanitized["customTidalApi"] = customAPI
|
||||||
|
allowTidal := customAPI != ""
|
||||||
|
sanitized["downloader"] = sanitizeDownloaderValue(sanitized["downloader"], allowTidal)
|
||||||
|
sanitized["autoOrder"] = sanitizeAutoOrderValue(sanitized["autoOrder"], allowTidal)
|
||||||
|
|
||||||
|
return sanitized
|
||||||
|
}
|
||||||
|
|
||||||
|
func CleanupLegacyTidalPublicAPIState() error {
|
||||||
|
appDir, err := EnsureAppDir()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
cachePath := filepath.Join(appDir, legacyTidalAPICacheFile)
|
||||||
|
if err := os.Remove(cachePath); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func SanitizePersistedConfigSettings() error {
|
||||||
|
configPath, err := GetConfigPath()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var settings map[string]interface{}
|
||||||
|
if err := json.Unmarshal(data, &settings); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
sanitized := SanitizeSettingsMap(settings)
|
||||||
|
payload, err := json.MarshalIndent(sanitized, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return os.WriteFile(configPath, payload, 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
func GetDefaultMusicPath() string {
|
func GetDefaultMusicPath() string {
|
||||||
|
|
||||||
homeDir, err := os.UserHomeDir()
|
homeDir, err := os.UserHomeDir()
|
||||||
@@ -47,7 +174,7 @@ func LoadConfigSettings() (map[string]interface{}, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return settings, nil
|
return SanitizeSettingsMap(settings), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetRedownloadWithSuffixSetting() bool {
|
func GetRedownloadWithSuffixSetting() bool {
|
||||||
@@ -60,6 +187,34 @@ func GetRedownloadWithSuffixSetting() bool {
|
|||||||
return enabled
|
return enabled
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func GetCustomTidalAPISetting() string {
|
||||||
|
settings, err := LoadConfigSettings()
|
||||||
|
if err != nil || settings == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizeCustomTidalAPIValue(settings["customTidalApi"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeExistingFileCheckMode(value string) string {
|
||||||
|
switch strings.TrimSpace(strings.ToLower(value)) {
|
||||||
|
case "isrc", "upc":
|
||||||
|
return "isrc"
|
||||||
|
default:
|
||||||
|
return "filename"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetExistingFileCheckModeSetting() string {
|
||||||
|
settings, err := LoadConfigSettings()
|
||||||
|
if err != nil || settings == nil {
|
||||||
|
return "filename"
|
||||||
|
}
|
||||||
|
|
||||||
|
rawMode, _ := settings["existingFileCheckMode"].(string)
|
||||||
|
return normalizeExistingFileCheckMode(rawMode)
|
||||||
|
}
|
||||||
|
|
||||||
func GetLinkResolverSetting() string {
|
func GetLinkResolverSetting() string {
|
||||||
settings, err := LoadConfigSettings()
|
settings, err := LoadConfigSettings()
|
||||||
if err != nil || settings == nil {
|
if err != nil || settings == nil {
|
||||||
|
|||||||
+190
-53
@@ -19,6 +19,11 @@ import (
|
|||||||
"golang.org/x/text/unicode/norm"
|
"golang.org/x/text/unicode/norm"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type executableCandidate struct {
|
||||||
|
path string
|
||||||
|
source string
|
||||||
|
}
|
||||||
|
|
||||||
func ValidateExecutable(path string) error {
|
func ValidateExecutable(path string) error {
|
||||||
cleanedPath := filepath.Clean(path)
|
cleanedPath := filepath.Clean(path)
|
||||||
if cleanedPath == "" {
|
if cleanedPath == "" {
|
||||||
@@ -83,6 +88,50 @@ func GetFFmpegDir() (string, error) {
|
|||||||
return EnsureAppDir()
|
return EnsureAppDir()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func copyExecutable(src, dst string) error {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
in, err := os.Open(src)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer in.Close()
|
||||||
|
|
||||||
|
out, err := os.OpenFile(dst, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o755)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer out.Close()
|
||||||
|
|
||||||
|
if _, err = io.Copy(out, in); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := out.Sync(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return prepareExecutableForUse(dst)
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendExecutableCandidate(candidates []executableCandidate, seen map[string]struct{}, path, source string) []executableCandidate {
|
||||||
|
cleanedPath := filepath.Clean(strings.TrimSpace(path))
|
||||||
|
if cleanedPath == "" {
|
||||||
|
return candidates
|
||||||
|
}
|
||||||
|
if _, exists := seen[cleanedPath]; exists {
|
||||||
|
return candidates
|
||||||
|
}
|
||||||
|
|
||||||
|
seen[cleanedPath] = struct{}{}
|
||||||
|
return append(candidates, executableCandidate{
|
||||||
|
path: cleanedPath,
|
||||||
|
source: source,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func resolveSystemExecutable(executableName string) string {
|
func resolveSystemExecutable(executableName string) string {
|
||||||
if runtime.GOOS == "darwin" {
|
if runtime.GOOS == "darwin" {
|
||||||
candidates := []string{
|
candidates := []string{
|
||||||
@@ -114,83 +163,163 @@ func resolveSystemExecutable(executableName string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetFFmpegPath() (string, error) {
|
func runExecutableVersionCheck(path string) error {
|
||||||
ffmpegDir, err := GetFFmpegDir()
|
cmd := exec.Command(path, "-version")
|
||||||
if err != nil {
|
setHideWindow(cmd)
|
||||||
return "", err
|
return cmd.Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeMacOSQuarantineAttribute(path string) error {
|
||||||
|
cmd := exec.Command("xattr", "-d", "com.apple.quarantine", path)
|
||||||
|
setHideWindow(cmd)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
trimmedOutput := strings.TrimSpace(string(output))
|
||||||
|
lowerOutput := strings.ToLower(trimmedOutput)
|
||||||
|
if strings.Contains(lowerOutput, "no such xattr") || strings.Contains(lowerOutput, "attribute not found") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if trimmedOutput != "" {
|
||||||
|
return fmt.Errorf("%w: %s", err, trimmedOutput)
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepareExecutableForUse(path string) error {
|
||||||
|
cleanedPath := filepath.Clean(strings.TrimSpace(path))
|
||||||
|
if cleanedPath == "" {
|
||||||
|
return fmt.Errorf("empty path")
|
||||||
|
}
|
||||||
|
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Chmod(cleanedPath, 0755); err != nil {
|
||||||
|
return fmt.Errorf("failed to mark executable: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if runtime.GOOS == "darwin" {
|
||||||
|
if err := removeMacOSQuarantineAttribute(cleanedPath); err != nil {
|
||||||
|
fmt.Printf("[FFmpeg] Warning: failed to remove macOS quarantine from %s: %v\n", cleanedPath, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveExecutablePath(executableName string) (string, string, error) {
|
||||||
|
ffmpegDir, err := GetFFmpegDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
localPath := filepath.Join(ffmpegDir, executableName)
|
||||||
|
nextDir := filepath.Join(filepath.Dir(ffmpegDir), ".spotiflac-next")
|
||||||
|
nextPath := filepath.Join(nextDir, executableName)
|
||||||
|
localExists := false
|
||||||
|
candidates := make([]executableCandidate, 0, 3)
|
||||||
|
seen := make(map[string]struct{}, 3)
|
||||||
|
|
||||||
|
if systemPath := resolveSystemExecutable(executableName); systemPath != "" {
|
||||||
|
candidates = appendExecutableCandidate(candidates, seen, systemPath, "system")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(localPath); err == nil {
|
||||||
|
localExists = true
|
||||||
|
candidates = appendExecutableCandidate(candidates, seen, localPath, "local")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !localExists {
|
||||||
|
if _, err := os.Stat(nextPath); err == nil {
|
||||||
|
if copyErr := copyExecutable(nextPath, localPath); copyErr == nil {
|
||||||
|
fmt.Printf("[FFmpeg] Copied %s from SpotiFLAC-Next folder\n", executableName)
|
||||||
|
candidates = appendExecutableCandidate(candidates, seen, localPath, "migrated")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastErr error
|
||||||
|
for _, candidate := range candidates {
|
||||||
|
if candidate.source != "system" {
|
||||||
|
if err := prepareExecutableForUse(candidate.path); err != nil {
|
||||||
|
lastErr = err
|
||||||
|
fmt.Printf("[FFmpeg] Skipping %s %s: %v\n", candidate.source, candidate.path, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ValidateExecutable(candidate.path); err != nil {
|
||||||
|
lastErr = err
|
||||||
|
fmt.Printf("[FFmpeg] Skipping %s %s: %v\n", candidate.source, candidate.path, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := runExecutableVersionCheck(candidate.path); err != nil {
|
||||||
|
lastErr = err
|
||||||
|
fmt.Printf("[FFmpeg] Skipping %s %s: %v\n", candidate.source, candidate.path, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidate.path, localPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(candidates) > 0 {
|
||||||
|
if lastErr != nil {
|
||||||
|
return "", localPath, fmt.Errorf("no working %s executable found: %w", executableName, lastErr)
|
||||||
|
}
|
||||||
|
return "", localPath, fmt.Errorf("no working %s executable found", executableName)
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", localPath, fmt.Errorf("%s not found in app directory or system path", executableName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetFFmpegPath() (string, error) {
|
||||||
ffmpegName := "ffmpeg"
|
ffmpegName := "ffmpeg"
|
||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
ffmpegName = "ffmpeg.exe"
|
ffmpegName = "ffmpeg.exe"
|
||||||
}
|
}
|
||||||
|
|
||||||
if path := resolveSystemExecutable(ffmpegName); path != "" {
|
path, localPath, err := resolveExecutablePath(ffmpegName)
|
||||||
return path, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
localPath := filepath.Join(ffmpegDir, ffmpegName)
|
|
||||||
if _, err := os.Stat(localPath); err == nil {
|
|
||||||
return localPath, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return localPath, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetFFprobePath() (string, error) {
|
|
||||||
ffmpegDir, err := GetFFmpegDir()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if localPath != "" {
|
||||||
|
return localPath, err
|
||||||
|
}
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetFFprobePath() (string, error) {
|
||||||
ffprobeName := "ffprobe"
|
ffprobeName := "ffprobe"
|
||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
ffprobeName = "ffprobe.exe"
|
ffprobeName = "ffprobe.exe"
|
||||||
}
|
}
|
||||||
|
|
||||||
if path := resolveSystemExecutable(ffprobeName); path != "" {
|
path, localPath, err := resolveExecutablePath(ffprobeName)
|
||||||
|
if err != nil {
|
||||||
|
if localPath != "" {
|
||||||
|
return localPath, err
|
||||||
|
}
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
return path, nil
|
return path, nil
|
||||||
}
|
|
||||||
|
|
||||||
localPath := filepath.Join(ffmpegDir, ffprobeName)
|
|
||||||
if _, err := os.Stat(localPath); err == nil {
|
|
||||||
return localPath, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return localPath, fmt.Errorf("ffprobe not found in app directory or system path")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func IsFFprobeInstalled() (bool, error) {
|
func IsFFprobeInstalled() (bool, error) {
|
||||||
ffprobePath, err := GetFFprobePath()
|
_, err := GetFFprobePath()
|
||||||
if err != nil {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := ValidateExecutable(ffprobePath); err != nil {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd := exec.Command(ffprobePath, "-version")
|
|
||||||
setHideWindow(cmd)
|
|
||||||
err = cmd.Run()
|
|
||||||
return err == nil, nil
|
return err == nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func IsFFmpegInstalled() (bool, error) {
|
func IsFFmpegInstalled() (bool, error) {
|
||||||
ffmpegPath, err := GetFFmpegPath()
|
if _, err := GetFFmpegPath(); err != nil {
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := ValidateExecutable(ffmpegPath); err != nil {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd := exec.Command(ffmpegPath, "-version")
|
|
||||||
|
|
||||||
setHideWindow(cmd)
|
|
||||||
err = cmd.Run()
|
|
||||||
if err != nil {
|
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -507,6 +636,10 @@ func extractZip(zipPath, destDir string) error {
|
|||||||
return fmt.Errorf("failed to extract file: %w", err)
|
return fmt.Errorf("failed to extract file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := prepareExecutableForUse(destPath); err != nil {
|
||||||
|
return fmt.Errorf("failed to prepare extracted executable: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
fmt.Printf("[FFmpeg] Extracted to: %s\n", destPath)
|
fmt.Printf("[FFmpeg] Extracted to: %s\n", destPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -584,6 +717,10 @@ func extractTarXz(tarXzPath, destDir string) error {
|
|||||||
return fmt.Errorf("failed to extract file: %w", err)
|
return fmt.Errorf("failed to extract file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := prepareExecutableForUse(destPath); err != nil {
|
||||||
|
return fmt.Errorf("failed to prepare extracted executable: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
fmt.Printf("[FFmpeg] Extracted to: %s\n", destPath)
|
fmt.Printf("[FFmpeg] Extracted to: %s\n", destPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package backend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
const DefaultDownloaderUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"
|
||||||
|
|
||||||
|
func NewRequestWithDefaultHeaders(method string, rawURL string, body io.Reader) (*http.Request, error) {
|
||||||
|
req, err := http.NewRequest(method, rawURL, body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("User-Agent", DefaultDownloaderUserAgent)
|
||||||
|
req.Header.Set("Accept", "application/json, text/plain, */*")
|
||||||
|
|
||||||
|
return req, nil
|
||||||
|
}
|
||||||
@@ -180,7 +180,7 @@ func queryMusicBrainzRecordings(client *http.Client, query string) (*MusicBrainz
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Header.Set("User-Agent", fmt.Sprintf("SpotiFLAC/%s ( hi@afkarxyz.qzz.io )", AppVersion))
|
req.Header.Set("User-Agent", fmt.Sprintf("SpotiFLAC/%s ( support@spotbye.qzz.io )", AppVersion))
|
||||||
req.Header.Set("Accept", "application/json")
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
var lastErr error
|
var lastErr error
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package backend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const amazonMusicAPIBaseURL = "https://amazon.spotbye.qzz.io"
|
||||||
|
|
||||||
|
const (
|
||||||
|
qobuzWJHEBaseURL = "https://music.wjhe.top"
|
||||||
|
qobuzWJHESearchAPIURL = qobuzWJHEBaseURL + "/api/music/qobuz/search"
|
||||||
|
qobuzWJHEStreamAPIURL = qobuzWJHEBaseURL + "/api/music/qobuz/url"
|
||||||
|
qobuzMusicDLDownloadAPIURL = "https://www.musicdl.me/api/qobuz/download"
|
||||||
|
qobuzGDStudioAPIURLXYZ = "https://music.gdstudio.xyz/api.php"
|
||||||
|
qobuzGDStudioAPIURLORG = "https://music.gdstudio.org/api.php"
|
||||||
|
qobuzGDStudioVersion = "2026.5.10"
|
||||||
|
)
|
||||||
|
|
||||||
|
var defaultQobuzDownloadProviderURLs = []string{
|
||||||
|
qobuzWJHEStreamAPIURL,
|
||||||
|
qobuzGDStudioAPIURLXYZ,
|
||||||
|
qobuzGDStudioAPIURLORG,
|
||||||
|
qobuzMusicDLDownloadAPIURL,
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetQobuzDownloadProviderURLs() []string {
|
||||||
|
return append([]string(nil), defaultQobuzDownloadProviderURLs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetQobuzWJHESearchAPIURL() string {
|
||||||
|
return qobuzWJHESearchAPIURL
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetQobuzWJHEStreamAPIURL() string {
|
||||||
|
return qobuzWJHEStreamAPIURL
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetQobuzMusicDLDownloadAPIURL() string {
|
||||||
|
return qobuzMusicDLDownloadAPIURL
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetQobuzGDStudioAPIURLs() []string {
|
||||||
|
return []string{qobuzGDStudioAPIURLXYZ, qobuzGDStudioAPIURLORG}
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetQobuzGDStudioPrimaryAPIURL() string {
|
||||||
|
return qobuzGDStudioAPIURLXYZ
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetQobuzGDStudioFallbackAPIURL() string {
|
||||||
|
return qobuzGDStudioAPIURLORG
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetQobuzGDStudioSignatureHost(apiURL string) string {
|
||||||
|
parsed, err := url.Parse(strings.TrimSpace(apiURL))
|
||||||
|
if err != nil || strings.TrimSpace(parsed.Host) == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(parsed.Host)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetQobuzGDStudioVersion() string {
|
||||||
|
return qobuzGDStudioVersion
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsQobuzWJHEProviderURL(raw string) bool {
|
||||||
|
candidate := strings.TrimSpace(raw)
|
||||||
|
return candidate == qobuzWJHEStreamAPIURL || strings.HasPrefix(candidate, qobuzWJHEStreamAPIURL+"?")
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsQobuzMusicDLProviderURL(raw string) bool {
|
||||||
|
return strings.EqualFold(strings.TrimSpace(raw), qobuzMusicDLDownloadAPIURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsQobuzGDStudioProviderURL(raw string) bool {
|
||||||
|
candidate := strings.TrimSpace(raw)
|
||||||
|
for _, apiURL := range GetQobuzGDStudioAPIURLs() {
|
||||||
|
if candidate == apiURL || strings.HasPrefix(candidate, apiURL+"?") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetAmazonMusicAPIBaseURL() string {
|
||||||
|
return amazonMusicAPIBaseURL
|
||||||
|
}
|
||||||
+674
-104
@@ -1,6 +1,12 @@
|
|||||||
package backend
|
package backend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/md5"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -9,23 +15,14 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type QobuzDownloader struct {
|
type QobuzDownloader struct {
|
||||||
client *http.Client
|
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 {
|
type QobuzTrack struct {
|
||||||
@@ -64,8 +61,63 @@ type QobuzTrack struct {
|
|||||||
} `json:"album"`
|
} `json:"album"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type QobuzStreamResponse struct {
|
type qobuzMusicDLRequest struct {
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
|
Quality string `json:"quality"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type qobuzMusicDLResponse struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
URLType string `json:"url_type"`
|
||||||
|
TrackID string `json:"track_id"`
|
||||||
|
Quality string `json:"quality_label"`
|
||||||
|
DownloadURL string `json:"download_url"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type qobuzPublicSearchResponse struct {
|
||||||
|
Tracks struct {
|
||||||
|
Total int `json:"total"`
|
||||||
|
Items []QobuzTrack `json:"items"`
|
||||||
|
} `json:"tracks"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const qobuzProbeTrackID int64 = 341032040
|
||||||
|
|
||||||
|
var (
|
||||||
|
qobuzMusicDLDebugKeyOnce sync.Once
|
||||||
|
qobuzMusicDLDebugKey string
|
||||||
|
qobuzMusicDLDebugKeyErr error
|
||||||
|
qobuzStreamingURLPattern = regexp.MustCompile(`https?://[^\s"'<>\\)]+`)
|
||||||
|
)
|
||||||
|
|
||||||
|
var qobuzMusicDLDebugKeySeedParts = [][]byte{
|
||||||
|
{0x73, 0x70, 0x6f, 0x74, 0x69, 0x66},
|
||||||
|
{0x6c, 0x61, 0x63, 0x3a, 0x71, 0x6f},
|
||||||
|
{0x62, 0x75, 0x7a, 0x3a, 0x6d, 0x75, 0x73, 0x69, 0x63, 0x64, 0x6c, 0x3a, 0x76, 0x31},
|
||||||
|
}
|
||||||
|
|
||||||
|
var qobuzMusicDLDebugKeyAAD = []byte{
|
||||||
|
0x71, 0x6f, 0x62, 0x75, 0x7a, 0x7c, 0x6d, 0x75, 0x73, 0x69, 0x63, 0x64,
|
||||||
|
0x6c, 0x7c, 0x64, 0x65, 0x62, 0x75, 0x67, 0x7c, 0x76, 0x31,
|
||||||
|
}
|
||||||
|
|
||||||
|
var qobuzMusicDLDebugKeyNonce = []byte{
|
||||||
|
0x91, 0x2a, 0x5c, 0x77, 0x0f, 0x33, 0xa8, 0x14, 0x62, 0x9d, 0xce, 0x41,
|
||||||
|
}
|
||||||
|
|
||||||
|
var qobuzMusicDLDebugKeyCiphertext = []byte{
|
||||||
|
0xf3, 0x4a, 0x83, 0x45, 0x24, 0xb6, 0x22, 0xaf, 0xd6, 0xc3, 0x6e, 0x2d,
|
||||||
|
0x56, 0xd1, 0xbb, 0x0b, 0xe9, 0x1b, 0x4f, 0x1c, 0x5f, 0x41, 0x55, 0xc2,
|
||||||
|
0xc6, 0xdf, 0xad, 0x21, 0x58, 0xfe, 0xd5, 0xb8, 0x2d, 0x29, 0xf9, 0x9e,
|
||||||
|
0x6f, 0xd6,
|
||||||
|
}
|
||||||
|
|
||||||
|
var qobuzMusicDLDebugKeyTag = []byte{
|
||||||
|
0x69, 0x0c, 0x42, 0x70, 0x14, 0x83, 0xff, 0x14, 0xc8, 0xbe, 0x17, 0x00,
|
||||||
|
0x69, 0xb1, 0xfe, 0xbb,
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewQobuzDownloader() *QobuzDownloader {
|
func NewQobuzDownloader() *QobuzDownloader {
|
||||||
@@ -73,114 +125,625 @@ func NewQobuzDownloader() *QobuzDownloader {
|
|||||||
client: &http.Client{
|
client: &http.Client{
|
||||||
Timeout: 60 * time.Second,
|
Timeout: 60 * time.Second,
|
||||||
},
|
},
|
||||||
appID: qobuzDefaultAPIAppID,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *QobuzDownloader) searchByISRC(isrc string) (*QobuzTrack, error) {
|
func previewQobuzResponseBody(body []byte, maxLen int) string {
|
||||||
|
preview := strings.TrimSpace(string(body))
|
||||||
|
if len(preview) > maxLen {
|
||||||
|
return preview[:maxLen] + "..."
|
||||||
|
}
|
||||||
|
return preview
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildQobuzOpenTrackURL(trackID int64) string {
|
||||||
|
return fmt.Sprintf("https://open.qobuz.com/track/%d", trackID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getQobuzMusicDLDebugKey() (string, error) {
|
||||||
|
qobuzMusicDLDebugKeyOnce.Do(func() {
|
||||||
|
hasher := sha256.New()
|
||||||
|
for _, part := range qobuzMusicDLDebugKeySeedParts {
|
||||||
|
hasher.Write(part)
|
||||||
|
}
|
||||||
|
|
||||||
|
block, err := aes.NewCipher(hasher.Sum(nil))
|
||||||
|
if err != nil {
|
||||||
|
qobuzMusicDLDebugKeyErr = err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
qobuzMusicDLDebugKeyErr = err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed := make([]byte, 0, len(qobuzMusicDLDebugKeyCiphertext)+len(qobuzMusicDLDebugKeyTag))
|
||||||
|
sealed = append(sealed, qobuzMusicDLDebugKeyCiphertext...)
|
||||||
|
sealed = append(sealed, qobuzMusicDLDebugKeyTag...)
|
||||||
|
|
||||||
|
plaintext, err := gcm.Open(nil, qobuzMusicDLDebugKeyNonce, sealed, qobuzMusicDLDebugKeyAAD)
|
||||||
|
if err != nil {
|
||||||
|
qobuzMusicDLDebugKeyErr = err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
qobuzMusicDLDebugKey = string(plaintext)
|
||||||
|
})
|
||||||
|
|
||||||
|
if qobuzMusicDLDebugKeyErr != nil {
|
||||||
|
return "", qobuzMusicDLDebugKeyErr
|
||||||
|
}
|
||||||
|
|
||||||
|
return qobuzMusicDLDebugKey, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmptyQobuzValue(values ...string) string {
|
||||||
|
for _, value := range values {
|
||||||
|
trimmed := strings.TrimSpace(value)
|
||||||
|
if trimmed != "" {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeQobuzSearchValue(value string) string {
|
||||||
|
replacer := strings.NewReplacer(
|
||||||
|
"&", " and ",
|
||||||
|
"feat.", " ",
|
||||||
|
"ft.", " ",
|
||||||
|
"/", " ",
|
||||||
|
"-", " ",
|
||||||
|
"_", " ",
|
||||||
|
)
|
||||||
|
normalized := strings.ToLower(strings.TrimSpace(value))
|
||||||
|
normalized = replacer.Replace(normalized)
|
||||||
|
return strings.Join(strings.Fields(normalized), " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func qobuzTrackDisplayArtist(track QobuzTrack) string {
|
||||||
|
return firstNonEmptyQobuzValue(track.Performer.Name, track.Album.Artist.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func qobuzTrackSupportsHiRes(track QobuzTrack) bool {
|
||||||
|
if track.Hires || track.HiresStreamable {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return track.MaximumBitDepth >= 24 || track.MaximumSamplingRate > 48
|
||||||
|
}
|
||||||
|
|
||||||
|
func scoreQobuzSearchCandidate(track QobuzTrack, spotifyTrackName string, spotifyArtistName string, spotifyAlbumName string) int {
|
||||||
|
score := 0
|
||||||
|
|
||||||
|
titleNeedle := normalizeQobuzSearchValue(spotifyTrackName)
|
||||||
|
titleHaystack := normalizeQobuzSearchValue(track.Title)
|
||||||
|
switch {
|
||||||
|
case titleNeedle != "" && titleHaystack == titleNeedle:
|
||||||
|
score += 1000
|
||||||
|
case titleNeedle != "" && (strings.Contains(titleHaystack, titleNeedle) || strings.Contains(titleNeedle, titleHaystack)):
|
||||||
|
score += 500
|
||||||
|
}
|
||||||
|
|
||||||
|
artistNeedle := normalizeQobuzSearchValue(spotifyArtistName)
|
||||||
|
artistHaystack := normalizeQobuzSearchValue(qobuzTrackDisplayArtist(track))
|
||||||
|
switch {
|
||||||
|
case artistNeedle != "" && artistHaystack == artistNeedle:
|
||||||
|
score += 300
|
||||||
|
case artistNeedle != "" && artistHaystack != "" && (strings.Contains(artistHaystack, artistNeedle) || strings.Contains(artistNeedle, artistHaystack)):
|
||||||
|
score += 180
|
||||||
|
}
|
||||||
|
|
||||||
|
albumNeedle := normalizeQobuzSearchValue(spotifyAlbumName)
|
||||||
|
albumHaystack := normalizeQobuzSearchValue(track.Album.Title)
|
||||||
|
switch {
|
||||||
|
case albumNeedle != "" && albumHaystack == albumNeedle:
|
||||||
|
score += 150
|
||||||
|
case albumNeedle != "" && albumHaystack != "" && (strings.Contains(albumHaystack, albumNeedle) || strings.Contains(albumNeedle, albumHaystack)):
|
||||||
|
score += 90
|
||||||
|
}
|
||||||
|
|
||||||
|
if qobuzTrackSupportsHiRes(track) {
|
||||||
|
score += 40
|
||||||
|
} else if track.MaximumBitDepth >= 16 {
|
||||||
|
score += 20
|
||||||
|
}
|
||||||
|
|
||||||
|
return score
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapQobuzWJHEQuality(quality string) (int, string) {
|
||||||
|
switch strings.TrimSpace(quality) {
|
||||||
|
case "27", "7":
|
||||||
|
return 2000, "flac"
|
||||||
|
case "", "6":
|
||||||
|
return 1000, "flac"
|
||||||
|
default:
|
||||||
|
return 320, "mp3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildQobuzWJHEDownloadURL(trackID int64, quality string) string {
|
||||||
|
wjheQuality, wjheFormat := mapQobuzWJHEQuality(quality)
|
||||||
|
params := url.Values{
|
||||||
|
"ID": {strconv.FormatInt(trackID, 10)},
|
||||||
|
"quality": {strconv.Itoa(wjheQuality)},
|
||||||
|
"format": {wjheFormat},
|
||||||
|
}
|
||||||
|
return GetQobuzWJHEStreamAPIURL() + "?" + params.Encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
func qobuzURLLooksStreamable(raw string) bool {
|
||||||
|
candidate := strings.TrimSpace(raw)
|
||||||
|
if candidate == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := url.Parse(candidate)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return (parsed.Scheme == "http" || parsed.Scheme == "https") && parsed.Host != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func findQobuzStreamingURLInPayload(payload interface{}) string {
|
||||||
|
switch value := payload.(type) {
|
||||||
|
case string:
|
||||||
|
candidate := strings.ReplaceAll(strings.TrimSpace(value), `\/`, `/`)
|
||||||
|
if qobuzURLLooksStreamable(candidate) {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
case []interface{}:
|
||||||
|
for _, item := range value {
|
||||||
|
if url := findQobuzStreamingURLInPayload(item); url != "" {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case map[string]interface{}:
|
||||||
|
for _, key := range []string{"download_url", "url", "play_url", "stream_url", "link", "file"} {
|
||||||
|
if nested, ok := value[key]; ok {
|
||||||
|
if url := findQobuzStreamingURLInPayload(nested); url != "" {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, nested := range value {
|
||||||
|
if url := findQobuzStreamingURLInPayload(nested); url != "" {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractQobuzStreamingURL(body []byte) string {
|
||||||
|
trimmed := strings.TrimSpace(string(body))
|
||||||
|
if trimmed == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var directResp struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
DownloadURL string `json:"download_url"`
|
||||||
|
Data struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
DownloadURL string `json:"download_url"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &directResp); err == nil {
|
||||||
|
for _, candidate := range []string{
|
||||||
|
directResp.DownloadURL,
|
||||||
|
directResp.URL,
|
||||||
|
directResp.Data.DownloadURL,
|
||||||
|
directResp.Data.URL,
|
||||||
|
} {
|
||||||
|
if qobuzURLLooksStreamable(candidate) {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var genericPayload interface{}
|
||||||
|
if err := json.Unmarshal(body, &genericPayload); err == nil {
|
||||||
|
if streamURL := findQobuzStreamingURLInPayload(genericPayload); streamURL != "" {
|
||||||
|
return streamURL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if openIdx := strings.Index(trimmed, "("); openIdx >= 0 {
|
||||||
|
if closeIdx := strings.LastIndex(trimmed, ")"); closeIdx > openIdx+1 {
|
||||||
|
callbackBody := strings.TrimSpace(trimmed[openIdx+1 : closeIdx])
|
||||||
|
if streamURL := extractQobuzStreamingURL([]byte(callbackBody)); streamURL != "" {
|
||||||
|
return streamURL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, match := range qobuzStreamingURLPattern.FindAllString(trimmed, -1) {
|
||||||
|
candidate := strings.ReplaceAll(match, `\/`, `/`)
|
||||||
|
if qobuzURLLooksStreamable(candidate) {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func newQobuzNoRedirectClient(base *http.Client) *http.Client {
|
||||||
|
if base == nil {
|
||||||
|
return &http.Client{
|
||||||
|
Timeout: 20 * time.Second,
|
||||||
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||||
|
return http.ErrUseLastResponse
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cloned := *base
|
||||||
|
if cloned.Timeout == 0 {
|
||||||
|
cloned.Timeout = 20 * time.Second
|
||||||
|
}
|
||||||
|
cloned.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||||
|
return http.ErrUseLastResponse
|
||||||
|
}
|
||||||
|
return &cloned
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *QobuzDownloader) searchByISRC(isrc string, spotifyTrackName string, spotifyArtistName string, spotifyAlbumName string) (*QobuzTrack, error) {
|
||||||
if strings.HasPrefix(isrc, "qobuz_") {
|
if strings.HasPrefix(isrc, "qobuz_") {
|
||||||
trackID := strings.TrimPrefix(isrc, "qobuz_")
|
trackID := strings.TrimSpace(strings.TrimPrefix(isrc, "qobuz_"))
|
||||||
resp, err := doQobuzSignedRequest(http.MethodGet, "track/get", url.Values{"track_id": {trackID}}, q.client)
|
resp, err := doQobuzSignedRequest(http.MethodGet, "track/get", url.Values{"track_id": {trackID}}, q.client)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to fetch track: %w", err)
|
return nil, fmt.Errorf("failed to fetch track from Qobuz public API: %w", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||||
|
return nil, fmt.Errorf("Qobuz public API track/get returned status %d: %s", resp.StatusCode, previewQobuzResponseBody(body, 256))
|
||||||
}
|
}
|
||||||
|
|
||||||
var trackResp QobuzTrack
|
var trackResp QobuzTrack
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&trackResp); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(&trackResp); err != nil {
|
||||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
return nil, fmt.Errorf("failed to decode Qobuz public track/get response: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &trackResp, nil
|
return &trackResp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := doQobuzSignedRequest(http.MethodGet, "track/search", url.Values{
|
queries := []string{strings.TrimSpace(isrc)}
|
||||||
"query": {isrc},
|
if fallbackQuery := strings.TrimSpace(strings.Join([]string{spotifyTrackName, spotifyArtistName}, " ")); fallbackQuery != "" {
|
||||||
"limit": {"1"},
|
queries = append(queries, fallbackQuery)
|
||||||
}, q.client)
|
}
|
||||||
|
|
||||||
|
var lastErr error
|
||||||
|
for _, query := range queries {
|
||||||
|
if strings.TrimSpace(query) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var searchResp qobuzPublicSearchResponse
|
||||||
|
if err := doQobuzSignedJSONRequest("track/search", url.Values{
|
||||||
|
"query": {strings.TrimSpace(query)},
|
||||||
|
"limit": {"10"},
|
||||||
|
}, &searchResp); err != nil {
|
||||||
|
lastErr = fmt.Errorf("failed to search Qobuz public API: %w", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if searchResp.Tracks.Total == 0 || len(searchResp.Tracks.Items) == 0 {
|
||||||
|
lastErr = fmt.Errorf("track not found for query: %s", query)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
bestIndex := 0
|
||||||
|
bestScore := -1
|
||||||
|
for idx, candidate := range searchResp.Tracks.Items {
|
||||||
|
score := scoreQobuzSearchCandidate(candidate, spotifyTrackName, spotifyArtistName, spotifyAlbumName)
|
||||||
|
if idx == 0 || score > bestScore {
|
||||||
|
bestIndex = idx
|
||||||
|
bestScore = score
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
selected := searchResp.Tracks.Items[bestIndex]
|
||||||
|
return &selected, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if lastErr == nil {
|
||||||
|
lastErr = fmt.Errorf("track not found for ISRC: %s", isrc)
|
||||||
|
}
|
||||||
|
return nil, lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *QobuzDownloader) DownloadFromWJHE(trackID int64, quality string) (string, error) {
|
||||||
|
apiURL := buildQobuzWJHEDownloadURL(trackID, quality)
|
||||||
|
client := newQobuzNoRedirectClient(q.client)
|
||||||
|
|
||||||
|
req, err := NewRequestWithDefaultHeaders(http.MethodHead, apiURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to search track: %w", err)
|
return "", fmt.Errorf("failed to create WJHE request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to reach WJHE: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode == http.StatusMethodNotAllowed || resp.StatusCode == http.StatusNotImplemented {
|
||||||
|
resp.Body.Close()
|
||||||
|
req, err = NewRequestWithDefaultHeaders(http.MethodGet, apiURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create WJHE fallback request: %w", err)
|
||||||
|
}
|
||||||
|
resp, err = client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to reach WJHE with GET fallback: %w", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if location := strings.TrimSpace(resp.Header.Get("Location")); qobuzURLLooksStreamable(location) {
|
||||||
|
return location, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 128*1024))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to read WJHE response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if streamURL := extractQobuzStreamingURL(body); streamURL != "" {
|
||||||
|
return streamURL, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.Request != nil && resp.Request.URL != nil {
|
||||||
|
if streamURL := strings.TrimSpace(resp.Request.URL.String()); streamURL != "" && streamURL != apiURL && qobuzURLLooksStreamable(streamURL) {
|
||||||
|
return streamURL, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusBadRequest {
|
||||||
|
return "", fmt.Errorf("WJHE returned status %d: %s", resp.StatusCode, previewQobuzResponseBody(body, 256))
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("WJHE response did not include a stream URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
func qobuzGDStudioPaddedVersion() string {
|
||||||
|
parts := strings.Split(GetQobuzGDStudioVersion(), ".")
|
||||||
|
for idx, part := range parts {
|
||||||
|
part = strings.TrimSpace(part)
|
||||||
|
if len(part) == 1 {
|
||||||
|
part = "0" + part
|
||||||
|
}
|
||||||
|
parts[idx] = part
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func qobuzGDStudioEscapedValue(value string) string {
|
||||||
|
return strings.ReplaceAll(url.QueryEscape(strings.TrimSpace(value)), "+", "%20")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *QobuzDownloader) getQobuzGDStudioTS9(apiURL string) string {
|
||||||
|
fallback := strconv.FormatInt(time.Now().UnixMilli(), 10)
|
||||||
|
if len(fallback) >= 9 {
|
||||||
|
fallback = fallback[:9]
|
||||||
|
}
|
||||||
|
|
||||||
|
client := q.client
|
||||||
|
if client == nil {
|
||||||
|
client = &http.Client{Timeout: 10 * time.Second}
|
||||||
|
}
|
||||||
|
|
||||||
|
signatureHost := GetQobuzGDStudioSignatureHost(apiURL)
|
||||||
|
if signatureHost == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := NewRequestWithDefaultHeaders(http.MethodGet, fmt.Sprintf("https://%s/time", signatureHost), nil)
|
||||||
|
if err != nil {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 64))
|
||||||
|
if err != nil {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
timestamp := strings.TrimSpace(string(body))
|
||||||
|
if len(timestamp) >= 9 {
|
||||||
|
return timestamp[:9]
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildQobuzGDStudioSignature(apiURL string, value string, ts9 string) string {
|
||||||
|
signatureHost := GetQobuzGDStudioSignatureHost(apiURL)
|
||||||
|
signatureBase := fmt.Sprintf("%s|%s|%s|%s", signatureHost, qobuzGDStudioPaddedVersion(), ts9, qobuzGDStudioEscapedValue(value))
|
||||||
|
sum := md5.Sum([]byte(signatureBase))
|
||||||
|
digest := hex.EncodeToString(sum[:])
|
||||||
|
return strings.ToUpper(digest[len(digest)-8:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapQobuzGDStudioBitrate(quality string) string {
|
||||||
|
switch strings.TrimSpace(quality) {
|
||||||
|
case "27", "7":
|
||||||
|
return "999"
|
||||||
|
case "", "6":
|
||||||
|
return "740"
|
||||||
|
default:
|
||||||
|
return "320"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *QobuzDownloader) DownloadFromGDStudio(trackID int64, quality string, apiURL string) (string, error) {
|
||||||
|
apiURL = strings.TrimSpace(apiURL)
|
||||||
|
if apiURL == "" {
|
||||||
|
apiURL = GetQobuzGDStudioPrimaryAPIURL()
|
||||||
|
}
|
||||||
|
|
||||||
|
signatureHost := GetQobuzGDStudioSignatureHost(apiURL)
|
||||||
|
if signatureHost == "" {
|
||||||
|
return "", fmt.Errorf("GDStudio API URL is invalid: %s", apiURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
trackIDString := strconv.FormatInt(trackID, 10)
|
||||||
|
ts9 := q.getQobuzGDStudioTS9(apiURL)
|
||||||
|
payload := url.Values{
|
||||||
|
"types": {"url"},
|
||||||
|
"id": {trackIDString},
|
||||||
|
"source": {"qobuz"},
|
||||||
|
"br": {mapQobuzGDStudioBitrate(quality)},
|
||||||
|
"s": {buildQobuzGDStudioSignature(apiURL, trackIDString, ts9)},
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := NewRequestWithDefaultHeaders(http.MethodPost, apiURL, strings.NewReader(payload.Encode()))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create GDStudio request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8")
|
||||||
|
req.Header.Set("Origin", fmt.Sprintf("https://%s", signatureHost))
|
||||||
|
req.Header.Set("Referer", fmt.Sprintf("https://%s/", signatureHost))
|
||||||
|
|
||||||
|
resp, err := q.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to reach GDStudio: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 256*1024))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to read GDStudio response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
|
return "", fmt.Errorf("GDStudio returned status %d: %s", resp.StatusCode, previewQobuzResponseBody(body, 256))
|
||||||
}
|
}
|
||||||
|
|
||||||
var searchResp QobuzSearchResponse
|
streamURL := extractQobuzStreamingURL(body)
|
||||||
|
if streamURL == "" {
|
||||||
body, err := io.ReadAll(resp.Body)
|
return "", fmt.Errorf("GDStudio response did not include a stream URL: %s", previewQobuzResponseBody(body, 256))
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to read response body: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(body) == 0 {
|
return streamURL, nil
|
||||||
return nil, fmt.Errorf("API returned empty response")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := json.Unmarshal(body, &searchResp); err != nil {
|
|
||||||
|
|
||||||
bodyStr := string(body)
|
|
||||||
if len(bodyStr) > 200 {
|
|
||||||
bodyStr = bodyStr[:200] + "..."
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("failed to decode response: %w (response: %s)", err, bodyStr)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(searchResp.Tracks.Items) == 0 {
|
|
||||||
return nil, fmt.Errorf("track not found for ISRC: %s", isrc)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &searchResp.Tracks.Items[0], nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildQobuzAPIURL(apiBase string, trackID int64, quality string) string {
|
func (q *QobuzDownloader) DownloadFromMusicDL(trackID int64, quality string) (string, error) {
|
||||||
if strings.Contains(apiBase, "qbz.afkarxyz.qzz.io") {
|
if strings.TrimSpace(quality) == "" {
|
||||||
return fmt.Sprintf("%s%d?quality=%s", apiBase, trackID, quality)
|
quality = "6"
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("%s%d&quality=%s", apiBase, trackID, quality)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (q *QobuzDownloader) DownloadFromStandard(apiBase string, trackID int64, quality string) (string, error) {
|
debugKey, err := getQobuzMusicDLDebugKey()
|
||||||
apiURL := buildQobuzAPIURL(apiBase, trackID, quality)
|
|
||||||
resp, err := q.client.Get(apiURL)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", fmt.Errorf("failed to decrypt MusicDL debug key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, err := json.Marshal(qobuzMusicDLRequest{
|
||||||
|
URL: buildQobuzOpenTrackURL(trackID),
|
||||||
|
Quality: strings.TrimSpace(quality),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to encode MusicDL request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := NewRequestWithDefaultHeaders(http.MethodPost, GetQobuzMusicDLDownloadAPIURL(), bytes.NewReader(payload))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create MusicDL request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("X-Debug-Key", debugKey)
|
||||||
|
|
||||||
|
resp, err := q.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to reach MusicDL: %w", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
return "", fmt.Errorf("status %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", fmt.Errorf("failed to read MusicDL response: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(body) == 0 {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return "", fmt.Errorf("empty body")
|
return "", fmt.Errorf("MusicDL returned status %d: %s", resp.StatusCode, previewQobuzResponseBody(body, 256))
|
||||||
}
|
}
|
||||||
|
|
||||||
var streamResp QobuzStreamResponse
|
var downloadResp qobuzMusicDLResponse
|
||||||
if err := json.Unmarshal(body, &streamResp); err == nil && streamResp.URL != "" {
|
if err := json.Unmarshal(body, &downloadResp); err != nil {
|
||||||
return streamResp.URL, nil
|
return "", fmt.Errorf("failed to decode MusicDL response: %w (%s)", err, previewQobuzResponseBody(body, 256))
|
||||||
}
|
}
|
||||||
|
|
||||||
var nestedResp struct {
|
if !downloadResp.Success {
|
||||||
Data struct {
|
message := strings.TrimSpace(downloadResp.Error)
|
||||||
URL string `json:"url"`
|
if message == "" {
|
||||||
} `json:"data"`
|
message = strings.TrimSpace(downloadResp.Message)
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(body, &nestedResp); err == nil && nestedResp.Data.URL != "" {
|
if message == "" {
|
||||||
return nestedResp.Data.URL, nil
|
message = "MusicDL reported failure"
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("%s", message)
|
||||||
}
|
}
|
||||||
|
|
||||||
return "", fmt.Errorf("invalid response")
|
downloadURL := strings.TrimSpace(downloadResp.DownloadURL)
|
||||||
|
if downloadURL == "" {
|
||||||
|
return "", fmt.Errorf("MusicDL response did not include a download_url")
|
||||||
|
}
|
||||||
|
|
||||||
|
return downloadURL, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func CheckQobuzMusicDLStatusDetailed(client *http.Client) error {
|
||||||
|
if client == nil {
|
||||||
|
client = &http.Client{Timeout: 4 * time.Second}
|
||||||
|
}
|
||||||
|
|
||||||
|
downloader := &QobuzDownloader{client: client}
|
||||||
|
_, err := downloader.DownloadFromMusicDL(qobuzProbeTrackID, "27")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func CheckQobuzMusicDLStatus(client *http.Client) bool {
|
||||||
|
return CheckQobuzMusicDLStatusDetailed(client) == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func CheckQobuzWJHEStatusDetailed(client *http.Client) error {
|
||||||
|
if client == nil {
|
||||||
|
client = &http.Client{Timeout: 4 * time.Second}
|
||||||
|
}
|
||||||
|
|
||||||
|
downloader := &QobuzDownloader{client: client}
|
||||||
|
_, err := downloader.DownloadFromWJHE(qobuzProbeTrackID, "27")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func CheckQobuzWJHEStatus(client *http.Client) bool {
|
||||||
|
return CheckQobuzWJHEStatusDetailed(client) == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func CheckQobuzGDStudioAPIStatusDetailed(client *http.Client, apiURL string) error {
|
||||||
|
if client == nil {
|
||||||
|
client = &http.Client{Timeout: 4 * time.Second}
|
||||||
|
}
|
||||||
|
|
||||||
|
downloader := &QobuzDownloader{client: client}
|
||||||
|
_, err := downloader.DownloadFromGDStudio(qobuzProbeTrackID, "27", apiURL)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func CheckQobuzGDStudioAPIStatus(client *http.Client, apiURL string) bool {
|
||||||
|
return CheckQobuzGDStudioAPIStatusDetailed(client, apiURL) == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string, allowFallback bool) (string, error) {
|
func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string, allowFallback bool) (string, error) {
|
||||||
@@ -191,45 +754,36 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string, allowFal
|
|||||||
|
|
||||||
fmt.Printf("Getting download URL for track ID: %d with requested quality: %s\n", trackID, qualityCode)
|
fmt.Printf("Getting download URL for track ID: %d with requested quality: %s\n", trackID, qualityCode)
|
||||||
|
|
||||||
standardAPIs := prioritizeProviders("qobuz", []string{
|
|
||||||
"https://dab.yeet.su/api/stream?trackId=",
|
|
||||||
"https://dabmusic.xyz/api/stream?trackId=",
|
|
||||||
"https://qbz.afkarxyz.qzz.io/api/track/",
|
|
||||||
})
|
|
||||||
|
|
||||||
downloadFunc := func(qual string) (string, error) {
|
downloadFunc := func(qual string) (string, error) {
|
||||||
type Provider struct {
|
attemptMap := make(map[string]qobuzProviderAttempt)
|
||||||
Name string
|
attemptIDs := make([]string, 0, len(GetQobuzDownloadProviderURLs()))
|
||||||
API string
|
for _, provider := range q.getQobuzDownloadProviders() {
|
||||||
Func func() (string, error)
|
for _, attempt := range provider.Attempts(trackID, qual) {
|
||||||
}
|
attemptMap[attempt.ID] = attempt
|
||||||
|
attemptIDs = append(attemptIDs, attempt.ID)
|
||||||
var providers []Provider
|
}
|
||||||
|
|
||||||
for _, api := range standardAPIs {
|
|
||||||
currentAPI := api
|
|
||||||
providers = append(providers, Provider{
|
|
||||||
Name: "Standard(" + currentAPI + ")",
|
|
||||||
API: currentAPI,
|
|
||||||
Func: func() (string, error) {
|
|
||||||
return q.DownloadFromStandard(currentAPI, trackID, qual)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
orderedProviderIDs := prioritizeProviders("qobuz", attemptIDs)
|
||||||
|
orderedProviderIDs = moveQobuzAttemptIDsLast(orderedProviderIDs, GetQobuzMusicDLDownloadAPIURL())
|
||||||
var lastErr error
|
var lastErr error
|
||||||
for _, p := range providers {
|
for _, providerID := range orderedProviderIDs {
|
||||||
fmt.Printf("Trying Provider: %s (Quality: %s)...\n", p.Name, qual)
|
attempt, ok := attemptMap[providerID]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
url, err := p.Func()
|
fmt.Printf("Trying Provider: %s (Quality: %s)...\n", attempt.Name, qual)
|
||||||
|
|
||||||
|
url, err := attempt.Download()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
fmt.Printf("✓ Success\n")
|
fmt.Printf("✓ Success\n")
|
||||||
recordProviderSuccess("qobuz", p.API)
|
recordProviderSuccess("qobuz", attempt.ID)
|
||||||
return url, nil
|
return url, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("Provider failed: %v\n", err)
|
fmt.Printf("Provider failed: %v\n", err)
|
||||||
recordProviderFailure("qobuz", p.API)
|
recordProviderFailure("qobuz", attempt.ID)
|
||||||
lastErr = err
|
lastErr = err
|
||||||
}
|
}
|
||||||
return "", lastErr
|
return "", lastErr
|
||||||
@@ -272,7 +826,12 @@ func (q *QobuzDownloader) DownloadFile(url, filepath string) error {
|
|||||||
Timeout: 5 * time.Minute,
|
Timeout: 5 * time.Minute,
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := downloadClient.Get(url)
|
req, err := NewRequestWithDefaultHeaders(http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create download request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := downloadClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to download file: %w", err)
|
return fmt.Errorf("failed to download file: %w", err)
|
||||||
}
|
}
|
||||||
@@ -306,7 +865,12 @@ func (q *QobuzDownloader) DownloadCoverArt(coverURL, filepath string) error {
|
|||||||
return fmt.Errorf("no cover URL provided")
|
return fmt.Errorf("no cover URL provided")
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := q.client.Get(coverURL)
|
req, err := NewRequestWithDefaultHeaders(http.MethodGet, coverURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create cover request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := q.client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to download cover: %w", err)
|
return fmt.Errorf("failed to download cover: %w", err)
|
||||||
}
|
}
|
||||||
@@ -432,7 +996,7 @@ func (q *QobuzDownloader) DownloadTrackWithISRC(isrc, outputDir, quality, filena
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
track, err := q.searchByISRC(isrc)
|
track, err := q.searchByISRC(isrc, spotifyTrackName, spotifyArtistName, spotifyAlbumName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -446,7 +1010,13 @@ func (q *QobuzDownloader) DownloadTrackWithISRC(isrc, outputDir, quality, filena
|
|||||||
|
|
||||||
qualityInfo := "Standard"
|
qualityInfo := "Standard"
|
||||||
if track.Hires {
|
if track.Hires {
|
||||||
|
if track.MaximumBitDepth > 0 && track.MaximumSamplingRate > 0 {
|
||||||
qualityInfo = fmt.Sprintf("Hi-Res (%d-bit / %.1f kHz)", track.MaximumBitDepth, track.MaximumSamplingRate)
|
qualityInfo = fmt.Sprintf("Hi-Res (%d-bit / %.1f kHz)", track.MaximumBitDepth, track.MaximumSamplingRate)
|
||||||
|
} else if track.MaximumBitDepth > 0 {
|
||||||
|
qualityInfo = fmt.Sprintf("Hi-Res available (%d-bit)", track.MaximumBitDepth)
|
||||||
|
} else {
|
||||||
|
qualityInfo = "Hi-Res available"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
fmt.Printf("Quality: %s\n", qualityInfo)
|
fmt.Printf("Quality: %s\n", qualityInfo)
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const (
|
|||||||
qobuzAPIBaseURL = "https://www.qobuz.com/api.json/0.2"
|
qobuzAPIBaseURL = "https://www.qobuz.com/api.json/0.2"
|
||||||
qobuzDefaultAPIAppID = "712109809"
|
qobuzDefaultAPIAppID = "712109809"
|
||||||
qobuzDefaultAPIAppSecret = "589be88e4538daea11f509d29e4a23b1"
|
qobuzDefaultAPIAppSecret = "589be88e4538daea11f509d29e4a23b1"
|
||||||
qobuzDefaultUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"
|
qobuzDefaultUA = DefaultDownloaderUserAgent
|
||||||
qobuzCredentialsCacheFile = "qobuz-api-credentials.json"
|
qobuzCredentialsCacheFile = "qobuz-api-credentials.json"
|
||||||
qobuzCredentialsCacheTTL = 24 * time.Hour
|
qobuzCredentialsCacheTTL = 24 * time.Hour
|
||||||
qobuzCredentialsProbeTrackISRC = "USUM71703861"
|
qobuzCredentialsProbeTrackISRC = "USUM71703861"
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package backend
|
||||||
|
|
||||||
|
type qobuzDownloadProvider interface {
|
||||||
|
Name() string
|
||||||
|
Attempts(trackID int64, quality string) []qobuzProviderAttempt
|
||||||
|
}
|
||||||
|
|
||||||
|
type qobuzProviderAttempt struct {
|
||||||
|
Name string
|
||||||
|
ID string
|
||||||
|
Download func() (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type QobuzProviderWJHE struct {
|
||||||
|
downloader *QobuzDownloader
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p QobuzProviderWJHE) Name() string {
|
||||||
|
return "QobuzProviderWJHE"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p QobuzProviderWJHE) Attempts(trackID int64, quality string) []qobuzProviderAttempt {
|
||||||
|
return []qobuzProviderAttempt{
|
||||||
|
{
|
||||||
|
Name: p.Name(),
|
||||||
|
ID: GetQobuzWJHEStreamAPIURL(),
|
||||||
|
Download: func() (string, error) {
|
||||||
|
return p.downloader.DownloadFromWJHE(trackID, quality)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type QobuzProviderMusicDL struct {
|
||||||
|
downloader *QobuzDownloader
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p QobuzProviderMusicDL) Name() string {
|
||||||
|
return "QobuzProviderMusicDL"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p QobuzProviderMusicDL) Attempts(trackID int64, quality string) []qobuzProviderAttempt {
|
||||||
|
return []qobuzProviderAttempt{
|
||||||
|
{
|
||||||
|
Name: p.Name(),
|
||||||
|
ID: GetQobuzMusicDLDownloadAPIURL(),
|
||||||
|
Download: func() (string, error) {
|
||||||
|
return p.downloader.DownloadFromMusicDL(trackID, quality)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type QobuzProviderGDStudio struct {
|
||||||
|
downloader *QobuzDownloader
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p QobuzProviderGDStudio) Name() string {
|
||||||
|
return "QobuzProviderGDStudio"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p QobuzProviderGDStudio) Attempts(trackID int64, quality string) []qobuzProviderAttempt {
|
||||||
|
attempts := make([]qobuzProviderAttempt, 0, len(GetQobuzGDStudioAPIURLs()))
|
||||||
|
for _, apiURL := range GetQobuzGDStudioAPIURLs() {
|
||||||
|
currentAPIURL := apiURL
|
||||||
|
attempts = append(attempts, qobuzProviderAttempt{
|
||||||
|
Name: p.Name(),
|
||||||
|
ID: currentAPIURL,
|
||||||
|
Download: func() (string, error) {
|
||||||
|
return p.downloader.DownloadFromGDStudio(trackID, quality, currentAPIURL)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return attempts
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *QobuzDownloader) getQobuzDownloadProviders() []qobuzDownloadProvider {
|
||||||
|
return []qobuzDownloadProvider{
|
||||||
|
QobuzProviderWJHE{downloader: q},
|
||||||
|
QobuzProviderGDStudio{downloader: q},
|
||||||
|
QobuzProviderMusicDL{downloader: q},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func moveQobuzAttemptIDsLast(providerIDs []string, lastIDs ...string) []string {
|
||||||
|
if len(providerIDs) == 0 || len(lastIDs) == 0 {
|
||||||
|
return append([]string(nil), providerIDs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
lastIDSet := make(map[string]struct{}, len(lastIDs))
|
||||||
|
for _, providerID := range lastIDs {
|
||||||
|
lastIDSet[providerID] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
ordered := make([]string, 0, len(providerIDs))
|
||||||
|
trailing := make([]string, 0, len(providerIDs))
|
||||||
|
for _, providerID := range providerIDs {
|
||||||
|
if _, ok := lastIDSet[providerID]; ok {
|
||||||
|
trailing = append(trailing, providerID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ordered = append(ordered, providerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return append(ordered, trailing...)
|
||||||
|
}
|
||||||
+259
-359
@@ -48,23 +48,154 @@ type TidalBTSManifest struct {
|
|||||||
URLs []string `json:"urls"`
|
URLs []string `json:"urls"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getConfiguredTidalAPIAttemptList() ([]string, error) {
|
||||||
|
customAPI := GetCustomTidalAPISetting()
|
||||||
|
if customAPI == "" {
|
||||||
|
return nil, fmt.Errorf("no configured custom tidal api instance")
|
||||||
|
}
|
||||||
|
return []string{customAPI}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildTidalOutputPath(outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyTrackNumber, spotifyDiscNumber int, isrcOverride string, useFirstArtistOnly bool) (string, bool, error) {
|
||||||
|
if outputDir != "." {
|
||||||
|
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||||
|
return "", false, fmt.Errorf("directory error: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
artistNameForFile := sanitizeFilename(spotifyArtistName)
|
||||||
|
albumArtistForFile := sanitizeFilename(spotifyAlbumArtist)
|
||||||
|
if useFirstArtistOnly {
|
||||||
|
artistNameForFile = sanitizeFilename(GetFirstArtist(spotifyArtistName))
|
||||||
|
albumArtistForFile = sanitizeFilename(GetFirstArtist(spotifyAlbumArtist))
|
||||||
|
}
|
||||||
|
|
||||||
|
trackTitleForFile := sanitizeFilename(spotifyTrackName)
|
||||||
|
albumTitleForFile := sanitizeFilename(spotifyAlbumName)
|
||||||
|
|
||||||
|
filename := buildTidalFilename(trackTitleForFile, artistNameForFile, albumTitleForFile, albumArtistForFile, spotifyReleaseDate, spotifyTrackNumber, spotifyDiscNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber, isrcOverride)
|
||||||
|
outputFilename := filepath.Join(outputDir, filename)
|
||||||
|
|
||||||
|
outputFilename, alreadyExists := ResolveOutputPathForDownload(outputFilename, GetRedownloadWithSuffixSetting())
|
||||||
|
return outputFilename, alreadyExists, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func finalizeTidalDownload(outputFilename, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL string, useSingleGenre bool, embedGenre bool) {
|
||||||
|
trackTitle := spotifyTrackName
|
||||||
|
artistName := spotifyArtistName
|
||||||
|
albumTitle := spotifyAlbumName
|
||||||
|
|
||||||
|
type mbResult struct {
|
||||||
|
ISRC string
|
||||||
|
Metadata Metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
metaChan := make(chan mbResult, 1)
|
||||||
|
if embedGenre && spotifyURL != "" {
|
||||||
|
go func() {
|
||||||
|
res := mbResult{}
|
||||||
|
var isrc string
|
||||||
|
parts := strings.Split(spotifyURL, "/")
|
||||||
|
if len(parts) > 0 {
|
||||||
|
sID := strings.Split(parts[len(parts)-1], "?")[0]
|
||||||
|
if sID != "" {
|
||||||
|
client := NewSongLinkClient()
|
||||||
|
if val, err := client.GetISRC(sID); err == nil {
|
||||||
|
isrc = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res.ISRC = isrc
|
||||||
|
if isrc != "" {
|
||||||
|
if ShouldSkipMusicBrainzMetadataFetch() {
|
||||||
|
fmt.Println("Skipping MusicBrainz metadata fetch because status check is offline.")
|
||||||
|
} else {
|
||||||
|
fmt.Println("Fetching MusicBrainz metadata...")
|
||||||
|
if fetchedMeta, err := FetchMusicBrainzMetadata(isrc, trackTitle, artistName, albumTitle, useSingleGenre, embedGenre); err == nil {
|
||||||
|
res.Metadata = fetchedMeta
|
||||||
|
fmt.Println("✓ MusicBrainz metadata fetched")
|
||||||
|
} else {
|
||||||
|
fmt.Printf("Warning: Failed to fetch MusicBrainz metadata: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
metaChan <- res
|
||||||
|
}()
|
||||||
|
} else {
|
||||||
|
close(metaChan)
|
||||||
|
}
|
||||||
|
|
||||||
|
isrc := strings.TrimSpace(isrcOverride)
|
||||||
|
var mbMeta Metadata
|
||||||
|
if spotifyURL != "" {
|
||||||
|
result := <-metaChan
|
||||||
|
if isrc == "" {
|
||||||
|
isrc = result.ISRC
|
||||||
|
}
|
||||||
|
mbMeta = result.Metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
upc := ""
|
||||||
|
if spotifyURL != "" {
|
||||||
|
if identifiers, err := GetSpotifyTrackIdentifiersDirect(spotifyURL); err == nil || identifiers.ISRC != "" || identifiers.UPC != "" {
|
||||||
|
if strings.TrimSpace(isrc) == "" && strings.TrimSpace(identifiers.ISRC) != "" {
|
||||||
|
isrc = strings.TrimSpace(identifiers.ISRC)
|
||||||
|
}
|
||||||
|
upc = strings.TrimSpace(identifiers.UPC)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Adding metadata...")
|
||||||
|
|
||||||
|
coverPath := ""
|
||||||
|
if spotifyCoverURL != "" {
|
||||||
|
coverPath = outputFilename + ".cover.jpg"
|
||||||
|
coverClient := NewCoverClient()
|
||||||
|
if err := coverClient.DownloadCoverToPath(spotifyCoverURL, coverPath, embedMaxQualityCover); err != nil {
|
||||||
|
fmt.Printf("Warning: Failed to download Spotify cover: %v\n", err)
|
||||||
|
coverPath = ""
|
||||||
|
} else {
|
||||||
|
defer os.Remove(coverPath)
|
||||||
|
fmt.Println("Spotify cover downloaded")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
trackNumberToEmbed := spotifyTrackNumber
|
||||||
|
if trackNumberToEmbed == 0 {
|
||||||
|
trackNumberToEmbed = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata := Metadata{
|
||||||
|
Title: trackTitle,
|
||||||
|
Artist: artistName,
|
||||||
|
Album: albumTitle,
|
||||||
|
AlbumArtist: spotifyAlbumArtist,
|
||||||
|
Date: spotifyReleaseDate,
|
||||||
|
TrackNumber: trackNumberToEmbed,
|
||||||
|
TotalTracks: spotifyTotalTracks,
|
||||||
|
DiscNumber: spotifyDiscNumber,
|
||||||
|
TotalDiscs: spotifyTotalDiscs,
|
||||||
|
URL: spotifyURL,
|
||||||
|
Comment: spotifyURL,
|
||||||
|
Copyright: spotifyCopyright,
|
||||||
|
Publisher: spotifyPublisher,
|
||||||
|
Composer: spotifyComposer,
|
||||||
|
Separator: metadataSeparator,
|
||||||
|
Description: "https://github.com/spotbye/SpotiFLAC",
|
||||||
|
ISRC: isrc,
|
||||||
|
UPC: upc,
|
||||||
|
Genre: mbMeta.Genre,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := EmbedMetadata(outputFilename, metadata, coverPath); err != nil {
|
||||||
|
fmt.Printf("Tagging failed: %v\n", err)
|
||||||
|
} else {
|
||||||
|
fmt.Println("Metadata saved")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func NewTidalDownloader(apiURL string) *TidalDownloader {
|
func NewTidalDownloader(apiURL string) *TidalDownloader {
|
||||||
if apiURL == "" {
|
apiURL = strings.TrimRight(strings.TrimSpace(apiURL), "/")
|
||||||
downloader := &TidalDownloader{
|
|
||||||
client: &http.Client{
|
|
||||||
Timeout: 5 * time.Second,
|
|
||||||
},
|
|
||||||
timeout: 5 * time.Second,
|
|
||||||
maxRetries: 3,
|
|
||||||
apiURL: "",
|
|
||||||
}
|
|
||||||
|
|
||||||
apis, err := downloader.GetAvailableAPIs()
|
|
||||||
if err == nil && len(apis) > 0 {
|
|
||||||
apiURL = apis[0]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return &TidalDownloader{
|
return &TidalDownloader{
|
||||||
client: &http.Client{
|
client: &http.Client{
|
||||||
Timeout: 5 * time.Second,
|
Timeout: 5 * time.Second,
|
||||||
@@ -76,16 +207,12 @@ func NewTidalDownloader(apiURL string) *TidalDownloader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (t *TidalDownloader) GetAvailableAPIs() ([]string, error) {
|
func (t *TidalDownloader) GetAvailableAPIs() ([]string, error) {
|
||||||
apis := []string{
|
apis, err := getConfiguredTidalAPIAttemptList()
|
||||||
"https://hifi-one.spotisaver.net",
|
if err == nil && len(apis) > 0 {
|
||||||
"https://hifi-two.spotisaver.net",
|
return apis, nil
|
||||||
"https://eu-central.monochrome.tf",
|
|
||||||
"https://us-west.monochrome.tf",
|
|
||||||
"https://api.monochrome.tf",
|
|
||||||
"https://monochrome-api.samidy.com",
|
|
||||||
"https://tidal.kinoplus.online",
|
|
||||||
}
|
}
|
||||||
return prioritizeProviders("tidal", apis), nil
|
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *TidalDownloader) GetTidalURLFromSpotify(spotifyTrackID string) (string, error) {
|
func (t *TidalDownloader) GetTidalURLFromSpotify(spotifyTrackID string) (string, error) {
|
||||||
@@ -125,18 +252,19 @@ func (t *TidalDownloader) GetTrackIDFromURL(tidalURL string) (int64, error) {
|
|||||||
|
|
||||||
func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (string, error) {
|
func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (string, error) {
|
||||||
fmt.Println("Fetching URL...")
|
fmt.Println("Fetching URL...")
|
||||||
|
if strings.TrimSpace(t.apiURL) == "" {
|
||||||
|
return "", fmt.Errorf("no configured custom tidal api instance")
|
||||||
|
}
|
||||||
|
|
||||||
url := fmt.Sprintf("%s/track/?id=%d&quality=%s", t.apiURL, trackID, quality)
|
url := fmt.Sprintf("%s/track/?id=%d&quality=%s", t.apiURL, trackID, quality)
|
||||||
fmt.Printf("Tidal API URL: %s\n", url)
|
fmt.Printf("Tidal API URL: %s\n", url)
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", url, nil)
|
req, err := NewRequestWithDefaultHeaders(http.MethodGet, url, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("✗ failed to create request: %v\n", err)
|
fmt.Printf("✗ failed to create request: %v\n", err)
|
||||||
return "", fmt.Errorf("failed to create request: %w", err)
|
return "", fmt.Errorf("failed to create request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36")
|
|
||||||
|
|
||||||
resp, err := t.client.Do(req)
|
resp, err := t.client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("✗ Tidal API request failed: %v\n", err)
|
fmt.Printf("✗ Tidal API request failed: %v\n", err)
|
||||||
@@ -188,19 +316,17 @@ func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (string,
|
|||||||
return "", fmt.Errorf("download URL not found in response")
|
return "", fmt.Errorf("download URL not found in response")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *TidalDownloader) DownloadFile(url, filepath string) error {
|
func (t *TidalDownloader) DownloadFile(url, filepath string, quality string) error {
|
||||||
|
|
||||||
if strings.HasPrefix(url, "MANIFEST:") {
|
if strings.HasPrefix(url, "MANIFEST:") {
|
||||||
return t.DownloadFromManifest(strings.TrimPrefix(url, "MANIFEST:"), filepath)
|
return t.DownloadFromManifest(strings.TrimPrefix(url, "MANIFEST:"), filepath, quality)
|
||||||
}
|
}
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", url, nil)
|
req, err := NewRequestWithDefaultHeaders(http.MethodGet, url, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create request: %w", err)
|
return fmt.Errorf("failed to create request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36")
|
|
||||||
|
|
||||||
resp, err := t.client.Do(req)
|
resp, err := t.client.Do(req)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -230,22 +356,27 @@ func (t *TidalDownloader) DownloadFile(url, filepath string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *TidalDownloader) DownloadFromManifest(manifestB64, outputPath string) error {
|
func (t *TidalDownloader) DownloadFromManifest(manifestB64, outputPath string, quality string) error {
|
||||||
directURL, initURL, mediaURLs, mimeType, err := parseManifest(manifestB64)
|
directURL, initURL, mediaURLs, mimeType, err := parseManifest(manifestB64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to parse manifest: %w", err)
|
return fmt.Errorf("failed to parse manifest: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isLosslessRequested := quality == "LOSSLESS" || quality == "HI_RES" || quality == "HI_RES_LOSSLESS"
|
||||||
|
isActualLossless := strings.Contains(strings.ToLower(mimeType), "flac") || mimeType == ""
|
||||||
|
if isLosslessRequested && !isActualLossless {
|
||||||
|
return fmt.Errorf("requested %s quality but Tidal provided lossy format (%s). Aborting download", quality, mimeType)
|
||||||
|
}
|
||||||
|
|
||||||
client := &http.Client{
|
client := &http.Client{
|
||||||
Timeout: 120 * time.Second,
|
Timeout: 120 * time.Second,
|
||||||
}
|
}
|
||||||
|
|
||||||
doRequest := func(url string) (*http.Response, error) {
|
doRequest := func(url string) (*http.Response, error) {
|
||||||
req, err := http.NewRequest("GET", url, nil)
|
req, err := NewRequestWithDefaultHeaders(http.MethodGet, url, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36")
|
|
||||||
return client.Do(req)
|
return client.Do(req)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -417,12 +548,6 @@ func (t *TidalDownloader) DownloadFromManifest(manifestB64, outputPath string) e
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) {
|
func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) {
|
||||||
if outputDir != "." {
|
|
||||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
|
||||||
return "", fmt.Errorf("directory error: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("Using Tidal URL: %s\n", tidalURL)
|
fmt.Printf("Using Tidal URL: %s\n", tidalURL)
|
||||||
|
|
||||||
trackID, err := t.GetTrackIDFromURL(tidalURL)
|
trackID, err := t.GetTrackIDFromURL(tidalURL)
|
||||||
@@ -434,25 +559,10 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo
|
|||||||
return "", fmt.Errorf("no track ID found")
|
return "", fmt.Errorf("no track ID found")
|
||||||
}
|
}
|
||||||
|
|
||||||
artistName := spotifyArtistName
|
outputFilename, alreadyExists, err := buildTidalOutputPath(outputDir, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyTrackNumber, spotifyDiscNumber, isrcOverride, useFirstArtistOnly)
|
||||||
trackTitle := spotifyTrackName
|
if err != nil {
|
||||||
albumTitle := spotifyAlbumName
|
return "", err
|
||||||
|
|
||||||
artistNameForFile := sanitizeFilename(artistName)
|
|
||||||
albumArtistForFile := sanitizeFilename(spotifyAlbumArtist)
|
|
||||||
|
|
||||||
if useFirstArtistOnly {
|
|
||||||
artistNameForFile = sanitizeFilename(GetFirstArtist(artistName))
|
|
||||||
albumArtistForFile = sanitizeFilename(GetFirstArtist(spotifyAlbumArtist))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
trackTitleForFile := sanitizeFilename(trackTitle)
|
|
||||||
albumTitleForFile := sanitizeFilename(albumTitle)
|
|
||||||
|
|
||||||
filename := buildTidalFilename(trackTitleForFile, artistNameForFile, albumTitleForFile, albumArtistForFile, spotifyReleaseDate, spotifyTrackNumber, spotifyDiscNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber, isrcOverride)
|
|
||||||
outputFilename := filepath.Join(outputDir, filename)
|
|
||||||
|
|
||||||
outputFilename, alreadyExists := ResolveOutputPathForDownload(outputFilename, GetRedownloadWithSuffixSetting())
|
|
||||||
if alreadyExists {
|
if alreadyExists {
|
||||||
fmt.Printf("File already exists: %s (%.2f MB)\n", outputFilename, float64(mustFileSize(outputFilename))/(1024*1024))
|
fmt.Printf("File already exists: %s (%.2f MB)\n", outputFilename, float64(mustFileSize(outputFilename))/(1024*1024))
|
||||||
return "EXISTS:" + outputFilename, nil
|
return "EXISTS:" + outputFilename, nil
|
||||||
@@ -460,119 +570,24 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo
|
|||||||
|
|
||||||
downloadURL, err := t.GetDownloadURL(trackID, quality)
|
downloadURL, err := t.GetDownloadURL(trackID, quality)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if quality == "HI_RES" && allowFallback {
|
if isTidalHiResQuality(quality) && allowFallback {
|
||||||
fmt.Println("⚠ HI_RES unavailable/failed, falling back to LOSSLESS...")
|
fmt.Println("⚠ HI_RES unavailable/failed, falling back to LOSSLESS...")
|
||||||
downloadURL, err = t.GetDownloadURL(trackID, "LOSSLESS")
|
downloadURL, err = t.GetDownloadURL(trackID, "LOSSLESS")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to get download URL (HI_RES & LOSSLESS both failed): %w", err)
|
return outputFilename, fmt.Errorf("failed to get download URL (HI_RES & LOSSLESS both failed): %w", err)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return "", err
|
return outputFilename, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type mbResult struct {
|
|
||||||
ISRC string
|
|
||||||
Metadata Metadata
|
|
||||||
}
|
|
||||||
|
|
||||||
metaChan := make(chan mbResult, 1)
|
|
||||||
if embedGenre && spotifyURL != "" {
|
|
||||||
go func() {
|
|
||||||
res := mbResult{}
|
|
||||||
var isrc string
|
|
||||||
parts := strings.Split(spotifyURL, "/")
|
|
||||||
if len(parts) > 0 {
|
|
||||||
sID := strings.Split(parts[len(parts)-1], "?")[0]
|
|
||||||
if sID != "" {
|
|
||||||
client := NewSongLinkClient()
|
|
||||||
if val, err := client.GetISRC(sID); err == nil {
|
|
||||||
isrc = val
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
res.ISRC = isrc
|
|
||||||
if isrc != "" {
|
|
||||||
if ShouldSkipMusicBrainzMetadataFetch() {
|
|
||||||
fmt.Println("Skipping MusicBrainz metadata fetch because status check is offline.")
|
|
||||||
} else {
|
|
||||||
fmt.Println("Fetching MusicBrainz metadata...")
|
|
||||||
if fetchedMeta, err := FetchMusicBrainzMetadata(isrc, trackTitle, artistName, albumTitle, useSingleGenre, embedGenre); err == nil {
|
|
||||||
res.Metadata = fetchedMeta
|
|
||||||
fmt.Println("✓ MusicBrainz metadata fetched")
|
|
||||||
} else {
|
|
||||||
fmt.Printf("Warning: Failed to fetch MusicBrainz metadata: %v\n", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
metaChan <- res
|
|
||||||
}()
|
|
||||||
} else {
|
|
||||||
close(metaChan)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("Downloading to: %s\n", outputFilename)
|
fmt.Printf("Downloading to: %s\n", outputFilename)
|
||||||
if err := t.DownloadFile(downloadURL, outputFilename); err != nil {
|
if err := t.DownloadFile(downloadURL, outputFilename, quality); err != nil {
|
||||||
return "", err
|
cleanupTidalDownloadArtifacts(outputFilename)
|
||||||
|
return outputFilename, err
|
||||||
}
|
}
|
||||||
|
|
||||||
isrc := strings.TrimSpace(isrcOverride)
|
finalizeTidalDownload(outputFilename, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL, useSingleGenre, embedGenre)
|
||||||
var mbMeta Metadata
|
|
||||||
if spotifyURL != "" {
|
|
||||||
result := <-metaChan
|
|
||||||
if isrc == "" {
|
|
||||||
isrc = result.ISRC
|
|
||||||
}
|
|
||||||
mbMeta = result.Metadata
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println("Adding metadata...")
|
|
||||||
|
|
||||||
coverPath := ""
|
|
||||||
|
|
||||||
if spotifyCoverURL != "" {
|
|
||||||
coverPath = outputFilename + ".cover.jpg"
|
|
||||||
coverClient := NewCoverClient()
|
|
||||||
if err := coverClient.DownloadCoverToPath(spotifyCoverURL, coverPath, embedMaxQualityCover); err != nil {
|
|
||||||
fmt.Printf("Warning: Failed to download Spotify cover: %v\n", err)
|
|
||||||
coverPath = ""
|
|
||||||
} else {
|
|
||||||
defer os.Remove(coverPath)
|
|
||||||
fmt.Println("Spotify cover downloaded")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
trackNumberToEmbed := spotifyTrackNumber
|
|
||||||
if trackNumberToEmbed == 0 {
|
|
||||||
trackNumberToEmbed = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
metadata := Metadata{
|
|
||||||
Title: trackTitle,
|
|
||||||
Artist: artistName,
|
|
||||||
Album: albumTitle,
|
|
||||||
AlbumArtist: spotifyAlbumArtist,
|
|
||||||
Date: spotifyReleaseDate,
|
|
||||||
TrackNumber: trackNumberToEmbed,
|
|
||||||
TotalTracks: spotifyTotalTracks,
|
|
||||||
DiscNumber: spotifyDiscNumber,
|
|
||||||
TotalDiscs: spotifyTotalDiscs,
|
|
||||||
URL: spotifyURL,
|
|
||||||
Comment: spotifyURL,
|
|
||||||
Copyright: spotifyCopyright,
|
|
||||||
Publisher: spotifyPublisher,
|
|
||||||
Composer: spotifyComposer,
|
|
||||||
Separator: metadataSeparator,
|
|
||||||
Description: "https://github.com/spotbye/SpotiFLAC",
|
|
||||||
ISRC: isrc,
|
|
||||||
Genre: mbMeta.Genre,
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := EmbedMetadata(outputFilename, metadata, coverPath); err != nil {
|
|
||||||
fmt.Printf("Tagging failed: %v\n", err)
|
|
||||||
} else {
|
|
||||||
fmt.Println("Metadata saved")
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println("Done")
|
fmt.Println("Done")
|
||||||
fmt.Println("✓ Downloaded successfully from Tidal")
|
fmt.Println("✓ Downloaded successfully from Tidal")
|
||||||
@@ -580,17 +595,6 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) {
|
func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) {
|
||||||
apis, err := t.GetAvailableAPIs()
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("no APIs available for fallback: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if outputDir != "." {
|
|
||||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
|
||||||
return "", fmt.Errorf("directory error: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("Using Tidal URL: %s\n", tidalURL)
|
fmt.Printf("Using Tidal URL: %s\n", tidalURL)
|
||||||
|
|
||||||
trackID, err := t.GetTrackIDFromURL(tidalURL)
|
trackID, err := t.GetTrackIDFromURL(tidalURL)
|
||||||
@@ -602,146 +606,24 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality
|
|||||||
return "", fmt.Errorf("no track ID found")
|
return "", fmt.Errorf("no track ID found")
|
||||||
}
|
}
|
||||||
|
|
||||||
artistName := spotifyArtistName
|
outputFilename, alreadyExists, err := buildTidalOutputPath(outputDir, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyTrackNumber, spotifyDiscNumber, isrcOverride, useFirstArtistOnly)
|
||||||
trackTitle := spotifyTrackName
|
if err != nil {
|
||||||
albumTitle := spotifyAlbumName
|
return "", err
|
||||||
|
|
||||||
artistNameForFile := sanitizeFilename(artistName)
|
|
||||||
albumArtistForFile := sanitizeFilename(spotifyAlbumArtist)
|
|
||||||
|
|
||||||
if useFirstArtistOnly {
|
|
||||||
artistNameForFile = sanitizeFilename(GetFirstArtist(artistName))
|
|
||||||
albumArtistForFile = sanitizeFilename(GetFirstArtist(spotifyAlbumArtist))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
trackTitleForFile := sanitizeFilename(trackTitle)
|
|
||||||
albumTitleForFile := sanitizeFilename(albumTitle)
|
|
||||||
|
|
||||||
filename := buildTidalFilename(trackTitleForFile, artistNameForFile, albumTitleForFile, albumArtistForFile, spotifyReleaseDate, spotifyTrackNumber, spotifyDiscNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber, isrcOverride)
|
|
||||||
outputFilename := filepath.Join(outputDir, filename)
|
|
||||||
|
|
||||||
outputFilename, alreadyExists := ResolveOutputPathForDownload(outputFilename, GetRedownloadWithSuffixSetting())
|
|
||||||
if alreadyExists {
|
if alreadyExists {
|
||||||
fmt.Printf("File already exists: %s (%.2f MB)\n", outputFilename, float64(mustFileSize(outputFilename))/(1024*1024))
|
fmt.Printf("File already exists: %s (%.2f MB)\n", outputFilename, float64(mustFileSize(outputFilename))/(1024*1024))
|
||||||
return "EXISTS:" + outputFilename, nil
|
return "EXISTS:" + outputFilename, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
successAPI, downloadURL, err := getDownloadURLRotated(apis, trackID, quality)
|
|
||||||
if err != nil {
|
|
||||||
if quality == "HI_RES" && allowFallback {
|
|
||||||
fmt.Println("⚠ HI_RES unavailable/failed on all APIs, falling back to LOSSLESS...")
|
|
||||||
successAPI, downloadURL, err = getDownloadURLRotated(apis, trackID, "LOSSLESS")
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to get download URL (HI_RES & LOSSLESS both failed): %w", err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type mbResultFallback struct {
|
|
||||||
ISRC string
|
|
||||||
Metadata Metadata
|
|
||||||
}
|
|
||||||
|
|
||||||
metaChan := make(chan mbResultFallback, 1)
|
|
||||||
if embedGenre && spotifyURL != "" {
|
|
||||||
go func() {
|
|
||||||
res := mbResultFallback{}
|
|
||||||
var isrc string
|
|
||||||
parts := strings.Split(spotifyURL, "/")
|
|
||||||
if len(parts) > 0 {
|
|
||||||
sID := strings.Split(parts[len(parts)-1], "?")[0]
|
|
||||||
if sID != "" {
|
|
||||||
client := NewSongLinkClient()
|
|
||||||
if val, err := client.GetISRC(sID); err == nil {
|
|
||||||
isrc = val
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
res.ISRC = isrc
|
|
||||||
if isrc != "" {
|
|
||||||
if ShouldSkipMusicBrainzMetadataFetch() {
|
|
||||||
fmt.Println("Skipping MusicBrainz metadata fetch because status check is offline.")
|
|
||||||
} else {
|
|
||||||
fmt.Println("Fetching MusicBrainz metadata...")
|
|
||||||
if fetchedMeta, err := FetchMusicBrainzMetadata(isrc, trackTitle, artistName, albumTitle, useSingleGenre, embedGenre); err == nil {
|
|
||||||
res.Metadata = fetchedMeta
|
|
||||||
fmt.Println("✓ MusicBrainz metadata fetched")
|
|
||||||
} else {
|
|
||||||
fmt.Printf("Warning: Failed to fetch MusicBrainz metadata: %v\n", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
metaChan <- res
|
|
||||||
}()
|
|
||||||
} else {
|
|
||||||
close(metaChan)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("Downloading to: %s\n", outputFilename)
|
fmt.Printf("Downloading to: %s\n", outputFilename)
|
||||||
downloader := NewTidalDownloader(successAPI)
|
successAPI, err := t.downloadWithRotatingAPIs(trackID, outputFilename, quality, allowFallback)
|
||||||
if err := downloader.DownloadFile(downloadURL, outputFilename); err != nil {
|
if err != nil {
|
||||||
return "", err
|
cleanupTidalDownloadArtifacts(outputFilename)
|
||||||
|
return outputFilename, err
|
||||||
}
|
}
|
||||||
|
fmt.Printf("✓ Downloaded using API: %s\n", successAPI)
|
||||||
|
|
||||||
isrc := strings.TrimSpace(isrcOverride)
|
finalizeTidalDownload(outputFilename, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL, useSingleGenre, embedGenre)
|
||||||
var mbMeta Metadata
|
|
||||||
if spotifyURL != "" {
|
|
||||||
result := <-metaChan
|
|
||||||
if isrc == "" {
|
|
||||||
isrc = result.ISRC
|
|
||||||
}
|
|
||||||
mbMeta = result.Metadata
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println("Adding metadata...")
|
|
||||||
|
|
||||||
coverPath := ""
|
|
||||||
|
|
||||||
if spotifyCoverURL != "" {
|
|
||||||
coverPath = outputFilename + ".cover.jpg"
|
|
||||||
coverClient := NewCoverClient()
|
|
||||||
if err := coverClient.DownloadCoverToPath(spotifyCoverURL, coverPath, embedMaxQualityCover); err != nil {
|
|
||||||
fmt.Printf("Warning: Failed to download Spotify cover: %v\n", err)
|
|
||||||
coverPath = ""
|
|
||||||
} else {
|
|
||||||
defer os.Remove(coverPath)
|
|
||||||
fmt.Println("Spotify cover downloaded")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
trackNumberToEmbed := spotifyTrackNumber
|
|
||||||
if trackNumberToEmbed == 0 {
|
|
||||||
trackNumberToEmbed = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
metadata := Metadata{
|
|
||||||
Title: trackTitle,
|
|
||||||
Artist: artistName,
|
|
||||||
Album: albumTitle,
|
|
||||||
AlbumArtist: spotifyAlbumArtist,
|
|
||||||
Date: spotifyReleaseDate,
|
|
||||||
TrackNumber: trackNumberToEmbed,
|
|
||||||
TotalTracks: spotifyTotalTracks,
|
|
||||||
DiscNumber: spotifyDiscNumber,
|
|
||||||
TotalDiscs: spotifyTotalDiscs,
|
|
||||||
URL: spotifyURL,
|
|
||||||
Comment: spotifyURL,
|
|
||||||
Copyright: spotifyCopyright,
|
|
||||||
Publisher: spotifyPublisher,
|
|
||||||
Composer: spotifyComposer,
|
|
||||||
Separator: metadataSeparator,
|
|
||||||
Description: "https://github.com/spotbye/SpotiFLAC",
|
|
||||||
ISRC: isrc,
|
|
||||||
Genre: mbMeta.Genre,
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := EmbedMetadata(outputFilename, metadata, coverPath); err != nil {
|
|
||||||
fmt.Printf("Tagging failed: %v\n", err)
|
|
||||||
} else {
|
|
||||||
fmt.Println("Metadata saved")
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println("Done")
|
fmt.Println("Done")
|
||||||
fmt.Println("✓ Downloaded successfully from Tidal")
|
fmt.Println("✓ Downloaded successfully from Tidal")
|
||||||
@@ -752,10 +634,13 @@ func (t *TidalDownloader) Download(spotifyTrackID, outputDir, quality, filenameF
|
|||||||
|
|
||||||
tidalURL, err := t.GetTidalURLFromSpotify(spotifyTrackID)
|
tidalURL, err := t.GetTidalURLFromSpotify(spotifyTrackID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("songlink couldn't find Tidal URL: %w", err)
|
return "", fmt.Errorf("songlink/songstats couldn't find Tidal URL: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return t.DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL, allowFallback, useFirstArtistOnly, useSingleGenre, embedGenre)
|
if t.apiURL == "" {
|
||||||
|
return "", fmt.Errorf("no configured custom tidal api instance")
|
||||||
|
}
|
||||||
|
return t.DownloadByURL(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL, allowFallback, useFirstArtistOnly, useSingleGenre, embedGenre)
|
||||||
}
|
}
|
||||||
|
|
||||||
type SegmentTemplate struct {
|
type SegmentTemplate struct {
|
||||||
@@ -812,10 +697,12 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
|
|||||||
|
|
||||||
var mpd MPD
|
var mpd MPD
|
||||||
var segTemplate *SegmentTemplate
|
var segTemplate *SegmentTemplate
|
||||||
|
var dashMimeType string
|
||||||
|
|
||||||
if err := xml.Unmarshal(manifestBytes, &mpd); err == nil {
|
if err := xml.Unmarshal(manifestBytes, &mpd); err == nil {
|
||||||
var selectedBandwidth int
|
var selectedBandwidth int
|
||||||
var selectedCodecs string
|
var selectedCodecs string
|
||||||
|
var selectedMimeType string
|
||||||
|
|
||||||
for _, as := range mpd.Period.AdaptationSets {
|
for _, as := range mpd.Period.AdaptationSets {
|
||||||
|
|
||||||
@@ -824,6 +711,7 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
|
|||||||
if segTemplate == nil {
|
if segTemplate == nil {
|
||||||
segTemplate = as.SegmentTemplate
|
segTemplate = as.SegmentTemplate
|
||||||
selectedCodecs = as.Codecs
|
selectedCodecs = as.Codecs
|
||||||
|
selectedMimeType = as.MimeType
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -838,6 +726,8 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
|
|||||||
} else {
|
} else {
|
||||||
selectedCodecs = as.Codecs
|
selectedCodecs = as.Codecs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
selectedMimeType = as.MimeType
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -845,6 +735,7 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
|
|||||||
|
|
||||||
if selectedBandwidth > 0 {
|
if selectedBandwidth > 0 {
|
||||||
fmt.Printf("Selected stream: Codec=%s, Bandwidth=%d bps\n", selectedCodecs, selectedBandwidth)
|
fmt.Printf("Selected stream: Codec=%s, Bandwidth=%d bps\n", selectedCodecs, selectedBandwidth)
|
||||||
|
dashMimeType = fmt.Sprintf("%s; codecs=\"%s\"", selectedMimeType, selectedCodecs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -870,7 +761,7 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
|
|||||||
mediaURL := strings.ReplaceAll(mediaTemplate, "$Number$", fmt.Sprintf("%d", i))
|
mediaURL := strings.ReplaceAll(mediaTemplate, "$Number$", fmt.Sprintf("%d", i))
|
||||||
mediaURLs = append(mediaURLs, mediaURL)
|
mediaURLs = append(mediaURLs, mediaURL)
|
||||||
}
|
}
|
||||||
return "", initURL, mediaURLs, "", nil
|
return "", initURL, mediaURLs, dashMimeType, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("Using regex fallback for DASH manifest...")
|
fmt.Println("Using regex fallback for DASH manifest...")
|
||||||
@@ -917,82 +808,91 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
|
|||||||
mediaURLs = append(mediaURLs, mediaURL)
|
mediaURLs = append(mediaURLs, mediaURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
return "", initURL, mediaURLs, "", nil
|
return "", initURL, mediaURLs, dashMimeType, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func getDownloadURLRotated(apis []string, trackID int64, quality string) (string, string, error) {
|
func (t *TidalDownloader) downloadWithRotatingAPIs(trackID int64, outputFilename string, quality string, allowFallback bool) (string, error) {
|
||||||
|
qualities := []string{quality}
|
||||||
|
if isTidalHiResQuality(quality) && allowFallback {
|
||||||
|
qualities = append(qualities, "LOSSLESS")
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastErr error
|
||||||
|
for idx, candidateQuality := range qualities {
|
||||||
|
if idx > 0 {
|
||||||
|
fmt.Printf("⚠ %s unavailable/failed on all APIs, falling back to %s...\n", quality, candidateQuality)
|
||||||
|
}
|
||||||
|
|
||||||
|
apiURL, err := t.tryDownloadAcrossTidalAPIs(trackID, outputFilename, candidateQuality, false)
|
||||||
|
if err == nil {
|
||||||
|
return apiURL, nil
|
||||||
|
}
|
||||||
|
lastErr = err
|
||||||
|
}
|
||||||
|
|
||||||
|
if lastErr == nil {
|
||||||
|
lastErr = fmt.Errorf("no tidal api succeeded")
|
||||||
|
}
|
||||||
|
return "", lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *TidalDownloader) tryDownloadAcrossTidalAPIs(trackID int64, outputFilename string, quality string, refreshed bool) (string, error) {
|
||||||
|
apis, err := getConfiguredTidalAPIAttemptList()
|
||||||
|
if err != nil && len(apis) == 0 {
|
||||||
|
return "", fmt.Errorf("failed to load tidal api list: %w", err)
|
||||||
|
}
|
||||||
if len(apis) == 0 {
|
if len(apis) == 0 {
|
||||||
return "", "", fmt.Errorf("no APIs available")
|
return "", fmt.Errorf("no tidal apis available")
|
||||||
}
|
}
|
||||||
|
|
||||||
orderedAPIs := prioritizeProviders("tidal", apis)
|
var lastErr error
|
||||||
fmt.Printf("Trying %d prioritized APIs...\n", len(orderedAPIs))
|
errors := make([]string, 0, len(apis))
|
||||||
|
|
||||||
var lastError error
|
for _, apiURL := range apis {
|
||||||
var errors []string
|
fmt.Printf("Trying Tidal API: %s\n", apiURL)
|
||||||
|
|
||||||
for _, apiURL := range orderedAPIs {
|
downloader := NewTidalDownloader(apiURL)
|
||||||
fmt.Printf("Trying API: %s\n", apiURL)
|
downloadURL, err := downloader.GetDownloadURL(trackID, quality)
|
||||||
|
|
||||||
client := &http.Client{
|
|
||||||
Timeout: 15 * time.Second,
|
|
||||||
}
|
|
||||||
|
|
||||||
url := fmt.Sprintf("%s/track/?id=%d&quality=%s", apiURL, trackID, quality)
|
|
||||||
resp, err := client.Get(url)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
lastError = err
|
lastErr = err
|
||||||
recordProviderFailure("tidal", apiURL)
|
|
||||||
errors = append(errors, fmt.Sprintf("%s: %v", apiURL, err))
|
errors = append(errors, fmt.Sprintf("%s: %v", apiURL, err))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
if err := downloader.DownloadFile(downloadURL, outputFilename, quality); err != nil {
|
||||||
resp.Body.Close()
|
lastErr = err
|
||||||
lastError = fmt.Errorf("HTTP %d", resp.StatusCode)
|
cleanupTidalDownloadArtifacts(outputFilename)
|
||||||
recordProviderFailure("tidal", apiURL)
|
errors = append(errors, fmt.Sprintf("%s: %v", apiURL, err))
|
||||||
errors = append(errors, fmt.Sprintf("%s: %v", apiURL, lastError))
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
return apiURL, nil
|
||||||
resp.Body.Close()
|
|
||||||
if err != nil {
|
|
||||||
lastError = err
|
|
||||||
recordProviderFailure("tidal", apiURL)
|
|
||||||
errors = append(errors, fmt.Sprintf("%s: read body failed", apiURL))
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var v2Response TidalAPIResponseV2
|
if lastErr == nil {
|
||||||
if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" {
|
lastErr = fmt.Errorf("all tidal apis failed")
|
||||||
fmt.Printf("✓ Success with: %s\n", apiURL)
|
|
||||||
recordProviderSuccess("tidal", apiURL)
|
|
||||||
return apiURL, "MANIFEST:" + v2Response.Data.Manifest, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var v1Responses []TidalAPIResponse
|
fmt.Println("All Tidal APIs failed:")
|
||||||
if err := json.Unmarshal(body, &v1Responses); err == nil {
|
for _, item := range errors {
|
||||||
for _, item := range v1Responses {
|
fmt.Printf(" ✗ %s\n", item)
|
||||||
if item.OriginalTrackURL != "" {
|
|
||||||
fmt.Printf("✓ Success with: %s\n", apiURL)
|
|
||||||
recordProviderSuccess("tidal", apiURL)
|
|
||||||
return apiURL, item.OriginalTrackURL, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
lastError = fmt.Errorf("no download URL or manifest in response")
|
return "", fmt.Errorf("all tidal apis failed for quality %s: %w", quality, lastErr)
|
||||||
recordProviderFailure("tidal", apiURL)
|
}
|
||||||
errors = append(errors, fmt.Sprintf("%s: %v", apiURL, lastError))
|
|
||||||
|
func cleanupTidalDownloadArtifacts(outputPath string) {
|
||||||
|
if outputPath == "" {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("All APIs failed:")
|
_ = os.Remove(outputPath)
|
||||||
for _, e := range errors {
|
_ = os.Remove(outputPath + ".m4a.tmp")
|
||||||
fmt.Printf(" ✗ %s\n", e)
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return "", "", fmt.Errorf("all %d APIs failed. Last error: %v", len(apis), lastError)
|
func isTidalHiResQuality(quality string) bool {
|
||||||
|
normalized := strings.TrimSpace(strings.ToUpper(quality))
|
||||||
|
return normalized == "HI_RES" || normalized == "HI_RES_LOSSLESS"
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildTidalFilename(title, artist, album, albumArtist, releaseDate string, trackNumber, discNumber int, format string, includeTrackNumber bool, position int, useAlbumTrackNumber bool, extra ...string) string {
|
func buildTidalFilename(title, artist, album, albumArtist, releaseDate string, trackNumber, discNumber int, format string, includeTrackNumber bool, position int, useAlbumTrackNumber bool, extra ...string) string {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
"@radix-ui/react-progress": "^1.1.8",
|
"@radix-ui/react-progress": "^1.1.8",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
|
"@radix-ui/react-slider": "^1.3.6",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-switch": "^1.2.6",
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
867c45db7982e126a7249d80210f23be
|
8864b4f7b7971b624d1ba25030f2db4e
|
||||||
Generated
+3
@@ -32,6 +32,9 @@ importers:
|
|||||||
'@radix-ui/react-select':
|
'@radix-ui/react-select':
|
||||||
specifier: ^2.2.6
|
specifier: ^2.2.6
|
||||||
version: 2.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
version: 2.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
|
'@radix-ui/react-slider':
|
||||||
|
specifier: ^1.3.6
|
||||||
|
version: 1.3.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
'@radix-ui/react-slot':
|
'@radix-ui/react-slot':
|
||||||
specifier: ^1.2.4
|
specifier: ^1.2.4
|
||||||
version: 1.2.4(@types/react@19.2.14)(react@19.2.4)
|
version: 1.2.4(@types/react@19.2.14)(react@19.2.4)
|
||||||
|
|||||||
+40
-21
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useCallback, useLayoutEffect } from "react";
|
import { useState, useEffect, useCallback, useLayoutEffect, useRef } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
|
||||||
import { Search, X, ArrowUp } from "lucide-react";
|
import { Search, X, ArrowUp } from "lucide-react";
|
||||||
@@ -24,8 +24,9 @@ import { AudioResamplerPage } from "@/components/AudioResamplerPage";
|
|||||||
import { FileManagerPage } from "@/components/FileManagerPage";
|
import { FileManagerPage } from "@/components/FileManagerPage";
|
||||||
import { SettingsPage } from "@/components/SettingsPage";
|
import { SettingsPage } from "@/components/SettingsPage";
|
||||||
import { DebugLoggerPage } from "@/components/DebugLoggerPage";
|
import { DebugLoggerPage } from "@/components/DebugLoggerPage";
|
||||||
import { AboutPage } from "@/components/AboutPage";
|
import { OtherProjects } from "@/components/OtherProjects";
|
||||||
import { HistoryPage } from "@/components/HistoryPage";
|
import { HistoryPage } from "@/components/HistoryPage";
|
||||||
|
import { SupportPage } from "@/components/SupportPage";
|
||||||
import type { HistoryItem } from "@/components/FetchHistory";
|
import type { HistoryItem } from "@/components/FetchHistory";
|
||||||
import { useDownload } from "@/hooks/useDownload";
|
import { useDownload } from "@/hooks/useDownload";
|
||||||
import { useMetadata } from "@/hooks/useMetadata";
|
import { useMetadata } from "@/hooks/useMetadata";
|
||||||
@@ -125,6 +126,7 @@ function parseStoredHistory(value: string | null): HistoryItem[] {
|
|||||||
}
|
}
|
||||||
function App() {
|
function App() {
|
||||||
const [currentPage, setCurrentPage] = useState<PageType>("main");
|
const [currentPage, setCurrentPage] = useState<PageType>("main");
|
||||||
|
const contentScrollRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [spotifyUrl, setSpotifyUrl] = useState("");
|
const [spotifyUrl, setSpotifyUrl] = useState("");
|
||||||
const [selectedTracks, setSelectedTracks] = useState<string[]>([]);
|
const [selectedTracks, setSelectedTracks] = useState<string[]>([]);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
@@ -161,7 +163,7 @@ function App() {
|
|||||||
if (savedSettings) {
|
if (savedSettings) {
|
||||||
applyThemeMode(savedSettings.themeMode);
|
applyThemeMode(savedSettings.themeMode);
|
||||||
applyTheme(savedSettings.theme);
|
applyTheme(savedSettings.theme);
|
||||||
applyFont(savedSettings.fontFamily);
|
applyFont(savedSettings.fontFamily, savedSettings.customFonts);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -169,7 +171,7 @@ function App() {
|
|||||||
const settings = await loadSettings();
|
const settings = await loadSettings();
|
||||||
applyThemeMode(settings.themeMode);
|
applyThemeMode(settings.themeMode);
|
||||||
applyTheme(settings.theme);
|
applyTheme(settings.theme);
|
||||||
applyFont(settings.fontFamily);
|
applyFont(settings.fontFamily, settings.customFonts);
|
||||||
if (!settings.downloadPath) {
|
if (!settings.downloadPath) {
|
||||||
const settingsWithDefaults = await getSettingsWithDefaults();
|
const settingsWithDefaults = await getSettingsWithDefaults();
|
||||||
await saveSettings(settingsWithDefaults);
|
await saveSettings(settingsWithDefaults);
|
||||||
@@ -199,18 +201,31 @@ function App() {
|
|||||||
checkForUpdates();
|
checkForUpdates();
|
||||||
ensureApiStatusCheckStarted();
|
ensureApiStatusCheckStarted();
|
||||||
void loadHistory();
|
void loadHistory();
|
||||||
const handleScroll = () => {
|
|
||||||
setShowScrollTop(window.scrollY > 300);
|
|
||||||
};
|
|
||||||
window.addEventListener("scroll", handleScroll);
|
|
||||||
return () => {
|
return () => {
|
||||||
mediaQuery.removeEventListener("change", handleChange);
|
mediaQuery.removeEventListener("change", handleChange);
|
||||||
window.removeEventListener("scroll", handleScroll);
|
};
|
||||||
|
}, []);
|
||||||
|
useEffect(() => {
|
||||||
|
const contentElement = contentScrollRef.current;
|
||||||
|
if (!contentElement) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const handleScroll = () => {
|
||||||
|
setShowScrollTop(contentElement.scrollTop > 300);
|
||||||
|
};
|
||||||
|
handleScroll();
|
||||||
|
contentElement.addEventListener("scroll", handleScroll, { passive: true });
|
||||||
|
return () => {
|
||||||
|
contentElement.removeEventListener("scroll", handleScroll);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
const scrollToTop = useCallback(() => {
|
const scrollToTop = useCallback(() => {
|
||||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
contentScrollRef.current?.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
}, []);
|
}, []);
|
||||||
|
useEffect(() => {
|
||||||
|
contentScrollRef.current?.scrollTo({ top: 0, behavior: "auto" });
|
||||||
|
setShowScrollTop(false);
|
||||||
|
}, [currentPage]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedTracks([]);
|
setSelectedTracks([]);
|
||||||
setSearchQuery("");
|
setSearchQuery("");
|
||||||
@@ -432,7 +447,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
if ("album_info" in metadata.metadata) {
|
if ("album_info" in metadata.metadata) {
|
||||||
const { album_info, track_list } = metadata.metadata;
|
const { album_info, track_list } = metadata.metadata;
|
||||||
return (<AlbumInfo albumInfo={album_info} trackList={track_list} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} failedTracks={download.failedTracks} skippedTracks={download.skippedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} downloadProgress={download.downloadProgress} currentDownloadInfo={download.currentDownloadInfo} currentPage={currentListPage} itemsPerPage={ITEMS_PER_PAGE} downloadedLyrics={lyrics.downloadedLyrics} failedLyrics={lyrics.failedLyrics} skippedLyrics={lyrics.skippedLyrics} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} checkingAvailabilityTrack={availability.checkingTrackId} availabilityMap={availability.availabilityMap} downloadedCovers={cover.downloadedCovers} failedCovers={cover.failedCovers} skippedCovers={cover.skippedCovers} downloadingCoverTrack={cover.downloadingCoverTrack} isBulkDownloadingCovers={cover.isBulkDownloadingCovers} isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics} isMetadataLoading={metadata.loading} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} onToggleSelectAll={toggleSelectAll} onDownloadTrack={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, album_info.name, position, albumArtist, releaseDate, discNumber, true)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, album_info.name, position, trackId, albumArtist, releaseDate, discNumber, true)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, album_info.name, undefined, true)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, album_info.name, true)} onDownloadAll={() => download.handleDownloadAll(track_list, album_info.name, true)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, album_info.name, true)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onPageChange={setCurrentListPage} onBack={metadata.resetMetadata} onArtistClick={async (artist) => {
|
return (<AlbumInfo albumInfo={album_info} trackList={track_list} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} failedTracks={download.failedTracks} skippedTracks={download.skippedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} downloadProgress={download.downloadProgress} downloadRemainingCount={download.downloadRemainingCount} currentDownloadInfo={download.currentDownloadInfo} currentPage={currentListPage} itemsPerPage={ITEMS_PER_PAGE} downloadedLyrics={lyrics.downloadedLyrics} failedLyrics={lyrics.failedLyrics} skippedLyrics={lyrics.skippedLyrics} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} checkingAvailabilityTrack={availability.checkingTrackId} availabilityMap={availability.availabilityMap} downloadedCovers={cover.downloadedCovers} failedCovers={cover.failedCovers} skippedCovers={cover.skippedCovers} downloadingCoverTrack={cover.downloadingCoverTrack} isBulkDownloadingCovers={cover.isBulkDownloadingCovers} isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics} isMetadataLoading={metadata.loading} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} onToggleSelectAll={toggleSelectAll} onDownloadTrack={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, album_info.name, position, albumArtist, releaseDate, discNumber, true)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, album_info.name, position, trackId, albumArtist, releaseDate, discNumber, true)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, album_info.name, undefined, true)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, album_info.name, true)} onDownloadAll={() => download.handleDownloadAll(track_list, album_info.name, true)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, album_info.name, true)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onPageChange={setCurrentListPage} onBack={metadata.resetMetadata} onArtistClick={async (artist) => {
|
||||||
const pendingArtistUrl = artist.external_urls.replace(/\/$/, "") + "/discography/all";
|
const pendingArtistUrl = artist.external_urls.replace(/\/$/, "") + "/discography/all";
|
||||||
setSpotifyUrl(pendingArtistUrl);
|
setSpotifyUrl(pendingArtistUrl);
|
||||||
const artistUrl = await metadata.handleArtistClick(artist);
|
const artistUrl = await metadata.handleArtistClick(artist);
|
||||||
@@ -450,7 +465,7 @@ function App() {
|
|||||||
const { playlist_info, track_list } = metadata.metadata;
|
const { playlist_info, track_list } = metadata.metadata;
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
const playlistFolderName = buildPlaylistFolderName(playlist_info.owner.name, playlist_info.owner.display_name, settings.playlistOwnerFolderName);
|
const playlistFolderName = buildPlaylistFolderName(playlist_info.owner.name, playlist_info.owner.display_name, settings.playlistOwnerFolderName);
|
||||||
return (<PlaylistInfo playlistInfo={playlist_info} trackList={track_list} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} failedTracks={download.failedTracks} skippedTracks={download.skippedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} downloadProgress={download.downloadProgress} currentDownloadInfo={download.currentDownloadInfo} currentPage={currentListPage} itemsPerPage={ITEMS_PER_PAGE} downloadedLyrics={lyrics.downloadedLyrics} failedLyrics={lyrics.failedLyrics} skippedLyrics={lyrics.skippedLyrics} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} checkingAvailabilityTrack={availability.checkingTrackId} availabilityMap={availability.availabilityMap} downloadedCovers={cover.downloadedCovers} failedCovers={cover.failedCovers} skippedCovers={cover.skippedCovers} downloadingCoverTrack={cover.downloadingCoverTrack} isBulkDownloadingCovers={cover.isBulkDownloadingCovers} isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics} isMetadataLoading={metadata.loading} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} onToggleSelectAll={toggleSelectAll} onDownloadTrack={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, playlistFolderName, position, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, playlistFolderName, position, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, playlistFolderName)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, playlistFolderName)} onDownloadAll={() => download.handleDownloadAll(track_list, playlistFolderName)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, playlistFolderName)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onPageChange={setCurrentListPage} onBack={metadata.resetMetadata} onAlbumClick={metadata.handleAlbumClick} onArtistClick={async (artist) => {
|
return (<PlaylistInfo playlistInfo={playlist_info} trackList={track_list} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} failedTracks={download.failedTracks} skippedTracks={download.skippedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} downloadProgress={download.downloadProgress} downloadRemainingCount={download.downloadRemainingCount} currentDownloadInfo={download.currentDownloadInfo} currentPage={currentListPage} itemsPerPage={ITEMS_PER_PAGE} downloadedLyrics={lyrics.downloadedLyrics} failedLyrics={lyrics.failedLyrics} skippedLyrics={lyrics.skippedLyrics} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} checkingAvailabilityTrack={availability.checkingTrackId} availabilityMap={availability.availabilityMap} downloadedCovers={cover.downloadedCovers} failedCovers={cover.failedCovers} skippedCovers={cover.skippedCovers} downloadingCoverTrack={cover.downloadingCoverTrack} isBulkDownloadingCovers={cover.isBulkDownloadingCovers} isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics} isMetadataLoading={metadata.loading} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} onToggleSelectAll={toggleSelectAll} onDownloadTrack={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, playlistFolderName, position, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, playlistFolderName, position, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, playlistFolderName)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, playlistFolderName)} onDownloadAll={() => download.handleDownloadAll(track_list, playlistFolderName)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, playlistFolderName)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onPageChange={setCurrentListPage} onBack={metadata.resetMetadata} onAlbumClick={metadata.handleAlbumClick} onArtistClick={async (artist) => {
|
||||||
const pendingArtistUrl = artist.external_urls.replace(/\/$/, "") + "/discography/all";
|
const pendingArtistUrl = artist.external_urls.replace(/\/$/, "") + "/discography/all";
|
||||||
setSpotifyUrl(pendingArtistUrl);
|
setSpotifyUrl(pendingArtistUrl);
|
||||||
const artistUrl = await metadata.handleArtistClick(artist);
|
const artistUrl = await metadata.handleArtistClick(artist);
|
||||||
@@ -466,7 +481,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
if ("artist_info" in metadata.metadata) {
|
if ("artist_info" in metadata.metadata) {
|
||||||
const { artist_info, album_list, track_list } = metadata.metadata;
|
const { artist_info, album_list, track_list } = metadata.metadata;
|
||||||
return (<ArtistInfo artistInfo={artist_info} albumList={album_list} trackList={track_list} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} failedTracks={download.failedTracks} skippedTracks={download.skippedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} downloadProgress={download.downloadProgress} currentDownloadInfo={download.currentDownloadInfo} currentPage={currentListPage} itemsPerPage={ITEMS_PER_PAGE} downloadedLyrics={lyrics.downloadedLyrics} failedLyrics={lyrics.failedLyrics} skippedLyrics={lyrics.skippedLyrics} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} checkingAvailabilityTrack={availability.checkingTrackId} availabilityMap={availability.availabilityMap} downloadedCovers={cover.downloadedCovers} failedCovers={cover.failedCovers} skippedCovers={cover.skippedCovers} downloadingCoverTrack={cover.downloadingCoverTrack} isBulkDownloadingCovers={cover.isBulkDownloadingCovers} isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics} isMetadataLoading={metadata.loading} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} onToggleSelectAll={toggleSelectAll} onDownloadTrack={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, artist_info.name, position, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, artist_info.name, position, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, artist_info.name)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, artist_info.name)} onDownloadAll={() => download.handleDownloadAll(track_list, artist_info.name)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, artist_info.name)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onAlbumClick={metadata.handleAlbumClick} onBack={metadata.resetMetadata} onArtistClick={async (artist) => {
|
return (<ArtistInfo artistInfo={artist_info} albumList={album_list} trackList={track_list} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} failedTracks={download.failedTracks} skippedTracks={download.skippedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} downloadProgress={download.downloadProgress} downloadRemainingCount={download.downloadRemainingCount} currentDownloadInfo={download.currentDownloadInfo} currentPage={currentListPage} itemsPerPage={ITEMS_PER_PAGE} downloadedLyrics={lyrics.downloadedLyrics} failedLyrics={lyrics.failedLyrics} skippedLyrics={lyrics.skippedLyrics} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} checkingAvailabilityTrack={availability.checkingTrackId} availabilityMap={availability.availabilityMap} downloadedCovers={cover.downloadedCovers} failedCovers={cover.failedCovers} skippedCovers={cover.skippedCovers} downloadingCoverTrack={cover.downloadingCoverTrack} isBulkDownloadingCovers={cover.isBulkDownloadingCovers} isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics} isMetadataLoading={metadata.loading} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} onToggleSelectAll={toggleSelectAll} onDownloadTrack={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, artist_info.name, position, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, artist_info.name, position, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, artist_info.name)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, artist_info.name)} onDownloadAll={() => download.handleDownloadAll(track_list, artist_info.name)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, artist_info.name)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onAlbumClick={metadata.handleAlbumClick} onBack={metadata.resetMetadata} onArtistClick={async (artist) => {
|
||||||
const pendingArtistUrl = artist.external_urls.replace(/\/$/, "") + "/discography/all";
|
const pendingArtistUrl = artist.external_urls.replace(/\/$/, "") + "/discography/all";
|
||||||
setSpotifyUrl(pendingArtistUrl);
|
setSpotifyUrl(pendingArtistUrl);
|
||||||
const artistUrl = await metadata.handleArtistClick(artist);
|
const artistUrl = await metadata.handleArtistClick(artist);
|
||||||
@@ -498,7 +513,7 @@ function App() {
|
|||||||
const savedSettings = getSettings();
|
const savedSettings = getSettings();
|
||||||
applyThemeMode(savedSettings.themeMode);
|
applyThemeMode(savedSettings.themeMode);
|
||||||
applyTheme(savedSettings.theme);
|
applyTheme(savedSettings.theme);
|
||||||
applyFont(savedSettings.fontFamily);
|
applyFont(savedSettings.fontFamily, savedSettings.customFonts);
|
||||||
if (pendingPageChange) {
|
if (pendingPageChange) {
|
||||||
setCurrentPage(pendingPageChange);
|
setCurrentPage(pendingPageChange);
|
||||||
setPendingPageChange(null);
|
setPendingPageChange(null);
|
||||||
@@ -514,8 +529,10 @@ function App() {
|
|||||||
return <SettingsPage onUnsavedChangesChange={setHasUnsavedSettings} onResetRequest={setResetSettingsFn}/>;
|
return <SettingsPage onUnsavedChangesChange={setHasUnsavedSettings} onResetRequest={setResetSettingsFn}/>;
|
||||||
case "debug":
|
case "debug":
|
||||||
return <DebugLoggerPage />;
|
return <DebugLoggerPage />;
|
||||||
case "about":
|
case "projects":
|
||||||
return <AboutPage />;
|
return <OtherProjects />;
|
||||||
|
case "support":
|
||||||
|
return <SupportPage />;
|
||||||
case "history":
|
case "history":
|
||||||
return <HistoryPage onHistorySelect={(cachedData) => {
|
return <HistoryPage onHistorySelect={(cachedData) => {
|
||||||
metadata.loadFromCache(cachedData);
|
metadata.loadFromCache(cachedData);
|
||||||
@@ -537,7 +554,7 @@ function App() {
|
|||||||
|
|
||||||
|
|
||||||
<Dialog open={metadata.showAlbumDialog} onOpenChange={metadata.setShowAlbumDialog}>
|
<Dialog open={metadata.showAlbumDialog} onOpenChange={metadata.setShowAlbumDialog}>
|
||||||
<DialogContent className="sm:max-w-[425px] p-6 [&>button]:hidden">
|
<DialogContent className="sm:max-w-106.25 p-6 [&>button]:hidden">
|
||||||
<div className="absolute right-4 top-4">
|
<div className="absolute right-4 top-4">
|
||||||
<Button variant="ghost" size="icon" className="h-6 w-6 opacity-70 hover:opacity-100" onClick={() => metadata.setShowAlbumDialog(false)}>
|
<Button variant="ghost" size="icon" className="h-6 w-6 opacity-70 hover:opacity-100" onClick={() => metadata.setShowAlbumDialog(false)}>
|
||||||
<X className="h-4 w-4"/>
|
<X className="h-4 w-4"/>
|
||||||
@@ -584,16 +601,18 @@ function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
return (<TooltipProvider>
|
return (<TooltipProvider>
|
||||||
<div className="min-h-screen bg-background flex flex-col">
|
<div className="h-screen overflow-hidden bg-background">
|
||||||
<TitleBar />
|
<TitleBar />
|
||||||
<Sidebar currentPage={currentPage} onPageChange={handlePageChange}/>
|
<Sidebar currentPage={currentPage} onPageChange={handlePageChange}/>
|
||||||
|
|
||||||
|
|
||||||
<div className="flex-1 ml-14 mt-10 p-4 md:p-8">
|
<div ref={contentScrollRef} className="fixed top-10 right-0 bottom-0 left-14 overflow-y-auto overflow-x-hidden">
|
||||||
|
<div className="p-4 md:p-8">
|
||||||
<div className="max-w-4xl mx-auto space-y-6">
|
<div className="max-w-4xl mx-auto space-y-6">
|
||||||
{renderPage()}
|
{renderPage()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<DownloadProgressToast onClick={downloadQueue.openQueue}/>
|
<DownloadProgressToast onClick={downloadQueue.openQueue}/>
|
||||||
@@ -608,7 +627,7 @@ function App() {
|
|||||||
|
|
||||||
|
|
||||||
<Dialog open={showUnsavedChangesDialog} onOpenChange={setShowUnsavedChangesDialog}>
|
<Dialog open={showUnsavedChangesDialog} onOpenChange={setShowUnsavedChangesDialog}>
|
||||||
<DialogContent className="sm:max-w-[425px] [&>button]:hidden">
|
<DialogContent className="sm:max-w-106.25 [&>button]:hidden">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Unsaved Changes</DialogTitle>
|
<DialogTitle>Unsaved Changes</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
@@ -655,7 +674,7 @@ function App() {
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<Dialog open={isFFmpegInstalled === false} onOpenChange={() => { }}>
|
<Dialog open={isFFmpegInstalled === false} onOpenChange={() => { }}>
|
||||||
<DialogContent className="max-w-[450px] [&>button]:hidden p-6 gap-5">
|
<DialogContent className="max-w-112.5 [&>button]:hidden p-6 gap-5">
|
||||||
<DialogHeader className="space-y-2">
|
<DialogHeader className="space-y-2">
|
||||||
<DialogTitle className="text-lg font-bold tracking-tight">
|
<DialogTitle className="text-lg font-bold tracking-tight">
|
||||||
FFmpeg Required
|
FFmpeg Required
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 9.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
@@ -1,23 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:sketch="http://www.bohemiancoding.com/sketch/ns" viewBox="0 0 512 512">
|
|
||||||
<defs>
|
|
||||||
<style>
|
|
||||||
.cls-1 {
|
|
||||||
fill: #2dc261;
|
|
||||||
fill-rule: evenodd;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</defs>
|
|
||||||
<!-- Generator: Adobe Illustrator 28.7.2, SVG Export Plug-In . SVG Version: 1.2.0 Build 154) -->
|
|
||||||
<g>
|
|
||||||
<g id="Layer_1">
|
|
||||||
<g id="SVGRepo_iconCarrier">
|
|
||||||
<g id="Page-1" sketch:type="MSPage">
|
|
||||||
<g id="Icon-Set-Filled" sketch:type="MSLayerGroup">
|
|
||||||
<path id="arrow-right-circle" class="cls-1" d="M350.1,268.7l-81.5,81.5c-5.6,5.6-14.7,5.6-20.4,0-5.6-5.6-5.6-14.8,0-20.5l59.4-59.3h-152.5c-8,0-14.4-6.5-14.4-14.4s6.4-14.4,14.4-14.4h152.5l-59.4-59.3c-5.6-5.6-5.6-14.7,0-20.5,5.6-5.6,14.7-5.6,20.4,0l81.5,81.5c3.5,3.5,4.5,8.2,3.7,12.7.8,4.5-.3,9.2-3.7,12.7h0ZM256,25.6c-127.3,0-230.4,103.1-230.4,230.4s103.2,230.4,230.4,230.4,230.4-103.1,230.4-230.4S383.3,25.6,256,25.6h0Z" sketch:type="MSShapeGroup"/>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 749.3 227.1" fill="#FFFFFF">
|
||||||
|
<!-- Generator: Adobe Illustrator 29.3.1, SVG Export Plug-In . SVG Version: 2.1.0 Build 151) -->
|
||||||
|
<path d="M222.8,85.4c0-3.4,2.5-5.6,6.4-5.6h18.6c16.9,0,28.3,9.3,28.3,22.9s-11.3,23.3-28.3,23.3h-2.6c-6.5,0-9.8,3.4-9.8,8.8v15.2c0,4.3-2.5,7-6.3,7s-6.3-2.7-6.3-7v-64.6ZM235.4,104.7c0,6.8,3.5,10.1,10.1,10.1h1.6c9.3,0,16.1-3.8,16.1-12.1s-6.8-12.1-16.1-12.1h-1.6c-6.6,0-10.1,3.2-10.1,10.1v4.1ZM276.1,151.1c0,3.6,2.5,5.9,6.3,5.9s4.8-1.6,6.1-5l2.3-6.1c1.8-4.9,5.1-7.1,8.6-7.1h20.5c3.6,0,6.8,2.3,8.6,7.1l2.3,6.1c1.3,3.4,3.6,5,6.1,5,3.8,0,6.3-2.4,6.3-5.9s-.2-2.2-.6-3.4l-24.5-63.8c-1.5-3.9-5-5.8-8.3-5.8s-6.8,1.9-8.3,5.8l-24.5,63.8c-.4,1.2-.6,2.4-.6,3.4ZM300,122.1c0-1.2.3-2.3.9-3.9l4.6-12.9c.9-2.5,2.4-3.7,4.1-3.7s3.2,1.2,4.1,3.7l4.6,12.9c.5,1.6.9,2.7.9,3.9,0,3.2-1.8,5.5-6.7,5.5h-5.8c-4.9,0-6.7-2.3-6.7-5.5ZM339,85.6c0-3.5,2.5-5.8,6.5-5.8h49.7c4,0,6.5,2.4,6.5,5.8s-2.5,5.8-6.5,5.8h-8.3c-6.6,0-10.2,3.4-10.2,11v47.4c0,4.4-2.5,7.1-6.4,7.1s-6.4-2.7-6.4-7.1v-47.4c0-7.7-3.6-11-10.2-11h-8.3c-4,0-6.5-2.4-6.5-5.8ZM413.4,150c0,4.3,2.5,7,6.3,7s6.3-2.7,6.3-7v-17.2c0-4.9,2.8-6.9,6.3-6.9h.9c2.3,0,4.5,1.4,5.9,3.5l16.4,24.1c1.5,2.3,3.5,3.6,5.9,3.6s5.8-2.7,5.8-5.9-.4-2.7-1.4-4.1l-10.9-15.3c-1.3-1.8-1.8-3.4-1.8-4.6,0-2.7,2.4-4.6,5.2-6.7,5.1-3.8,10.6-8.8,10.6-18.3s-10.4-22.3-27.5-22.3h-21.7c-3.9,0-6.3,2.3-6.3,5.6v64.6ZM425.9,103.7v-3.2c0-7,3.7-9.9,9.3-9.9h5.4c9.3,0,15.2,3.5,15.2,11.5s-6.3,11.7-15.6,11.7h-5.1c-5.6,0-9.3-2.9-9.3-9.9ZM484.8,149.8v-64.4c0-3.4,2.4-5.6,6.3-5.6h40.9c3.9,0,6.3,2.3,6.3,5.6s-2.4,5.6-6.3,5.6h-25.8c-5.1,0-8.8,3-8.8,8.8v2.4c0,5.7,3.7,8.8,8.8,8.8h20c3.9,0,6.3,2.3,6.3,5.6s-2.4,5.6-6.3,5.6h-19.2c-5.1,0-9.5,3.1-9.5,9.5v3c0,6.4,4.4,9.5,9.5,9.5h25.1c3.9,0,6.3,2.3,6.3,5.6s-2.4,5.6-6.3,5.6h-40.9c-3.9,0-6.3-2.3-6.3-5.6ZM545.7,117.6c0-23.3,17.5-39.4,38-39.4s38,16.1,38,39.4-17.5,39.4-38,39.4-38-16.1-38-39.4ZM559.9,117.6c0,16.4,9.7,26.9,23.8,26.9s23.8-10.5,23.8-26.9-9.7-26.9-23.8-26.9-23.8,10.4-23.8,26.9ZM636.8,150c0,4.3,2.5,7,6.3,7s6.3-2.7,6.3-7v-33.1c0-4,2.4-5.9,4.9-5.9s3.6,1.1,4.8,3l20.8,34.7c2.8,4.8,5.4,8.3,10.7,8.3s8.8-3.7,8.8-9.6v-62.2c0-4.3-2.5-7-6.3-7s-6.3,2.7-6.3,7v33.1c0,4-2.4,5.9-4.9,5.9s-3.6-1.1-4.8-3l-20.8-34.7c-2.8-4.8-5.4-8.3-10.7-8.3s-8.8,3.7-8.8,9.6v62.2Z"/>
|
||||||
|
<path d="M169.2,87.5c0-16.7-13-30.3-28.2-35.2-18.9-6.1-43.8-5.2-61.9,3.3-21.9,10.3-28.7,32.9-29,55.5-.2,18.5,1.6,67.4,29.2,67.7,20.5.3,23.5-26.1,33-38.8,6.7-9,15.4-11.6,26.1-14.2,18.4-4.5,30.9-19,30.8-38.2Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Generator: Adobe Illustrator 27.4.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||||
|
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||||
|
viewBox="0 0 1080 1080" style="enable-background:new 0 0 1080 1080;" xml:space="preserve">
|
||||||
|
<style type="text/css">
|
||||||
|
.st0{fill:#FFFFFF;}
|
||||||
|
</style>
|
||||||
|
<path class="st0" d="M1033.05,324.45c-0.19-137.9-107.59-250.92-233.6-291.7c-156.48-50.64-362.86-43.3-512.28,27.2
|
||||||
|
C106.07,145.41,49.18,332.61,47.06,519.31c-1.74,153.5,13.58,557.79,241.62,560.67c169.44,2.15,194.67-216.18,273.07-321.33
|
||||||
|
c55.78-74.81,127.6-95.94,216.01-117.82C929.71,603.22,1033.27,483.3,1033.05,324.45z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 735 B |
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
@@ -35,6 +35,7 @@ interface AlbumInfoProps {
|
|||||||
isDownloading: boolean;
|
isDownloading: boolean;
|
||||||
bulkDownloadType: "all" | "selected" | null;
|
bulkDownloadType: "all" | "selected" | null;
|
||||||
downloadProgress: number;
|
downloadProgress: number;
|
||||||
|
downloadRemainingCount: number;
|
||||||
currentDownloadInfo: {
|
currentDownloadInfo: {
|
||||||
name: string;
|
name: string;
|
||||||
artists: string;
|
artists: string;
|
||||||
@@ -77,7 +78,7 @@ interface AlbumInfoProps {
|
|||||||
onTrackClick?: (track: TrackMetadata) => void;
|
onTrackClick?: (track: TrackMetadata) => void;
|
||||||
onBack?: () => void;
|
onBack?: () => void;
|
||||||
}
|
}
|
||||||
export function AlbumInfo({ albumInfo, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, isMetadataLoading = false, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onPageChange, onArtistClick, onTrackClick, onBack, }: AlbumInfoProps) {
|
export function AlbumInfo({ albumInfo, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, downloadRemainingCount, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, isMetadataLoading = false, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onPageChange, onArtistClick, onTrackClick, onBack, }: AlbumInfoProps) {
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
const albumArtistNames = splitArtistNames(albumInfo.artists);
|
const albumArtistNames = splitArtistNames(albumInfo.artists);
|
||||||
const artistSeparator = albumInfo.artists.includes(";") ? "; " : ", ";
|
const artistSeparator = albumInfo.artists.includes(";") ? "; " : ", ";
|
||||||
@@ -270,7 +271,7 @@ export function AlbumInfo({ albumInfo, trackList, searchQuery, sortBy, selectedT
|
|||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>)}
|
</Tooltip>)}
|
||||||
</div>
|
</div>
|
||||||
{isDownloading && (<DownloadProgress progress={downloadProgress} currentTrack={currentDownloadInfo} onStop={onStopDownload}/>)}
|
{isDownloading && (<DownloadProgress progress={downloadProgress} remainingCount={downloadRemainingCount} currentTrack={currentDownloadInfo} onStop={onStopDownload}/>)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -1,34 +1,85 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { RefreshCw, CheckCircle2, XCircle, Loader2 } from "lucide-react";
|
import { PlugZap, CheckCircle2, Loader2, Wrench } from "lucide-react";
|
||||||
import { TidalIcon, QobuzIcon, AmazonIcon, LrclibIcon, MusicBrainzIcon } from "./PlatformIcons";
|
import { TidalIcon, QobuzIcon, AmazonIcon, AppleMusicIcon, DeezerIcon } from "./PlatformIcons";
|
||||||
import { useApiStatus } from "@/hooks/useApiStatus";
|
import { useApiStatus } from "@/hooks/useApiStatus";
|
||||||
|
import { SPOTIFLAC_NEXT_SOURCES } from "@/lib/api-status";
|
||||||
|
function renderStatusIndicator(status: "checking" | "online" | "offline" | "idle") {
|
||||||
|
if (status === "online") {
|
||||||
|
return <CheckCircle2 className="h-5 w-5 text-emerald-500"/>;
|
||||||
|
}
|
||||||
|
if (status === "offline") {
|
||||||
|
return <Wrench className="h-4 w-4 text-amber-600 dark:text-amber-400"/>;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
function renderPlatformIcon(type: string) {
|
||||||
|
if (type === "tidal") {
|
||||||
|
return <TidalIcon className="w-5 h-5 shrink-0 text-muted-foreground"/>;
|
||||||
|
}
|
||||||
|
if (type === "amazon") {
|
||||||
|
return <AmazonIcon className="w-5 h-5 shrink-0 text-muted-foreground"/>;
|
||||||
|
}
|
||||||
|
if (type === "deezer") {
|
||||||
|
return <DeezerIcon className="w-5 h-5 shrink-0 text-muted-foreground"/>;
|
||||||
|
}
|
||||||
|
if (type === "apple") {
|
||||||
|
return <AppleMusicIcon className="w-5 h-5 shrink-0 text-muted-foreground"/>;
|
||||||
|
}
|
||||||
|
return <QobuzIcon className="w-5 h-5 shrink-0 text-muted-foreground"/>;
|
||||||
|
}
|
||||||
export function ApiStatusTab() {
|
export function ApiStatusTab() {
|
||||||
const { sources, statuses, isCheckingAll, refreshAll } = useApiStatus();
|
const { sources, statuses, nextStatuses, checkingSources, checkAllCurrent, checkAllNext } = useApiStatus();
|
||||||
|
const isCheckingCurrent = sources.some((source) => checkingSources[source.id] === true);
|
||||||
|
const isCheckingNext = SPOTIFLAC_NEXT_SOURCES.some((source) => nextStatuses[source.id] === "checking");
|
||||||
return (<div className="space-y-6">
|
return (<div className="space-y-6">
|
||||||
<div className="flex items-center justify-end">
|
<div className="space-y-4">
|
||||||
<Button variant="outline" onClick={() => void refreshAll()} disabled={isCheckingAll} className="gap-2">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<RefreshCw className={`h-4 w-4 ${isCheckingAll ? "animate-spin" : ""}`}/>
|
<h3 className="text-sm font-semibold tracking-tight">SpotiFLAC</h3>
|
||||||
Refresh All
|
<Button variant="outline" size="sm" onClick={() => void checkAllCurrent()} disabled={isCheckingCurrent} className="gap-2">
|
||||||
|
{isCheckingCurrent ? <Loader2 className="h-4 w-4 animate-spin"/> : <PlugZap className="h-4 w-4"/>}
|
||||||
|
Check
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
{sources.map((source) => {
|
{sources.map((source) => {
|
||||||
const status = statuses[source.id] || "idle";
|
const status = statuses[source.id] || "idle";
|
||||||
return (<div key={source.id} className="flex items-center justify-between p-4 border rounded-lg bg-card text-card-foreground shadow-sm">
|
return (<div key={source.id} className="space-y-4 p-4 border rounded-lg bg-card text-card-foreground shadow-sm">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{source.type === "tidal" ? <TidalIcon className="w-5 h-5 shrink-0 text-muted-foreground"/> : source.type === "amazon" ? <AmazonIcon className="w-5 h-5 shrink-0 text-muted-foreground"/> : source.type === "lrclib" ? <LrclibIcon className="w-5 h-5 shrink-0 text-muted-foreground"/> : source.type === "musicbrainz" ? <MusicBrainzIcon className="w-5 h-5 shrink-0 text-muted-foreground"/> : <QobuzIcon className="w-5 h-5 shrink-0 text-muted-foreground"/>}
|
{renderPlatformIcon(source.type)}
|
||||||
<p className="font-medium leading-none">{source.name}</p>
|
<p className="font-medium leading-none">{source.name}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center">{renderStatusIndicator(status)}</div>
|
||||||
<div className="flex items-center">
|
|
||||||
{status === "checking" && <Loader2 className="h-5 w-5 animate-spin text-muted-foreground"/>}
|
|
||||||
{status === "online" && <CheckCircle2 className="h-5 w-5 text-emerald-500"/>}
|
|
||||||
{status === "offline" && <XCircle className="h-5 w-5 text-destructive"/>}
|
|
||||||
{status === "idle" && <div className="h-5 w-5 rounded-full bg-muted"/>}
|
|
||||||
</div>
|
</div>
|
||||||
</div>);
|
</div>);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t"/>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<h3 className="text-sm font-semibold tracking-tight">SpotiFLAC Next</h3>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => void checkAllNext()} disabled={isCheckingNext} className="gap-2">
|
||||||
|
{isCheckingNext ? <Loader2 className="h-4 w-4 animate-spin"/> : <PlugZap className="h-4 w-4"/>}
|
||||||
|
Check
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5">
|
||||||
|
{SPOTIFLAC_NEXT_SOURCES.map((source) => {
|
||||||
|
const status = nextStatuses[source.id] || "idle";
|
||||||
|
return (<div key={source.id} className="flex items-center justify-between p-4 border rounded-lg bg-card text-card-foreground shadow-sm">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{renderPlatformIcon(source.id)}
|
||||||
|
<p className="font-medium leading-none">{source.name}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">{renderStatusIndicator(status)}</div>
|
||||||
|
</div>);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>);
|
</div>);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ interface ArtistInfoProps {
|
|||||||
isDownloading: boolean;
|
isDownloading: boolean;
|
||||||
bulkDownloadType: "all" | "selected" | null;
|
bulkDownloadType: "all" | "selected" | null;
|
||||||
downloadProgress: number;
|
downloadProgress: number;
|
||||||
|
downloadRemainingCount: number;
|
||||||
currentDownloadInfo: {
|
currentDownloadInfo: {
|
||||||
name: string;
|
name: string;
|
||||||
artists: string;
|
artists: string;
|
||||||
@@ -95,7 +96,7 @@ interface ArtistInfoProps {
|
|||||||
onTrackClick?: (track: TrackMetadata) => void;
|
onTrackClick?: (track: TrackMetadata) => void;
|
||||||
onBack?: () => void;
|
onBack?: () => void;
|
||||||
}
|
}
|
||||||
export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, isMetadataLoading = false, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onAlbumClick, onArtistClick, onPageChange, onTrackClick, onBack, }: ArtistInfoProps) {
|
export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, downloadRemainingCount, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, isMetadataLoading = false, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onAlbumClick, onArtistClick, onPageChange, onTrackClick, onBack, }: ArtistInfoProps) {
|
||||||
const [downloadingHeader, setDownloadingHeader] = useState(false);
|
const [downloadingHeader, setDownloadingHeader] = useState(false);
|
||||||
const [downloadingAvatar, setDownloadingAvatar] = useState(false);
|
const [downloadingAvatar, setDownloadingAvatar] = useState(false);
|
||||||
const [downloadingGalleryIndex, setDownloadingGalleryIndex] = useState<number | null>(null);
|
const [downloadingGalleryIndex, setDownloadingGalleryIndex] = useState<number | null>(null);
|
||||||
@@ -325,7 +326,7 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
|
|||||||
{artistInfo.header ? (<>
|
{artistInfo.header ? (<>
|
||||||
<div className="relative w-full h-64 bg-cover bg-center">
|
<div className="relative w-full h-64 bg-cover bg-center">
|
||||||
<div className="absolute inset-0 bg-cover bg-center" style={{ backgroundImage: `url(${artistInfo.header})` }}/>
|
<div className="absolute inset-0 bg-cover bg-center" style={{ backgroundImage: `url(${artistInfo.header})` }}/>
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-black via-black/50 to-transparent"/>
|
<div className="absolute inset-0 bg-linear-to-t from-black via-black/50 to-transparent"/>
|
||||||
{onBack && (<div className="absolute top-4 right-4 z-10">
|
{onBack && (<div className="absolute top-4 right-4 z-10">
|
||||||
<Button variant="ghost" size="icon" onClick={onBack} className="text-white hover:bg-white/20 hover:text-white">
|
<Button variant="ghost" size="icon" onClick={onBack} className="text-white hover:bg-white/20 hover:text-white">
|
||||||
<XCircle className="h-5 w-5"/>
|
<XCircle className="h-5 w-5"/>
|
||||||
@@ -563,7 +564,7 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
|
|||||||
Filter Albums
|
Filter Albums
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="sm:max-w-[500px] h-[80vh] flex flex-col">
|
<DialogContent className="sm:max-w-125 h-[80vh] flex flex-col">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Select Albums</DialogTitle>
|
<DialogTitle>Select Albums</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
@@ -634,7 +635,7 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
|
|||||||
</Tooltip>)}
|
</Tooltip>)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{isDownloading && (<DownloadProgress progress={downloadProgress} currentTrack={currentDownloadInfo} onStop={onStopDownload}/>)}
|
{isDownloading && (<DownloadProgress progress={downloadProgress} remainingCount={downloadRemainingCount} currentTrack={currentDownloadInfo} onStop={onStopDownload}/>)}
|
||||||
<SearchAndSort searchQuery={searchQuery} sortBy={sortBy} onSearchChange={onSearchChange} onSortChange={onSortChange}/>
|
<SearchAndSort searchQuery={searchQuery} sortBy={sortBy} onSearchChange={onSearchChange} onSortChange={onSortChange}/>
|
||||||
<TrackList tracks={trackList} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={downloadedTracks} failedTracks={failedTracks} skippedTracks={skippedTracks} downloadingTrack={downloadingTrack} isDownloading={isDownloading} currentPage={currentPage} itemsPerPage={itemsPerPage} showCheckboxes={true} hideAlbumColumn={false} folderName={artistInfo.name} isArtistDiscography={true} downloadedLyrics={downloadedLyrics} failedLyrics={failedLyrics} skippedLyrics={skippedLyrics} downloadingLyricsTrack={downloadingLyricsTrack} checkingAvailabilityTrack={checkingAvailabilityTrack} availabilityMap={availabilityMap} onToggleTrack={onToggleTrack} onToggleSelectAll={onToggleSelectAll} onDownloadTrack={onDownloadTrack} onDownloadLyrics={onDownloadLyrics} onDownloadCover={onDownloadCover} downloadedCovers={downloadedCovers} failedCovers={failedCovers} skippedCovers={skippedCovers} downloadingCoverTrack={downloadingCoverTrack} onCheckAvailability={onCheckAvailability} onPageChange={onPageChange} onAlbumClick={onAlbumClick} onArtistClick={onArtistClick} onTrackClick={onTrackClick}/>
|
<TrackList tracks={trackList} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={downloadedTracks} failedTracks={failedTracks} skippedTracks={skippedTracks} downloadingTrack={downloadingTrack} isDownloading={isDownloading} currentPage={currentPage} itemsPerPage={itemsPerPage} showCheckboxes={true} hideAlbumColumn={false} folderName={artistInfo.name} isArtistDiscography={true} downloadedLyrics={downloadedLyrics} failedLyrics={failedLyrics} skippedLyrics={skippedLyrics} downloadingLyricsTrack={downloadingLyricsTrack} checkingAvailabilityTrack={checkingAvailabilityTrack} availabilityMap={availabilityMap} onToggleTrack={onToggleTrack} onToggleSelectAll={onToggleSelectAll} onDownloadTrack={onDownloadTrack} onDownloadLyrics={onDownloadLyrics} onDownloadCover={onDownloadCover} downloadedCovers={downloadedCovers} failedCovers={failedCovers} skippedCovers={skippedCovers} downloadingCoverTrack={downloadingCoverTrack} onCheckAvailability={onCheckAvailability} onPageChange={onPageChange} onAlbumClick={onAlbumClick} onArtistClick={onArtistClick} onTrackClick={onTrackClick}/>
|
||||||
</div>)}
|
</div>)}
|
||||||
|
|||||||
@@ -3,14 +3,17 @@ import { Progress } from "@/components/ui/progress";
|
|||||||
import { StopCircle } from "lucide-react";
|
import { StopCircle } from "lucide-react";
|
||||||
interface DownloadProgressProps {
|
interface DownloadProgressProps {
|
||||||
progress: number;
|
progress: number;
|
||||||
|
remainingCount?: number;
|
||||||
currentTrack: {
|
currentTrack: {
|
||||||
name: string;
|
name: string;
|
||||||
artists: string;
|
artists: string;
|
||||||
} | null;
|
} | null;
|
||||||
onStop: () => void;
|
onStop: () => void;
|
||||||
}
|
}
|
||||||
export function DownloadProgress({ progress, currentTrack, onStop }: DownloadProgressProps) {
|
export function DownloadProgress({ progress, remainingCount = 0, currentTrack, onStop }: DownloadProgressProps) {
|
||||||
const clampedProgress = Math.min(100, Math.max(0, progress));
|
const clampedProgress = Math.min(100, Math.max(0, progress));
|
||||||
|
const safeRemainingCount = Math.max(0, remainingCount);
|
||||||
|
const remainingLabel = `${safeRemainingCount.toLocaleString()} ${safeRemainingCount === 1 ? "track" : "tracks"} left`;
|
||||||
return (<div className="w-full space-y-2 mt-4">
|
return (<div className="w-full space-y-2 mt-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Progress value={clampedProgress} className="h-2 flex-1"/>
|
<Progress value={clampedProgress} className="h-2 flex-1"/>
|
||||||
@@ -20,7 +23,7 @@ export function DownloadProgress({ progress, currentTrack, onStop }: DownloadPro
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{clampedProgress}% -{" "}
|
{clampedProgress}% • {remainingLabel} -{" "}
|
||||||
{currentTrack
|
{currentTrack
|
||||||
? `${currentTrack.name} - ${currentTrack.artists}`
|
? `${currentTrack.name} - ${currentTrack.artists}`
|
||||||
: "Preparing download..."}
|
: "Preparing download..."}
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, Pagi
|
|||||||
import { GetDownloadHistory, ClearDownloadHistory, GetPreviewURL, GetFetchHistory, DeleteDownloadHistoryItem, DeleteFetchHistoryItem, ClearFetchHistoryByType } from "../../wailsjs/go/main/App";
|
import { GetDownloadHistory, ClearDownloadHistory, GetPreviewURL, GetFetchHistory, DeleteDownloadHistoryItem, DeleteFetchHistoryItem, ClearFetchHistoryByType } from "../../wailsjs/go/main/App";
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { openExternal } from "@/lib/utils";
|
import { openExternal } from "@/lib/utils";
|
||||||
import { SPOTIFY_PREVIEW_VOLUME } from "@/lib/preview";
|
import { getPreviewVolume } from "@/lib/preview";
|
||||||
|
import { createPreviewPlayback, type PreviewPlayback } from "@/lib/preview-player";
|
||||||
import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons";
|
import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons";
|
||||||
const formatDate = (timestamp: number) => {
|
const formatDate = (timestamp: number) => {
|
||||||
const date = new Date(timestamp * 1000);
|
const date = new Date(timestamp * 1000);
|
||||||
@@ -21,6 +22,37 @@ const formatDate = (timestamp: number) => {
|
|||||||
const seconds = String(date.getSeconds()).padStart(2, '0');
|
const seconds = String(date.getSeconds()).padStart(2, '0');
|
||||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||||
};
|
};
|
||||||
|
const getHistoryFormatLabel = (item: DownloadHistoryItem) => {
|
||||||
|
const normalizedPath = (item.path || "").trim().toLowerCase();
|
||||||
|
if (normalizedPath.endsWith(".flac"))
|
||||||
|
return "FLAC";
|
||||||
|
if (normalizedPath.endsWith(".mp3"))
|
||||||
|
return "MP3";
|
||||||
|
if (normalizedPath.endsWith(".m4a"))
|
||||||
|
return "M4A";
|
||||||
|
const normalizedFormat = (item.format || "").trim().toLowerCase();
|
||||||
|
switch (normalizedFormat) {
|
||||||
|
case "hi_res":
|
||||||
|
case "hi_res_lossless":
|
||||||
|
case "lossless":
|
||||||
|
case "flac":
|
||||||
|
case "6":
|
||||||
|
case "7":
|
||||||
|
case "27":
|
||||||
|
return "FLAC";
|
||||||
|
case "alac":
|
||||||
|
case "apple":
|
||||||
|
case "atmos":
|
||||||
|
case "m4a":
|
||||||
|
case "m4a-aac":
|
||||||
|
case "m4a-alac":
|
||||||
|
return "M4A";
|
||||||
|
case "mp3":
|
||||||
|
return "MP3";
|
||||||
|
default:
|
||||||
|
return (item.format || "-").toUpperCase();
|
||||||
|
}
|
||||||
|
};
|
||||||
interface DownloadHistoryItem {
|
interface DownloadHistoryItem {
|
||||||
id: string;
|
id: string;
|
||||||
spotify_id: string;
|
spotify_id: string;
|
||||||
@@ -57,7 +89,7 @@ export function HistoryPage({ onHistorySelect }: HistoryPageProps) {
|
|||||||
const [downloadSortBy, setDownloadSortBy] = useState("default");
|
const [downloadSortBy, setDownloadSortBy] = useState("default");
|
||||||
const [downloadCurrentPage, setDownloadCurrentPage] = useState(1);
|
const [downloadCurrentPage, setDownloadCurrentPage] = useState(1);
|
||||||
const [playingPreviewId, setPlayingPreviewId] = useState<string | null>(null);
|
const [playingPreviewId, setPlayingPreviewId] = useState<string | null>(null);
|
||||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
const playbackRef = useRef<PreviewPlayback | null>(null);
|
||||||
const [fetchHistory, setFetchHistory] = useState<FetchHistoryItem[]>([]);
|
const [fetchHistory, setFetchHistory] = useState<FetchHistoryItem[]>([]);
|
||||||
const [filteredFetchHistory, setFilteredFetchHistory] = useState<FetchHistoryItem[]>([]);
|
const [filteredFetchHistory, setFilteredFetchHistory] = useState<FetchHistoryItem[]>([]);
|
||||||
const [activeFetchTab, setActiveFetchTab] = useState("track");
|
const [activeFetchTab, setActiveFetchTab] = useState("track");
|
||||||
@@ -122,9 +154,8 @@ export function HistoryPage({ onHistorySelect }: HistoryPageProps) {
|
|||||||
}, [activeTab]);
|
}, [activeTab]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (audioRef.current) {
|
playbackRef.current?.destroy();
|
||||||
audioRef.current.pause();
|
playbackRef.current = null;
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -180,20 +211,35 @@ export function HistoryPage({ onHistorySelect }: HistoryPageProps) {
|
|||||||
}, [fetchSearchQuery, activeFetchTab]);
|
}, [fetchSearchQuery, activeFetchTab]);
|
||||||
const handlePreview = async (id: string, spotifyId: string) => {
|
const handlePreview = async (id: string, spotifyId: string) => {
|
||||||
if (playingPreviewId === id) {
|
if (playingPreviewId === id) {
|
||||||
audioRef.current?.pause();
|
playbackRef.current?.destroy();
|
||||||
|
playbackRef.current = null;
|
||||||
setPlayingPreviewId(null);
|
setPlayingPreviewId(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (audioRef.current) {
|
if (playbackRef.current) {
|
||||||
audioRef.current.pause();
|
playbackRef.current.destroy();
|
||||||
|
playbackRef.current = null;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const url = await GetPreviewURL(spotifyId);
|
const url = await GetPreviewURL(spotifyId);
|
||||||
if (url) {
|
if (url) {
|
||||||
const audio = new Audio(url);
|
const playback = await createPreviewPlayback(url, getPreviewVolume());
|
||||||
audioRef.current = audio;
|
const audio = playback.audio;
|
||||||
audio.volume = SPOTIFY_PREVIEW_VOLUME;
|
playbackRef.current = playback;
|
||||||
audio.onended = () => setPlayingPreviewId(null);
|
audio.onended = () => {
|
||||||
|
setPlayingPreviewId(null);
|
||||||
|
if (playbackRef.current?.audio === audio) {
|
||||||
|
playbackRef.current.destroy();
|
||||||
|
playbackRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
audio.onerror = () => {
|
||||||
|
setPlayingPreviewId(null);
|
||||||
|
if (playbackRef.current?.audio === audio) {
|
||||||
|
playbackRef.current.destroy();
|
||||||
|
playbackRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
audio.play();
|
audio.play();
|
||||||
setPlayingPreviewId(id);
|
setPlayingPreviewId(id);
|
||||||
}
|
}
|
||||||
@@ -271,7 +317,7 @@ export function HistoryPage({ onHistorySelect }: HistoryPageProps) {
|
|||||||
<Input placeholder="Search downloads..." value={downloadSearchQuery} onChange={(e) => setDownloadSearchQuery(e.target.value)} className="pl-8 h-9"/>
|
<Input placeholder="Search downloads..." value={downloadSearchQuery} onChange={(e) => setDownloadSearchQuery(e.target.value)} className="pl-8 h-9"/>
|
||||||
</div>
|
</div>
|
||||||
<Select value={downloadSortBy} onValueChange={setDownloadSortBy}>
|
<Select value={downloadSortBy} onValueChange={setDownloadSortBy}>
|
||||||
<SelectTrigger className="w-[180px] h-9">
|
<SelectTrigger className="w-45 h-9">
|
||||||
<ArrowUpDown className="mr-2 h-4 w-4"/>
|
<ArrowUpDown className="mr-2 h-4 w-4"/>
|
||||||
<SelectValue placeholder="Sort by"/>
|
<SelectValue placeholder="Sort by"/>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -332,7 +378,7 @@ export function HistoryPage({ onHistorySelect }: HistoryPageProps) {
|
|||||||
<td className="p-3 align-middle text-left hidden lg:table-cell">
|
<td className="p-3 align-middle text-left hidden lg:table-cell">
|
||||||
<div className="flex flex-col items-start gap-1">
|
<div className="flex flex-col items-start gap-1">
|
||||||
<span className="text-xs font-bold text-foreground">
|
<span className="text-xs font-bold text-foreground">
|
||||||
{['HI_RES_LOSSLESS', 'LOSSLESS', 'flac', '6', '7', '27'].includes(item.format?.toLowerCase() || '') ? 'FLAC' : item.format?.toUpperCase()}
|
{getHistoryFormatLabel(item)}
|
||||||
</span>
|
</span>
|
||||||
{item.quality && <span className="text-[11px] text-muted-foreground leading-none whitespace-nowrap">{item.quality}</span>}
|
{item.quality && <span className="text-[11px] text-muted-foreground leading-none whitespace-nowrap">{item.quality}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+40
-163
@@ -1,31 +1,27 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { openExternal } from "@/lib/utils";
|
import { openExternal } from "@/lib/utils";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@/components/ui/card";
|
||||||
import { Star, GitFork, Clock, Download, Blocks, Heart, Copy, CircleCheck, Info } from "lucide-react";
|
import { Star, GitFork, Clock, Download, Info } from "lucide-react";
|
||||||
import AudioTTSProIcon from "@/assets/audiotts-pro.webp";
|
import AudioTTSProIcon from "@/assets/audiotts-pro.webp";
|
||||||
import ChatGPTTTSIcon from "@/assets/chatgpt-tts.webp";
|
import ChatGPTTTSIcon from "@/assets/chatgpt-tts.webp";
|
||||||
import XIcon from "@/assets/x.webp";
|
import XIcon from "@/assets/x.webp";
|
||||||
import XProIcon from "@/assets/x-pro.webp";
|
|
||||||
import SpotubeDLIcon from "@/assets/icons/spotubedl.svg";
|
import SpotubeDLIcon from "@/assets/icons/spotubedl.svg";
|
||||||
import SpotiDownloaderIcon from "@/assets/icons/spotidownloader.svg";
|
|
||||||
import XBatchDLIcon from "@/assets/icons/xbatchdl.svg";
|
import XBatchDLIcon from "@/assets/icons/xbatchdl.svg";
|
||||||
import SpotiFLACNextIcon from "@/assets/icons/next.svg";
|
import SpotiFLACNextIcon from "@/assets/icons/next.svg";
|
||||||
import KofiLogo from "@/assets/ko-fi.gif";
|
|
||||||
import KofiSvg from "@/assets/kofi_symbol.svg";
|
|
||||||
import UsdtBarcode from "@/assets/usdt.jpg";
|
|
||||||
import { langColors } from "@/assets/github-lang-colors";
|
import { langColors } from "@/assets/github-lang-colors";
|
||||||
const browserExtensionItems = [
|
const browserExtensionItems = [
|
||||||
{ icon: AudioTTSProIcon, label: "AudioTTS Pro", alt: "AudioTTS Pro" },
|
{ icon: AudioTTSProIcon, label: "AudioTTS Pro", alt: "AudioTTS Pro" },
|
||||||
{ icon: ChatGPTTTSIcon, label: "ChatGPT TTS", alt: "ChatGPT TTS" },
|
{ icon: ChatGPTTTSIcon, label: "ChatGPT TTS", alt: "ChatGPT TTS" },
|
||||||
{ icon: XIcon, label: "Twitter/X Media Batch Downloader", alt: "Twitter/X Media Batch Downloader" },
|
{ icon: XIcon, label: "Twitter/X Media Batch Downloader", alt: "Twitter/X Media Batch Downloader" },
|
||||||
{ icon: XProIcon, label: "Twitter/X Media Batch Downloader Pro", alt: "Twitter/X Media Batch Downloader Pro" },
|
|
||||||
];
|
];
|
||||||
const projectCardClass = "cursor-pointer transition-colors hover:bg-muted/50 dark:hover:bg-accent/50";
|
const projectCardClass = "cursor-pointer gap-3 py-5 transition-colors hover:bg-muted/50 dark:hover:bg-accent/50";
|
||||||
export function AboutPage() {
|
const projectCardHeaderClass = "px-5 gap-1.5";
|
||||||
const [activeTab, setActiveTab] = useState<"projects" | "support">("projects");
|
const projectCardContentClass = "px-5";
|
||||||
|
const projectBodyClass = "text-[13px] leading-snug";
|
||||||
|
const releaseMetaClass = "text-xs text-muted-foreground whitespace-nowrap";
|
||||||
|
const releaseVersionClass = "text-xs bg-primary text-primary-foreground px-1.5 py-0.5 rounded-sm font-mono font-semibold whitespace-nowrap";
|
||||||
|
export function OtherProjects() {
|
||||||
const [repoStats, setRepoStats] = useState<Record<string, any>>({});
|
const [repoStats, setRepoStats] = useState<Record<string, any>>({});
|
||||||
const [copiedUsdt, setCopiedUsdt] = useState(false);
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchRepoStats = async () => {
|
const fetchRepoStats = async () => {
|
||||||
const CACHE_KEY = "github_repo_stats_v4";
|
const CACHE_KEY = "github_repo_stats_v4";
|
||||||
@@ -44,8 +40,7 @@ export function AboutPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const repos = [
|
const repos = [
|
||||||
{ name: "SpotiDownloader", owner: "afkarxyz" },
|
{ name: "SpotiFLAC-Next", owner: "spotbye" },
|
||||||
{ name: "SpotiFLAC-Next", owner: "spotiverse" },
|
|
||||||
{ name: "Twitter-X-Media-Batch-Downloader", owner: "afkarxyz" },
|
{ name: "Twitter-X-Media-Batch-Downloader", owner: "afkarxyz" },
|
||||||
];
|
];
|
||||||
const stats: Record<string, any> = {};
|
const stats: Record<string, any> = {};
|
||||||
@@ -176,36 +171,22 @@ export function AboutPage() {
|
|||||||
const getRepoDescription = (repoName: string): string => {
|
const getRepoDescription = (repoName: string): string => {
|
||||||
return repoStats[repoName]?.description || "";
|
return repoStats[repoName]?.description || "";
|
||||||
};
|
};
|
||||||
return (<div className="flex flex-col space-y-4">
|
return (<div className="flex flex-col space-y-3">
|
||||||
<div className="flex items-center justify-between shrink-0">
|
<div className="flex items-center justify-between shrink-0">
|
||||||
<h2 className="text-2xl font-bold tracking-tight">About</h2>
|
<h2 className="text-2xl font-bold tracking-tight">Other Projects</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2 border-b shrink-0">
|
<div className="flex-1 min-h-0 pr-1.5">
|
||||||
<Button variant={activeTab === "projects" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("projects")} className="rounded-b-none">
|
<div className="grid gap-2 grid-cols-3">
|
||||||
<Blocks className="h-4 w-4"/>
|
<Card className={projectCardClass} onClick={() => openExternal("https://github.com/spotbye/SpotiFLAC-Next")}>
|
||||||
Other Projects
|
<CardHeader className={projectCardHeaderClass}>
|
||||||
</Button>
|
|
||||||
<Button variant={activeTab === "support" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("support")} className="rounded-b-none">
|
|
||||||
<Heart className="h-4 w-4"/>
|
|
||||||
Support Me
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 min-h-0">
|
|
||||||
|
|
||||||
|
|
||||||
{activeTab === "projects" && (<div className="p-1 pr-2">
|
|
||||||
<div className="grid gap-2 grid-cols-4">
|
|
||||||
<Card className={`gap-2 ${projectCardClass}`} onClick={() => openExternal("https://github.com/spotbye/SpotiFLAC-Next")}>
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex justify-between items-start mb-2">
|
<div className="flex justify-between items-start mb-2">
|
||||||
<img src={SpotiFLACNextIcon} className="h-6 w-6 shrink-0" alt="SpotiFLAC Next"/>
|
<img src={SpotiFLACNextIcon} className="h-6 w-6 shrink-0" alt="SpotiFLAC Next"/>
|
||||||
<div className="flex items-center gap-2">
|
<div className="ml-3 flex flex-wrap items-center justify-end gap-2">
|
||||||
{repoStats["SpotiFLAC-Next"]?.latestReleaseAt && (<span className="text-[10px] text-muted-foreground whitespace-nowrap">
|
{repoStats["SpotiFLAC-Next"]?.latestReleaseAt && (<span className={releaseMetaClass}>
|
||||||
{formatReleaseTimeAgo(repoStats["SpotiFLAC-Next"].latestReleaseAt)}
|
{formatReleaseTimeAgo(repoStats["SpotiFLAC-Next"].latestReleaseAt)}
|
||||||
</span>)}
|
</span>)}
|
||||||
{repoStats["SpotiFLAC-Next"]?.latestVersion && (<span className="text-[10px] bg-primary text-primary-foreground px-1.5 py-0.5 rounded-sm font-mono font-semibold max-w-[80px] truncate">
|
{repoStats["SpotiFLAC-Next"]?.latestVersion && (<span className={releaseVersionClass}>
|
||||||
{repoStats["SpotiFLAC-Next"].latestVersion}
|
{repoStats["SpotiFLAC-Next"].latestVersion}
|
||||||
</span>)}
|
</span>)}
|
||||||
</div>
|
</div>
|
||||||
@@ -213,11 +194,11 @@ export function AboutPage() {
|
|||||||
<CardTitle className="leading-tight">
|
<CardTitle className="leading-tight">
|
||||||
SpotiFLAC Next
|
SpotiFLAC Next
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription className={projectBodyClass}>
|
||||||
{getRepoDescription("SpotiFLAC-Next")}
|
{getRepoDescription("SpotiFLAC-Next")}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
{repoStats["SpotiFLAC-Next"] && (<CardContent className="space-y-2">
|
{repoStats["SpotiFLAC-Next"] && (<CardContent className={`${projectCardContentClass} space-y-2`}>
|
||||||
{repoStats["SpotiFLAC-Next"].languages?.length > 0 && (<div className="flex flex-wrap gap-2 text-xs">
|
{repoStats["SpotiFLAC-Next"].languages?.length > 0 && (<div className="flex flex-wrap gap-2 text-xs">
|
||||||
{repoStats["SpotiFLAC-Next"].languages.map((lang: string) => (<span key={lang} className="px-2 py-0.5 rounded-full font-medium" style={{
|
{repoStats["SpotiFLAC-Next"].languages.map((lang: string) => (<span key={lang} className="px-2 py-0.5 rounded-full font-medium" style={{
|
||||||
backgroundColor: getLangColor(lang) + "20",
|
backgroundColor: getLangColor(lang) + "20",
|
||||||
@@ -245,76 +226,21 @@ export function AboutPage() {
|
|||||||
<Info className="h-3.5 w-3.5"/>
|
<Info className="h-3.5 w-3.5"/>
|
||||||
Note
|
Note
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs leading-relaxed text-sky-700 dark:text-sky-300">
|
<p className="text-xs leading-snug text-sky-700 dark:text-sky-300">
|
||||||
This project was created as a thank-you to everyone who has supported SpotiFLAC on Ko-fi.
|
This project released as a token of appreciation for those who have supported SpotiFLAC through Ko-fi, Patreon, or crypto. It’s not a paid product, but it’s shared privately through a supporter-only post.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>)}
|
</CardContent>)}
|
||||||
</Card>
|
</Card>
|
||||||
<Card className={projectCardClass} onClick={() => openExternal("https://github.com/spotbye/SpotiDownloader")}>
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex justify-between items-start mb-2">
|
|
||||||
<img src={SpotiDownloaderIcon} className="h-6 w-6 shrink-0" alt="SpotiDownloader"/>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{repoStats["SpotiDownloader"]?.latestReleaseAt && (<span className="text-[10px] text-muted-foreground whitespace-nowrap">
|
|
||||||
{formatReleaseTimeAgo(repoStats["SpotiDownloader"].latestReleaseAt)}
|
|
||||||
</span>)}
|
|
||||||
{repoStats["SpotiDownloader"]?.latestVersion && (<span className="text-[10px] bg-primary text-primary-foreground px-1.5 py-0.5 rounded-sm font-mono font-semibold max-w-[80px] truncate">
|
|
||||||
{repoStats["SpotiDownloader"].latestVersion}
|
|
||||||
</span>)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<CardTitle className="leading-tight">
|
|
||||||
SpotiDownloader
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
{getRepoDescription("SpotiDownloader")}
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
{repoStats["SpotiDownloader"] && (<CardContent className="space-y-3">
|
|
||||||
<div className="flex flex-wrap gap-2 text-xs">
|
|
||||||
{repoStats["SpotiDownloader"].languages?.map((lang: string) => (<span key={lang} className="px-2 py-0.5 rounded-full font-medium" style={{
|
|
||||||
backgroundColor: getLangColor(lang) + "20",
|
|
||||||
color: getLangColor(lang),
|
|
||||||
}}>
|
|
||||||
{lang}
|
|
||||||
</span>))}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<Star className="h-3.5 w-3.5 fill-amber-500 text-amber-500"/>{" "}
|
|
||||||
{formatNumber(repoStats["SpotiDownloader"].stars)}
|
|
||||||
</span>
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<GitFork className="h-3.5 w-3.5"/>{" "}
|
|
||||||
{repoStats["SpotiDownloader"].forks}
|
|
||||||
</span>
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<Clock className="h-3.5 w-3.5"/>{" "}
|
|
||||||
{formatTimeAgo(repoStats["SpotiDownloader"].createdAt)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1 text-xs text-muted-foreground items-start">
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<Download className="h-3.5 w-3.5"/> TOTAL:{" "}
|
|
||||||
{formatNumber(repoStats["SpotiDownloader"].totalDownloads)}
|
|
||||||
</span>
|
|
||||||
<span className="flex items-center gap-1 text-green-600 dark:text-green-400">
|
|
||||||
<Download className="h-3.5 w-3.5"/> LATEST:{" "}
|
|
||||||
{formatNumber(repoStats["SpotiDownloader"].latestDownloads)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</CardContent>)}
|
|
||||||
</Card>
|
|
||||||
<Card className={projectCardClass} onClick={() => openExternal("https://github.com/afkarxyz/Twitter-X-Media-Batch-Downloader")}>
|
<Card className={projectCardClass} onClick={() => openExternal("https://github.com/afkarxyz/Twitter-X-Media-Batch-Downloader")}>
|
||||||
<CardHeader>
|
<CardHeader className={projectCardHeaderClass}>
|
||||||
<div className="flex justify-between items-start mb-2">
|
<div className="flex justify-between items-start mb-2">
|
||||||
<img src={XBatchDLIcon} className="h-6 w-6 shrink-0" alt="Twitter/X Media Batch Downloader"/>
|
<img src={XBatchDLIcon} className="h-6 w-6 shrink-0" alt="Twitter/X Media Batch Downloader"/>
|
||||||
<div className="flex items-center gap-2">
|
<div className="ml-3 flex flex-wrap items-center justify-end gap-2">
|
||||||
{repoStats["Twitter-X-Media-Batch-Downloader"]?.latestReleaseAt && (<span className="text-[10px] text-muted-foreground whitespace-nowrap">
|
{repoStats["Twitter-X-Media-Batch-Downloader"]?.latestReleaseAt && (<span className={releaseMetaClass}>
|
||||||
{formatReleaseTimeAgo(repoStats["Twitter-X-Media-Batch-Downloader"].latestReleaseAt)}
|
{formatReleaseTimeAgo(repoStats["Twitter-X-Media-Batch-Downloader"].latestReleaseAt)}
|
||||||
</span>)}
|
</span>)}
|
||||||
{repoStats["Twitter-X-Media-Batch-Downloader"]?.latestVersion && (<span className="text-[10px] bg-primary text-primary-foreground px-1.5 py-0.5 rounded-sm font-mono font-semibold max-w-[80px] truncate">
|
{repoStats["Twitter-X-Media-Batch-Downloader"]?.latestVersion && (<span className={releaseVersionClass}>
|
||||||
{repoStats["Twitter-X-Media-Batch-Downloader"].latestVersion}
|
{repoStats["Twitter-X-Media-Batch-Downloader"].latestVersion}
|
||||||
</span>)}
|
</span>)}
|
||||||
</div>
|
</div>
|
||||||
@@ -322,11 +248,11 @@ export function AboutPage() {
|
|||||||
<CardTitle className="leading-tight">
|
<CardTitle className="leading-tight">
|
||||||
Twitter/X Media Batch Downloader
|
Twitter/X Media Batch Downloader
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription className={projectBodyClass}>
|
||||||
{getRepoDescription("Twitter-X-Media-Batch-Downloader")}
|
{getRepoDescription("Twitter-X-Media-Batch-Downloader")}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
{repoStats["Twitter-X-Media-Batch-Downloader"] && (<CardContent className="space-y-3">
|
{repoStats["Twitter-X-Media-Batch-Downloader"] && (<CardContent className={`${projectCardContentClass} space-y-2`}>
|
||||||
<div className="flex flex-wrap gap-2 text-xs">
|
<div className="flex flex-wrap gap-2 text-xs">
|
||||||
{repoStats["Twitter-X-Media-Batch-Downloader"].languages?.map((lang: string) => (<span key={lang} className="px-2 py-0.5 rounded-full font-medium" style={{
|
{repoStats["Twitter-X-Media-Batch-Downloader"].languages?.map((lang: string) => (<span key={lang} className="px-2 py-0.5 rounded-full font-medium" style={{
|
||||||
backgroundColor: getLangColor(lang) + "20",
|
backgroundColor: getLangColor(lang) + "20",
|
||||||
@@ -364,14 +290,14 @@ export function AboutPage() {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>)}
|
</CardContent>)}
|
||||||
</Card>
|
</Card>
|
||||||
<div className="flex flex-col gap-2 h-full">
|
<div className="flex h-full flex-col gap-1.5">
|
||||||
<Card className={`${projectCardClass} flex-1`} onClick={() => openExternal("https://exyezed.qzz.io/")}>
|
<Card className={`${projectCardClass} flex-1`} onClick={() => openExternal("https://exyezed.fyi/")}>
|
||||||
<CardHeader>
|
<CardHeader className={projectCardHeaderClass}>
|
||||||
<CardTitle>Browser Extensions & Scripts</CardTitle>
|
<CardTitle className="leading-tight">Browser Extensions & Scripts</CardTitle>
|
||||||
<CardDescription className="flex flex-col gap-2 pt-2">
|
<CardDescription className="flex flex-col gap-2.5 pt-1.5">
|
||||||
{browserExtensionItems.map((item) => (<div key={item.alt} className="flex items-center gap-2">
|
{browserExtensionItems.map((item) => (<div key={item.alt} className="flex items-center gap-2.5">
|
||||||
<img src={item.icon} className="h-[22px] w-[22px] rounded-sm shadow-sm" alt={item.alt}/>
|
<img src={item.icon} className="h-5.5 w-5.5 rounded-sm shadow-sm" alt={item.alt}/>
|
||||||
<span className="text-[11px] leading-tight text-muted-foreground">
|
<span className={`${projectBodyClass} text-muted-foreground`}>
|
||||||
{item.label}
|
{item.label}
|
||||||
</span>
|
</span>
|
||||||
</div>))}
|
</div>))}
|
||||||
@@ -379,67 +305,18 @@ export function AboutPage() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
</Card>
|
</Card>
|
||||||
<Card className={`${projectCardClass} flex-1`} onClick={() => openExternal("https://spotubedl.com/")}>
|
<Card className={`${projectCardClass} flex-1`} onClick={() => openExternal("https://spotubedl.com/")}>
|
||||||
<CardHeader>
|
<CardHeader className={projectCardHeaderClass}>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2 leading-tight">
|
||||||
<img src={SpotubeDLIcon} className="h-5 w-5" alt="SpotubeDL"/>{" "}
|
<img src={SpotubeDLIcon} className="h-5 w-5" alt="SpotubeDL"/>{" "}
|
||||||
SpotubeDL.com
|
SpotubeDL.com
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription className={projectBodyClass}>
|
||||||
Download Spotify Tracks, Albums, Playlists & Discography as MP3/OGG/Opus.
|
Download Spotify Tracks, Albums, Playlists & Discography as MP3/OGG/Opus.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>)}
|
|
||||||
|
|
||||||
{activeTab === "support" && (<div className="flex flex-col items-center justify-center p-4 space-y-6">
|
|
||||||
<div className="flex flex-col md:flex-row w-full max-w-3xl bg-card rounded-xl border shadow-sm">
|
|
||||||
|
|
||||||
<div className="flex-1 p-6 flex flex-col items-center justify-between border-b md:border-b-0 md:border-r space-y-6">
|
|
||||||
<div className="flex flex-col items-center space-y-4">
|
|
||||||
<div className="h-32 flex items-center justify-center w-full relative">
|
|
||||||
<img src={KofiLogo} className="w-72 absolute pointer-events-none" alt="Ko-fi"/>
|
|
||||||
</div>
|
|
||||||
<h4 className="font-semibold text-foreground">Support via Ko-fi</h4>
|
|
||||||
<p className="text-sm text-muted-foreground text-center px-4">
|
|
||||||
Enjoying the project? You can support ongoing development by buying me a coffee.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button className="h-10 w-full text-sm font-semibold text-white gap-2 group bg-[#72a4f2] hover:bg-[#5f8cd6]" onClick={() => openExternal("https://ko-fi.com/afkarxyz")}>
|
|
||||||
<img src={KofiSvg} className="w-5 h-5 shrink-0" alt="" aria-hidden="true"/>
|
|
||||||
Support me on Ko-fi
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
<div className="flex-1 p-6 flex flex-col items-center justify-between space-y-6">
|
|
||||||
<div className="flex flex-col items-center space-y-4 w-full">
|
|
||||||
<div className="h-32 flex items-center justify-center">
|
|
||||||
<div className="p-2 bg-white rounded-xl shadow-sm border">
|
|
||||||
<img src={UsdtBarcode} className="w-24 h-24 object-contain" alt="USDT Barcode"/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<h4 className="font-semibold text-foreground">USDT (TRC20)</h4>
|
|
||||||
<p className="text-sm text-muted-foreground text-center px-4">
|
|
||||||
Crypto donations are also accepted. Scan the QR code or copy the address.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 bg-muted/50 pl-3 pr-1.5 py-1.5 rounded-lg border w-full justify-between h-10">
|
|
||||||
<code className="text-xs font-mono text-muted-foreground truncate" title="THnzAAwZgp2Sq5CAXLP2njQDhTvgZG9EWs">
|
|
||||||
THnzAAwZgp2Sq5CAXLP2njQDhTvgZG9EWs
|
|
||||||
</code>
|
|
||||||
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0 hover:bg-background" onClick={() => {
|
|
||||||
navigator.clipboard.writeText("THnzAAwZgp2Sq5CAXLP2njQDhTvgZG9EWs");
|
|
||||||
setCopiedUsdt(true);
|
|
||||||
setTimeout(() => setCopiedUsdt(false), 500);
|
|
||||||
}}>
|
|
||||||
{copiedUsdt ? <CircleCheck className="h-3.5 w-3.5 text-green-500"/> : <Copy className="h-3.5 w-3.5"/>}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>);
|
</div>);
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import amazonMusicIcon from "../assets/icons/amzn.png";
|
import amazonMusicIcon from "../assets/icons/amzn.png";
|
||||||
|
import appleMusicIcon from "../assets/icons/am.png";
|
||||||
|
import deezerIcon from "../assets/icons/dzr.png";
|
||||||
import lrclibIcon from "../assets/icons/lrclib.png";
|
import lrclibIcon from "../assets/icons/lrclib.png";
|
||||||
import musicBrainzDarkIcon from "../assets/icons/musicbrainz_d.png";
|
import musicBrainzDarkIcon from "../assets/icons/musicbrainz_d.png";
|
||||||
import musicBrainzLightIcon from "../assets/icons/musicbrainz_l.png";
|
import musicBrainzLightIcon from "../assets/icons/musicbrainz_l.png";
|
||||||
@@ -81,6 +83,12 @@ export function QobuzIcon({ className = "w-4 h-4" }: PlatformIconProps) {
|
|||||||
export function AmazonIcon({ className = "w-4 h-4" }: PlatformIconProps) {
|
export function AmazonIcon({ className = "w-4 h-4" }: PlatformIconProps) {
|
||||||
return <PlatformIcon src={amazonMusicIcon} alt="Amazon Music" className={className} defaultClassName="rounded-[4px]"/>;
|
return <PlatformIcon src={amazonMusicIcon} alt="Amazon Music" className={className} defaultClassName="rounded-[4px]"/>;
|
||||||
}
|
}
|
||||||
|
export function AppleMusicIcon({ className = "w-4 h-4" }: PlatformIconProps) {
|
||||||
|
return <PlatformIcon src={appleMusicIcon} alt="Apple Music" className={className} defaultClassName="rounded-[4px]"/>;
|
||||||
|
}
|
||||||
|
export function DeezerIcon({ className = "w-4 h-4" }: PlatformIconProps) {
|
||||||
|
return <PlatformIcon src={deezerIcon} alt="Deezer" className={className} defaultClassName="rounded-[4px]"/>;
|
||||||
|
}
|
||||||
export function LrclibIcon({ className = "w-4 h-4" }: PlatformIconProps) {
|
export function LrclibIcon({ className = "w-4 h-4" }: PlatformIconProps) {
|
||||||
return <PlatformIcon src={lrclibIcon} alt="LRCLIB" className={className}/>;
|
return <PlatformIcon src={lrclibIcon} alt="LRCLIB" className={className}/>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ interface PlaylistInfoProps {
|
|||||||
isDownloading: boolean;
|
isDownloading: boolean;
|
||||||
bulkDownloadType: "all" | "selected" | null;
|
bulkDownloadType: "all" | "selected" | null;
|
||||||
downloadProgress: number;
|
downloadProgress: number;
|
||||||
|
downloadRemainingCount: number;
|
||||||
currentDownloadInfo: {
|
currentDownloadInfo: {
|
||||||
name: string;
|
name: string;
|
||||||
artists: string;
|
artists: string;
|
||||||
@@ -88,7 +89,7 @@ interface PlaylistInfoProps {
|
|||||||
onTrackClick: (track: TrackMetadata) => void;
|
onTrackClick: (track: TrackMetadata) => void;
|
||||||
onBack?: () => void;
|
onBack?: () => void;
|
||||||
}
|
}
|
||||||
export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, isMetadataLoading = false, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onPageChange, onAlbumClick, onArtistClick, onTrackClick, onBack, }: PlaylistInfoProps) {
|
export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, downloadRemainingCount, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, isMetadataLoading = false, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onPageChange, onAlbumClick, onArtistClick, onTrackClick, onBack, }: PlaylistInfoProps) {
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
const playlistName = playlistInfo.owner.name;
|
const playlistName = playlistInfo.owner.name;
|
||||||
const playlistFolderName = buildPlaylistFolderName(playlistName, playlistInfo.owner.display_name, settings.playlistOwnerFolderName);
|
const playlistFolderName = buildPlaylistFolderName(playlistName, playlistInfo.owner.display_name, settings.playlistOwnerFolderName);
|
||||||
@@ -235,7 +236,7 @@ export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, sel
|
|||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>)}
|
</Tooltip>)}
|
||||||
</div>
|
</div>
|
||||||
{isDownloading && (<DownloadProgress progress={downloadProgress} currentTrack={currentDownloadInfo} onStop={onStopDownload}/>)}
|
{isDownloading && (<DownloadProgress progress={downloadProgress} remainingCount={downloadRemainingCount} currentTrack={currentDownloadInfo} onStop={onStopDownload}/>)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -1,29 +1,43 @@
|
|||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import { flushSync } from "react-dom";
|
import { flushSync } from "react-dom";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
import { InputWithContext } from "@/components/ui/input-with-context";
|
import { InputWithContext } from "@/components/ui/input-with-context";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
|
||||||
import { FolderOpen, Save, RotateCcw, Info, ArrowRight, MonitorCog, FolderCog, Router, FolderLock } from "lucide-react";
|
import { FolderOpen, Save, RotateCcw, Info, ArrowRight, MonitorCog, FolderCog, Router, FolderLock, Plus, Trash2, ExternalLink, PlugZap, Download, Tags } from "lucide-react";
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { getSettings, getSettingsWithDefaults, saveSettings, resetToDefaultSettings, applyThemeMode, applyFont, FONT_OPTIONS, FOLDER_PRESETS, FILENAME_PRESETS, TEMPLATE_VARIABLES, type Settings as SettingsType, type FontFamily, type FolderPreset, type FilenamePreset, } from "@/lib/settings";
|
import { getSettings, getSettingsWithDefaults, saveSettings, resetToDefaultSettings, applyThemeMode, applyFont, getFontOptions, parseGoogleFontUrl, loadGoogleFontUrl, loadCustomFonts, saveCustomFonts, FOLDER_PRESETS, FILENAME_PRESETS, TEMPLATE_VARIABLES, hasConfiguredCustomTidalApi, sanitizeAutoOrder, type Settings as SettingsType, type FontFamily, type CustomFontFamily, type FolderPreset, type FilenamePreset, type ExistingFileCheckMode, } from "@/lib/settings";
|
||||||
import { themes, applyTheme } from "@/lib/themes";
|
import { themes, applyTheme } from "@/lib/themes";
|
||||||
import { SelectFolder, OpenConfigFolder } from "../../wailsjs/go/main/App";
|
import { SelectFolder, OpenConfigFolder, CheckCustomTidalAPI } from "../../wailsjs/go/main/App";
|
||||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||||
|
import { openExternal } from "@/lib/utils";
|
||||||
import { ApiStatusTab } from "./ApiStatusTab";
|
import { ApiStatusTab } from "./ApiStatusTab";
|
||||||
import { AmazonIcon, QobuzIcon, SonglinkIcon, SongstatsIcon, TidalIcon } from "./PlatformIcons";
|
import { AmazonIcon, QobuzIcon, SonglinkIcon, SongstatsIcon, TidalIcon } from "./PlatformIcons";
|
||||||
interface SettingsPageProps {
|
interface SettingsPageProps {
|
||||||
onUnsavedChangesChange?: (hasUnsavedChanges: boolean) => void;
|
onUnsavedChangesChange?: (hasUnsavedChanges: boolean) => void;
|
||||||
onResetRequest?: (resetFn: () => void) => void;
|
onResetRequest?: (resetFn: () => void) => void;
|
||||||
}
|
}
|
||||||
|
type CustomTidalApiStatus = "idle" | "checking" | "online" | "offline";
|
||||||
export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: SettingsPageProps) {
|
export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: SettingsPageProps) {
|
||||||
const [savedSettings, setSavedSettings] = useState<SettingsType>(getSettings());
|
const [savedSettings, setSavedSettings] = useState<SettingsType>(getSettings());
|
||||||
const [tempSettings, setTempSettings] = useState<SettingsType>(savedSettings);
|
const [tempSettings, setTempSettings] = useState<SettingsType>(savedSettings);
|
||||||
const [isDark, setIsDark] = useState(document.documentElement.classList.contains("dark"));
|
const [isDark, setIsDark] = useState(document.documentElement.classList.contains("dark"));
|
||||||
const [showResetConfirm, setShowResetConfirm] = useState(false);
|
const [showResetConfirm, setShowResetConfirm] = useState(false);
|
||||||
|
const [showAddFontDialog, setShowAddFontDialog] = useState(false);
|
||||||
|
const [showCustomTidalApiDialog, setShowCustomTidalApiDialog] = useState(false);
|
||||||
|
const [addFontUrl, setAddFontUrl] = useState("");
|
||||||
|
const [customTidalApiStatus, setCustomTidalApiStatus] = useState<CustomTidalApiStatus>("idle");
|
||||||
|
const parsedAddFont = parseGoogleFontUrl(addFontUrl);
|
||||||
|
const fontOptions = getFontOptions(tempSettings.customFonts);
|
||||||
const hasUnsavedChanges = JSON.stringify(savedSettings) !== JSON.stringify(tempSettings);
|
const hasUnsavedChanges = JSON.stringify(savedSettings) !== JSON.stringify(tempSettings);
|
||||||
|
const hasCustomTidalInstanceConfigured = hasConfiguredCustomTidalApi(tempSettings.customTidalApi);
|
||||||
|
const effectiveDownloader = !hasCustomTidalInstanceConfigured && tempSettings.downloader === "tidal"
|
||||||
|
? "auto"
|
||||||
|
: tempSettings.downloader;
|
||||||
|
const effectiveAutoOrder = sanitizeAutoOrder(tempSettings.autoOrder, hasCustomTidalInstanceConfigured);
|
||||||
const resetToSaved = useCallback(() => {
|
const resetToSaved = useCallback(() => {
|
||||||
const freshSavedSettings = getSettings();
|
const freshSavedSettings = getSettings();
|
||||||
flushSync(() => {
|
flushSync(() => {
|
||||||
@@ -55,14 +69,20 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
applyThemeMode(tempSettings.themeMode);
|
applyThemeMode(tempSettings.themeMode);
|
||||||
applyTheme(tempSettings.theme);
|
applyTheme(tempSettings.theme);
|
||||||
applyFont(tempSettings.fontFamily);
|
applyFont(tempSettings.fontFamily, tempSettings.customFonts);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setIsDark(document.documentElement.classList.contains("dark"));
|
setIsDark(document.documentElement.classList.contains("dark"));
|
||||||
}, 0);
|
}, 0);
|
||||||
}, [tempSettings.themeMode, tempSettings.theme, tempSettings.fontFamily]);
|
}, [tempSettings.themeMode, tempSettings.theme, tempSettings.fontFamily, tempSettings.customFonts]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (showAddFontDialog && parsedAddFont) {
|
||||||
|
loadGoogleFontUrl(parsedAddFont.url, "spotiflac-add-font-preview");
|
||||||
|
}
|
||||||
|
}, [showAddFontDialog, parsedAddFont]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadDefaults = async () => {
|
const loadDefaults = async () => {
|
||||||
if (!savedSettings.downloadPath) {
|
const currentSettings = getSettings();
|
||||||
|
if (!currentSettings.downloadPath) {
|
||||||
const settingsWithDefaults = await getSettingsWithDefaults();
|
const settingsWithDefaults = await getSettingsWithDefaults();
|
||||||
setSavedSettings(settingsWithDefaults);
|
setSavedSettings(settingsWithDefaults);
|
||||||
setTempSettings(settingsWithDefaults);
|
setTempSettings(settingsWithDefaults);
|
||||||
@@ -71,9 +91,19 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
|||||||
};
|
};
|
||||||
loadDefaults();
|
loadDefaults();
|
||||||
}, []);
|
}, []);
|
||||||
|
useEffect(() => {
|
||||||
|
const syncCustomFonts = async () => {
|
||||||
|
const customFonts = await loadCustomFonts();
|
||||||
|
setSavedSettings((prev) => ({ ...prev, customFonts }));
|
||||||
|
setTempSettings((prev) => ({ ...prev, customFonts }));
|
||||||
|
};
|
||||||
|
void syncCustomFonts();
|
||||||
|
}, []);
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
await saveSettings(tempSettings);
|
await saveSettings(tempSettings);
|
||||||
setSavedSettings(tempSettings);
|
const persistedSettings = getSettings();
|
||||||
|
setSavedSettings(persistedSettings);
|
||||||
|
setTempSettings(persistedSettings);
|
||||||
toast.success("Settings saved");
|
toast.success("Settings saved");
|
||||||
onUnsavedChangesChange?.(false);
|
onUnsavedChangesChange?.(false);
|
||||||
};
|
};
|
||||||
@@ -83,7 +113,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
|||||||
setSavedSettings(defaultSettings);
|
setSavedSettings(defaultSettings);
|
||||||
applyThemeMode(defaultSettings.themeMode);
|
applyThemeMode(defaultSettings.themeMode);
|
||||||
applyTheme(defaultSettings.theme);
|
applyTheme(defaultSettings.theme);
|
||||||
applyFont(defaultSettings.fontFamily);
|
applyFont(defaultSettings.fontFamily, defaultSettings.customFonts);
|
||||||
setShowResetConfirm(false);
|
setShowResetConfirm(false);
|
||||||
toast.success("Settings reset to default");
|
toast.success("Settings reset to default");
|
||||||
};
|
};
|
||||||
@@ -99,6 +129,51 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
|||||||
toast.error(`Error selecting folder: ${error}`);
|
toast.error(`Error selecting folder: ${error}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const closeAddFontDialog = () => {
|
||||||
|
setShowAddFontDialog(false);
|
||||||
|
setAddFontUrl("");
|
||||||
|
};
|
||||||
|
const handleAddFont = async () => {
|
||||||
|
if (!parsedAddFont) {
|
||||||
|
toast.error("Enter a valid Google Fonts URL");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const existingFonts = tempSettings.customFonts || [];
|
||||||
|
const existingIndex = existingFonts.findIndex((font) => font.value === parsedAddFont.value || font.url === parsedAddFont.url);
|
||||||
|
const customFonts = existingIndex >= 0
|
||||||
|
? existingFonts.map((font, index) => index === existingIndex ? parsedAddFont : font)
|
||||||
|
: [...existingFonts, parsedAddFont];
|
||||||
|
const savedCustomFonts = await saveCustomFonts(customFonts);
|
||||||
|
setSavedSettings((prev) => ({ ...prev, customFonts: savedCustomFonts }));
|
||||||
|
setTempSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
customFonts: savedCustomFonts,
|
||||||
|
fontFamily: parsedAddFont.value,
|
||||||
|
}));
|
||||||
|
closeAddFontDialog();
|
||||||
|
toast.success(`${parsedAddFont.label} added`);
|
||||||
|
};
|
||||||
|
const handleDeleteCustomFont = async (fontValue: CustomFontFamily) => {
|
||||||
|
const customFonts = (tempSettings.customFonts || []).filter((font) => font.value !== fontValue);
|
||||||
|
const savedCustomFonts = await saveCustomFonts(customFonts);
|
||||||
|
const shouldResetSavedFont = savedSettings.fontFamily === fontValue;
|
||||||
|
const shouldResetTempFont = tempSettings.fontFamily === fontValue;
|
||||||
|
const nextSavedSettings: SettingsType = {
|
||||||
|
...savedSettings,
|
||||||
|
customFonts: savedCustomFonts,
|
||||||
|
fontFamily: shouldResetSavedFont ? "google-sans" : savedSettings.fontFamily,
|
||||||
|
};
|
||||||
|
setSavedSettings(nextSavedSettings);
|
||||||
|
setTempSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
customFonts: savedCustomFonts,
|
||||||
|
fontFamily: shouldResetTempFont ? "google-sans" : prev.fontFamily,
|
||||||
|
}));
|
||||||
|
if (shouldResetSavedFont) {
|
||||||
|
await saveSettings(nextSavedSettings);
|
||||||
|
}
|
||||||
|
toast.success("Font deleted");
|
||||||
|
};
|
||||||
const handleTidalQualityChange = async (value: "LOSSLESS" | "HI_RES_LOSSLESS") => {
|
const handleTidalQualityChange = async (value: "LOSSLESS" | "HI_RES_LOSSLESS") => {
|
||||||
setTempSettings((prev) => ({ ...prev, tidalQuality: value }));
|
setTempSettings((prev) => ({ ...prev, tidalQuality: value }));
|
||||||
};
|
};
|
||||||
@@ -108,7 +183,49 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
|||||||
const handleAutoQualityChange = async (value: "16" | "24") => {
|
const handleAutoQualityChange = async (value: "16" | "24") => {
|
||||||
setTempSettings((prev) => ({ ...prev, autoQuality: value }));
|
setTempSettings((prev) => ({ ...prev, autoQuality: value }));
|
||||||
};
|
};
|
||||||
const [activeTab, setActiveTab] = useState<"general" | "files" | "api">("general");
|
const persistCustomTidalApi = useCallback(async (nextValue: string) => {
|
||||||
|
const normalizedValue = nextValue.trim().replace(/\/+$/g, "");
|
||||||
|
const persistedSettings = getSettings();
|
||||||
|
const nextSavedSettings: SettingsType = {
|
||||||
|
...persistedSettings,
|
||||||
|
customTidalApi: normalizedValue,
|
||||||
|
};
|
||||||
|
await saveSettings(nextSavedSettings);
|
||||||
|
const nextSavedState = getSettings();
|
||||||
|
setSavedSettings(nextSavedState);
|
||||||
|
setTempSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
customTidalApi: nextSavedState.customTidalApi,
|
||||||
|
downloader: !hasConfiguredCustomTidalApi(nextSavedState.customTidalApi) && prev.downloader === "tidal"
|
||||||
|
? nextSavedState.downloader
|
||||||
|
: prev.downloader,
|
||||||
|
autoOrder: sanitizeAutoOrder(prev.autoOrder, hasConfiguredCustomTidalApi(nextSavedState.customTidalApi)),
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
const handleCheckCustomTidalApi = async () => {
|
||||||
|
const normalizedCustomTidalApi = (tempSettings.customTidalApi || "").trim().replace(/\/+$/g, "");
|
||||||
|
if (!normalizedCustomTidalApi.startsWith("https://")) {
|
||||||
|
toast.error("Enter a valid HTTPS HiFi API URL");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCustomTidalApiStatus("checking");
|
||||||
|
try {
|
||||||
|
const isOnline = await CheckCustomTidalAPI(normalizedCustomTidalApi);
|
||||||
|
setCustomTidalApiStatus(isOnline ? "online" : "offline");
|
||||||
|
if (isOnline) {
|
||||||
|
toast.success("HiFi API instance is online");
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
toast.error("HiFi API instance is offline");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error("Failed to check custom Tidal API:", error);
|
||||||
|
setCustomTidalApiStatus("offline");
|
||||||
|
toast.error(`Failed to check HiFi API instance: ${error}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const [activeTab, setActiveTab] = useState<"general" | "download" | "files" | "metadata" | "status">("general");
|
||||||
return (<div className="space-y-4 h-full flex flex-col">
|
return (<div className="space-y-4 h-full flex flex-col">
|
||||||
<div className="flex items-center justify-between shrink-0">
|
<div className="flex items-center justify-between shrink-0">
|
||||||
<h1 className="text-2xl font-bold">Settings</h1>
|
<h1 className="text-2xl font-bold">Settings</h1>
|
||||||
@@ -140,33 +257,27 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
|||||||
<MonitorCog className="h-4 w-4"/>
|
<MonitorCog className="h-4 w-4"/>
|
||||||
General
|
General
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button variant={activeTab === "download" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("download")} className="rounded-b-none gap-2">
|
||||||
|
<Download className="h-4 w-4"/>
|
||||||
|
Download
|
||||||
|
</Button>
|
||||||
<Button variant={activeTab === "files" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("files")} className="rounded-b-none gap-2">
|
<Button variant={activeTab === "files" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("files")} className="rounded-b-none gap-2">
|
||||||
<FolderCog className="h-4 w-4"/>
|
<FolderCog className="h-4 w-4"/>
|
||||||
File Management
|
Files
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant={activeTab === "api" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("api")} className="rounded-b-none gap-2">
|
<Button variant={activeTab === "metadata" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("metadata")} className="rounded-b-none gap-2">
|
||||||
|
<Tags className="h-4 w-4"/>
|
||||||
|
Metadata
|
||||||
|
</Button>
|
||||||
|
<Button variant={activeTab === "status" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("status")} className="rounded-b-none gap-2">
|
||||||
<Router className="h-4 w-4"/>
|
<Router className="h-4 w-4"/>
|
||||||
Status
|
Status
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto pt-4">
|
<div className="flex-1 overflow-y-auto pt-4">
|
||||||
{activeTab === "general" && (<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
{activeTab === "general" && (<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="download-path">Download Path</Label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<InputWithContext id="download-path" value={tempSettings.downloadPath} onChange={(e) => setTempSettings((prev) => ({
|
|
||||||
...prev,
|
|
||||||
downloadPath: e.target.value,
|
|
||||||
}))} placeholder="C:\Users\YourUsername\Music"/>
|
|
||||||
<Button type="button" onClick={handleBrowseFolder} className="gap-1.5">
|
|
||||||
<FolderOpen className="h-4 w-4"/>
|
|
||||||
Browse
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="theme-mode">Mode</Label>
|
<Label htmlFor="theme-mode">Mode</Label>
|
||||||
<Select value={tempSettings.themeMode} onValueChange={(value: "auto" | "light" | "dark") => setTempSettings((prev) => ({ ...prev, themeMode: value }))}>
|
<Select value={tempSettings.themeMode} onValueChange={(value: "auto" | "light" | "dark") => setTempSettings((prev) => ({ ...prev, themeMode: value }))}>
|
||||||
@@ -201,21 +312,44 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="font">Font</Label>
|
<Label htmlFor="font">Font</Label>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Select value={tempSettings.fontFamily} onValueChange={(value: FontFamily) => setTempSettings((prev) => ({ ...prev, fontFamily: value }))}>
|
<Select value={tempSettings.fontFamily} onValueChange={(value: FontFamily) => setTempSettings((prev) => ({ ...prev, fontFamily: value }))}>
|
||||||
<SelectTrigger id="font">
|
<SelectTrigger id="font" className="max-w-full min-w-40">
|
||||||
<SelectValue placeholder="Select a font"/>
|
<SelectValue placeholder="Select a font"/>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{FONT_OPTIONS.map((font) => (<SelectItem key={font.value} value={font.value}>
|
{fontOptions.map((font) => {
|
||||||
|
const isCustomFont = font.value.startsWith("custom-");
|
||||||
|
return (<SelectItem key={font.value} value={font.value} indicatorPosition="inline" trailingAction={isCustomFont ? (<Button type="button" variant="ghost" size="icon" className="h-8 w-8 cursor-pointer text-muted-foreground hover:bg-transparent hover:text-destructive" aria-label={`Delete ${font.label}`} onPointerDown={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
}} onPointerUp={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
}} onClick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
void handleDeleteCustomFont(font.value as CustomFontFamily);
|
||||||
|
}}>
|
||||||
|
<Trash2 className="h-3.5 w-3.5 text-inherit"/>
|
||||||
|
</Button>) : undefined}>
|
||||||
<span style={{ fontFamily: font.fontFamily }}>
|
<span style={{ fontFamily: font.fontFamily }}>
|
||||||
{font.label}
|
{font.label}
|
||||||
</span>
|
</span>
|
||||||
</SelectItem>))}
|
</SelectItem>);
|
||||||
|
})}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
<Button type="button" variant="outline" onClick={() => setShowAddFontDialog(true)} className="shrink-0 gap-1.5">
|
||||||
|
<Plus className="h-4 w-4"/>
|
||||||
|
Add Font
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3 pt-2">
|
<div className="flex items-center gap-3 pt-2">
|
||||||
@@ -228,50 +362,27 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
|||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>)}
|
||||||
|
|
||||||
|
{activeTab === "download" && (<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="link-resolver">Link Resolver</Label>
|
<Label>Tidal Source</Label>
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<Select value={tempSettings.linkResolver} onValueChange={(value: "songstats" | "songlink") => setTempSettings((prev) => ({
|
<Button type="button" variant="outline" onClick={() => setShowCustomTidalApiDialog(true)} className="gap-2">
|
||||||
...prev,
|
<TidalIcon />
|
||||||
linkResolver: value,
|
Add Instance
|
||||||
}))}>
|
</Button>
|
||||||
<SelectTrigger id="link-resolver" className="h-9 w-fit min-w-[140px]">
|
{tempSettings.customTidalApi && (<span className="max-w-[260px] truncate text-xs text-muted-foreground" title={tempSettings.customTidalApi}>
|
||||||
<SelectValue placeholder="Select a link resolver"/>
|
{tempSettings.customTidalApi}
|
||||||
</SelectTrigger>
|
</span>)}
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="songlink">
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<SonglinkIcon className="h-4 w-4 shrink-0"/>
|
|
||||||
Songlink
|
|
||||||
</span>
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="songstats">
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<SongstatsIcon className="h-4 w-4 shrink-0"/>
|
|
||||||
Songstats
|
|
||||||
</span>
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Switch id="allow-link-resolver-fallback" checked={tempSettings.allowResolverFallback} onCheckedChange={(checked) => setTempSettings((prev) => ({
|
|
||||||
...prev,
|
|
||||||
allowResolverFallback: checked,
|
|
||||||
}))}/>
|
|
||||||
<Label htmlFor="allow-link-resolver-fallback" className="text-sm font-normal cursor-pointer">
|
|
||||||
Allow Fallback
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="downloader">Source</Label>
|
<Label htmlFor="downloader">Source</Label>
|
||||||
<div className="flex gap-2 flex-wrap">
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
<Select value={tempSettings.downloader} onValueChange={(value: any) => setTempSettings((prev) => ({
|
<Select value={effectiveDownloader} onValueChange={(value: SettingsType["downloader"]) => setTempSettings((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
downloader: value,
|
downloader: value,
|
||||||
}))}>
|
}))}>
|
||||||
@@ -280,12 +391,12 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="auto">Auto</SelectItem>
|
<SelectItem value="auto">Auto</SelectItem>
|
||||||
<SelectItem value="tidal">
|
{hasCustomTidalInstanceConfigured && (<SelectItem value="tidal">
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<TidalIcon />
|
<TidalIcon />
|
||||||
Tidal
|
Tidal
|
||||||
</span>
|
</span>
|
||||||
</SelectItem>
|
</SelectItem>)}
|
||||||
<SelectItem value="qobuz">
|
<SelectItem value="qobuz">
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<QobuzIcon />
|
<QobuzIcon />
|
||||||
@@ -298,20 +409,19 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
|||||||
Amazon Music
|
Amazon Music
|
||||||
</span>
|
</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
{tempSettings.downloader === "auto" && (<>
|
{effectiveDownloader === "auto" && (<>
|
||||||
<Select value={tempSettings.autoOrder || "tidal-qobuz-amazon"} onValueChange={(value: any) => setTempSettings((prev) => ({
|
<Select value={effectiveAutoOrder} onValueChange={(value: string) => setTempSettings((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
autoOrder: value,
|
autoOrder: value,
|
||||||
}))}>
|
}))}>
|
||||||
<SelectTrigger className="h-9 w-fit min-w-[140px]">
|
<SelectTrigger className="h-9 w-auto">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent className="w-fit min-w-max">
|
||||||
|
{hasCustomTidalInstanceConfigured && (<>
|
||||||
<SelectItem value="tidal-qobuz-amazon">
|
<SelectItem value="tidal-qobuz-amazon">
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
<TidalIcon className="fill-current"/>
|
<TidalIcon className="fill-current"/>
|
||||||
@@ -366,8 +476,6 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
|||||||
<TidalIcon className="fill-current"/>
|
<TidalIcon className="fill-current"/>
|
||||||
</span>
|
</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
|
||||||
|
|
||||||
<SelectItem value="tidal-qobuz">
|
<SelectItem value="tidal-qobuz">
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
<TidalIcon className="fill-current"/>
|
<TidalIcon className="fill-current"/>
|
||||||
@@ -389,13 +497,6 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
|||||||
<TidalIcon className="fill-current"/>
|
<TidalIcon className="fill-current"/>
|
||||||
</span>
|
</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="qobuz-amazon">
|
|
||||||
<span className="flex items-center gap-1.5">
|
|
||||||
<QobuzIcon className="fill-current"/>
|
|
||||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
|
||||||
<AmazonIcon className="fill-current"/>
|
|
||||||
</span>
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="amazon-tidal">
|
<SelectItem value="amazon-tidal">
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
<AmazonIcon className="fill-current"/>
|
<AmazonIcon className="fill-current"/>
|
||||||
@@ -403,6 +504,14 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
|||||||
<TidalIcon className="fill-current"/>
|
<TidalIcon className="fill-current"/>
|
||||||
</span>
|
</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
</>)}
|
||||||
|
<SelectItem value="qobuz-amazon">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<QobuzIcon className="fill-current"/>
|
||||||
|
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||||
|
<AmazonIcon className="fill-current"/>
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
<SelectItem value="amazon-qobuz">
|
<SelectItem value="amazon-qobuz">
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
<AmazonIcon className="fill-current"/>
|
<AmazonIcon className="fill-current"/>
|
||||||
@@ -424,19 +533,17 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
|||||||
</Select>
|
</Select>
|
||||||
</>)}
|
</>)}
|
||||||
|
|
||||||
{tempSettings.downloader === "tidal" && (<Select value={tempSettings.tidalQuality} onValueChange={handleTidalQualityChange}>
|
{effectiveDownloader === "tidal" && (<Select value={tempSettings.tidalQuality} onValueChange={handleTidalQualityChange}>
|
||||||
<SelectTrigger className="h-9 w-fit">
|
<SelectTrigger className="h-9 w-fit">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="LOSSLESS">16-bit/44.1kHz</SelectItem>
|
<SelectItem value="LOSSLESS">16-bit/44.1kHz</SelectItem>
|
||||||
<SelectItem value="HI_RES_LOSSLESS">
|
<SelectItem value="HI_RES_LOSSLESS">24-bit/48kHz</SelectItem>
|
||||||
24-bit/48kHz
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>)}
|
</Select>)}
|
||||||
|
|
||||||
{tempSettings.downloader === "qobuz" && (<Select value={tempSettings.qobuzQuality} onValueChange={handleQobuzQualityChange}>
|
{effectiveDownloader === "qobuz" && (<Select value={tempSettings.qobuzQuality} onValueChange={handleQobuzQualityChange}>
|
||||||
<SelectTrigger className="h-9 w-fit">
|
<SelectTrigger className="h-9 w-fit">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -446,19 +553,17 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>)}
|
</Select>)}
|
||||||
|
|
||||||
{tempSettings.downloader === "amazon" && (<div className="h-9 px-3 flex items-center text-sm font-medium border border-input rounded-md bg-muted/30 text-muted-foreground whitespace-nowrap cursor-default">
|
{effectiveDownloader === "amazon" && (<div className="h-9 px-3 flex items-center text-sm font-medium border border-input rounded-md bg-muted/30 text-muted-foreground whitespace-nowrap cursor-default">
|
||||||
16-bit - 24-bit/44.1kHz - 192kHz
|
16-bit - 24-bit/44.1kHz - 192kHz
|
||||||
</div>)}
|
</div>)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{((tempSettings.downloader === "tidal" &&
|
{((effectiveDownloader === "tidal" &&
|
||||||
tempSettings.tidalQuality === "HI_RES_LOSSLESS") ||
|
tempSettings.tidalQuality === "HI_RES_LOSSLESS") ||
|
||||||
(tempSettings.downloader === "qobuz" &&
|
(effectiveDownloader === "qobuz" &&
|
||||||
tempSettings.qobuzQuality === "27") ||
|
tempSettings.qobuzQuality === "27") ||
|
||||||
(tempSettings.downloader === "auto" &&
|
(effectiveDownloader === "auto" &&
|
||||||
tempSettings.autoQuality === "24")) && (<div className="flex items-center gap-3 pt-2">
|
tempSettings.autoQuality === "24")) && (<div className="flex items-center gap-3 pt-2">
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Switch id="allow-fallback" checked={tempSettings.allowFallback} onCheckedChange={(checked) => setTempSettings((prev) => ({
|
<Switch id="allow-fallback" checked={tempSettings.allowFallback} onCheckedChange={(checked) => setTempSettings((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
allowFallback: checked,
|
allowFallback: checked,
|
||||||
@@ -466,55 +571,67 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
|||||||
<Label htmlFor="allow-fallback" className="text-sm font-normal cursor-pointer">
|
<Label htmlFor="allow-fallback" className="text-sm font-normal cursor-pointer">
|
||||||
Allow Quality Fallback (16-bit)
|
Allow Quality Fallback (16-bit)
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
|
||||||
</div>)}
|
</div>)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div className="border-t pt-6"/>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="space-y-2">
|
||||||
<Switch id="embed-lyrics" checked={tempSettings.embedLyrics} onCheckedChange={(checked) => setTempSettings((prev) => ({
|
<Label htmlFor="link-resolver">Link Resolver</Label>
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<Select value={tempSettings.linkResolver} onValueChange={(value: "songstats" | "songlink") => setTempSettings((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
embedLyrics: checked,
|
linkResolver: value,
|
||||||
}))}/>
|
}))}>
|
||||||
<Label htmlFor="embed-lyrics" className="cursor-pointer text-sm font-normal">
|
<SelectTrigger id="link-resolver" className="h-9 w-fit min-w-35">
|
||||||
Embed Lyrics
|
<SelectValue placeholder="Select a link resolver"/>
|
||||||
</Label>
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="songlink">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<SonglinkIcon className="h-4 w-4 shrink-0"/>
|
||||||
|
Songlink
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="songstats">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<SongstatsIcon className="h-4 w-4 shrink-0"/>
|
||||||
|
Songstats
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Switch id="embed-max-quality-cover" checked={tempSettings.embedMaxQualityCover} onCheckedChange={(checked) => setTempSettings((prev) => ({
|
|
||||||
...prev,
|
|
||||||
embedMaxQualityCover: checked,
|
|
||||||
}))}/>
|
|
||||||
<Label htmlFor="embed-max-quality-cover" className="cursor-pointer text-sm font-normal">
|
|
||||||
Embed Max Quality Cover
|
|
||||||
</Label>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Switch id="embed-genre" checked={tempSettings.embedGenre} onCheckedChange={(checked) => setTempSettings((prev) => ({
|
<div className="flex items-center gap-3 pt-2">
|
||||||
|
<Switch id="allow-link-resolver-fallback" checked={tempSettings.allowResolverFallback} onCheckedChange={(checked) => setTempSettings((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
embedGenre: checked,
|
allowResolverFallback: checked,
|
||||||
}))}/>
|
}))}/>
|
||||||
<Label htmlFor="embed-genre" className="cursor-pointer text-sm font-normal">
|
<Label htmlFor="allow-link-resolver-fallback" className="text-sm font-normal cursor-pointer">
|
||||||
Embed Genre
|
Allow Resolver Fallback
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
{tempSettings.embedGenre && (<div className="flex items-center gap-3">
|
|
||||||
<Switch id="use-single-genre" checked={tempSettings.useSingleGenre} onCheckedChange={(checked) => setTempSettings((prev) => ({
|
|
||||||
...prev,
|
|
||||||
useSingleGenre: checked,
|
|
||||||
}))}/>
|
|
||||||
<Label htmlFor="use-single-genre" className="text-sm cursor-pointer font-normal">
|
|
||||||
Use Single Genre
|
|
||||||
</Label>
|
|
||||||
</div>)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>)}
|
</div>)}
|
||||||
|
|
||||||
{activeTab === "files" && (<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
{activeTab === "files" && (<div className="grid grid-cols-1 lg:grid-cols-2 lg:gap-8 items-start">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4 lg:pr-8 lg:border-r">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="download-path">Download Path</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<InputWithContext id="download-path" value={tempSettings.downloadPath} onChange={(e) => setTempSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
downloadPath: e.target.value,
|
||||||
|
}))} placeholder="C:\Users\YourUsername\Music"/>
|
||||||
|
<Button type="button" onClick={handleBrowseFolder} className="gap-1.5">
|
||||||
|
<FolderOpen className="h-4 w-4"/>
|
||||||
|
Browse
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Label className="text-sm">Folder Structure</Label>
|
<Label className="text-sm">Folder Structure</Label>
|
||||||
@@ -602,28 +719,23 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
|||||||
Create M3U8 Playlist File
|
Create M3U8 Playlist File
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Switch id="use-first-artist-only" checked={tempSettings.useFirstArtistOnly} onCheckedChange={(checked) => setTempSettings((prev) => ({
|
|
||||||
...prev,
|
|
||||||
useFirstArtistOnly: checked,
|
|
||||||
}))}/>
|
|
||||||
<Label htmlFor="use-first-artist-only" className="text-sm cursor-pointer font-normal">
|
|
||||||
Use First Artist Only
|
|
||||||
</Label>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="space-y-4 lg:pl-0">
|
||||||
<Switch id="redownload-with-suffix" checked={tempSettings.redownloadWithSuffix} onCheckedChange={(checked) => setTempSettings((prev) => ({
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="existing-file-check-mode">Existing File Check</Label>
|
||||||
|
<Select value={tempSettings.existingFileCheckMode} onValueChange={(value: ExistingFileCheckMode) => setTempSettings((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
redownloadWithSuffix: checked,
|
existingFileCheckMode: value,
|
||||||
}))}/>
|
}))}>
|
||||||
<Label htmlFor="redownload-with-suffix" className="text-sm cursor-pointer font-normal">
|
<SelectTrigger id="existing-file-check-mode">
|
||||||
Redownload With Suffix
|
<SelectValue placeholder="Select existing file check mode"/>
|
||||||
</Label>
|
</SelectTrigger>
|
||||||
</div>
|
<SelectContent>
|
||||||
|
<SelectItem value="filename">Filename</SelectItem>
|
||||||
|
<SelectItem value="isrc">ISRC</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -666,24 +778,6 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
|||||||
filenameTemplate: e.target.value,
|
filenameTemplate: e.target.value,
|
||||||
}))} placeholder="{track}. {title}" className="h-9 text-sm flex-1"/>)}
|
}))} placeholder="{track}. {title}" className="h-9 text-sm flex-1"/>)}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2 pt-2">
|
|
||||||
<Label className="text-sm">Separator</Label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Select value={tempSettings.separator} onValueChange={(value: "comma" | "semicolon") => setTempSettings((prev) => ({
|
|
||||||
...prev,
|
|
||||||
separator: value,
|
|
||||||
}))}>
|
|
||||||
<SelectTrigger className="h-9 w-fit">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="comma">Comma (,)</SelectItem>
|
|
||||||
<SelectItem value="semicolon">Semicolon (;)</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{tempSettings.filenameTemplate && (<p className="text-xs text-muted-foreground">
|
{tempSettings.filenameTemplate && (<p className="text-xs text-muted-foreground">
|
||||||
Preview:{" "}
|
Preview:{" "}
|
||||||
<span className="font-mono">
|
<span className="font-mono">
|
||||||
@@ -700,20 +794,201 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
|||||||
.flac
|
.flac
|
||||||
</span>
|
</span>
|
||||||
</p>)}
|
</p>)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm">Separator</Label>
|
||||||
|
<Select value={tempSettings.separator} onValueChange={(value: "comma" | "semicolon") => setTempSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
separator: value,
|
||||||
|
}))}>
|
||||||
|
<SelectTrigger className="h-9 w-fit">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="comma">Comma (,)</SelectItem>
|
||||||
|
<SelectItem value="semicolon">Semicolon (;)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Switch id="redownload-with-suffix" checked={tempSettings.redownloadWithSuffix} onCheckedChange={(checked) => setTempSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
redownloadWithSuffix: checked,
|
||||||
|
}))}/>
|
||||||
|
<Label htmlFor="redownload-with-suffix" className="text-sm cursor-pointer font-normal">
|
||||||
|
Redownload With Suffix
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>)}
|
</div>)}
|
||||||
|
|
||||||
{activeTab === "api" && (<ApiStatusTab />)}
|
{activeTab === "metadata" && (<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Switch id="embed-lyrics" checked={tempSettings.embedLyrics} onCheckedChange={(checked) => setTempSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
embedLyrics: checked,
|
||||||
|
}))}/>
|
||||||
|
<Label htmlFor="embed-lyrics" className="cursor-pointer text-sm font-normal">
|
||||||
|
Embed Lyrics
|
||||||
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Switch id="embed-max-quality-cover" checked={tempSettings.embedMaxQualityCover} onCheckedChange={(checked) => setTempSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
embedMaxQualityCover: checked,
|
||||||
|
}))}/>
|
||||||
|
<Label htmlFor="embed-max-quality-cover" className="cursor-pointer text-sm font-normal">
|
||||||
|
Embed Max Quality Cover
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Switch id="embed-genre" checked={tempSettings.embedGenre} onCheckedChange={(checked) => setTempSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
embedGenre: checked,
|
||||||
|
}))}/>
|
||||||
|
<Label htmlFor="embed-genre" className="cursor-pointer text-sm font-normal">
|
||||||
|
Embed Genre
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tempSettings.embedGenre && (<div className="flex items-center gap-3">
|
||||||
|
<Switch id="use-single-genre" checked={tempSettings.useSingleGenre} onCheckedChange={(checked) => setTempSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
useSingleGenre: checked,
|
||||||
|
}))}/>
|
||||||
|
<Label htmlFor="use-single-genre" className="text-sm cursor-pointer font-normal">
|
||||||
|
Use Single Genre
|
||||||
|
</Label>
|
||||||
|
</div>)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Switch id="use-first-artist-only" checked={tempSettings.useFirstArtistOnly} onCheckedChange={(checked) => setTempSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
useFirstArtistOnly: checked,
|
||||||
|
}))}/>
|
||||||
|
<Label htmlFor="use-first-artist-only" className="text-sm cursor-pointer font-normal">
|
||||||
|
Use First Artist Only
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>)}
|
||||||
|
|
||||||
|
{activeTab === "status" && (<ApiStatusTab />)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={showAddFontDialog} onOpenChange={(open) => open ? setShowAddFontDialog(true) : closeAddFontDialog()}>
|
||||||
|
<DialogContent className="sm:max-w-115 [&>button]:hidden">
|
||||||
|
<DialogHeader>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<DialogTitle>Add Font</DialogTitle>
|
||||||
|
<button type="button" onClick={() => openExternal("https://fonts.google.com")} className="inline-flex cursor-pointer items-center gap-1 text-xs text-muted-foreground hover:text-foreground hover:underline">
|
||||||
|
Open Google Fonts
|
||||||
|
<ExternalLink className="h-3 w-3"/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<DialogDescription />
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="google-font-url">Google Font URL</Label>
|
||||||
|
<Input id="google-font-url" value={addFontUrl} onChange={(event) => setAddFontUrl(event.target.value)} onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter" && parsedAddFont) {
|
||||||
|
void handleAddFont();
|
||||||
|
}
|
||||||
|
}} placeholder="https://fonts.google.com/specimen/Ubuntu" autoFocus/>
|
||||||
|
{addFontUrl.trim() && !parsedAddFont && (<p className="text-xs text-destructive">
|
||||||
|
Enter a valid Google Fonts URL.
|
||||||
|
</p>)}
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border bg-muted/20 p-4">
|
||||||
|
<p className="mb-2 text-xs font-medium text-muted-foreground">
|
||||||
|
Preview
|
||||||
|
</p>
|
||||||
|
<p className="text-2xl font-semibold leading-tight" style={{ fontFamily: parsedAddFont?.fontFamily }}>
|
||||||
|
Aa The quick brown fox
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground" style={{ fontFamily: parsedAddFont?.fontFamily }}>
|
||||||
|
Kendrick Lamar - All The Stars
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={closeAddFontDialog}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => void handleAddFont()} disabled={!parsedAddFont}>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog open={showCustomTidalApiDialog} onOpenChange={setShowCustomTidalApiDialog}>
|
||||||
|
<DialogContent className="sm:max-w-md [&>button]:hidden">
|
||||||
|
<DialogHeader>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<DialogTitle>Tidal Source</DialogTitle>
|
||||||
|
<button type="button" onClick={() => openExternal("https://github.com/binimum/hifi-api")} className="inline-flex cursor-pointer items-center gap-1 text-xs text-muted-foreground hover:text-foreground hover:underline">
|
||||||
|
How to create your own instance
|
||||||
|
<ExternalLink className="h-3 w-3"/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<DialogDescription />
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="custom-tidal-api">Instance URL</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input id="custom-tidal-api" type="url" value={tempSettings.customTidalApi || ""} onChange={(e) => {
|
||||||
|
const nextValue = e.target.value.replace(/\/+$/g, "");
|
||||||
|
setCustomTidalApiStatus("idle");
|
||||||
|
void persistCustomTidalApi(nextValue);
|
||||||
|
}} placeholder="https://your-hifi-api.example"/>
|
||||||
|
<Button type="button" variant="outline" className="gap-2" onClick={() => void handleCheckCustomTidalApi()} disabled={!((tempSettings.customTidalApi || "").trim().startsWith("https://")) || customTidalApiStatus === "checking"}>
|
||||||
|
{customTidalApiStatus === "checking" ? "Checking..." : <><PlugZap className="h-4 w-4"/>Check</>}
|
||||||
|
</Button>
|
||||||
|
{tempSettings.customTidalApi && (<Button type="button" variant="outline" size="icon" onClick={() => {
|
||||||
|
setCustomTidalApiStatus("idle");
|
||||||
|
void persistCustomTidalApi("");
|
||||||
|
}}>
|
||||||
|
<Trash2 className="h-4 w-4 text-destructive"/>
|
||||||
|
</Button>)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{customTidalApiStatus !== "idle" && (<p className={`text-xs ${customTidalApiStatus === "online"
|
||||||
|
? "text-green-600 dark:text-green-400"
|
||||||
|
: customTidalApiStatus === "offline"
|
||||||
|
? "text-destructive"
|
||||||
|
: "text-muted-foreground"}`}>
|
||||||
|
{customTidalApiStatus === "online"
|
||||||
|
? "Custom HiFi API instance is online."
|
||||||
|
: customTidalApiStatus === "offline"
|
||||||
|
? "Custom HiFi API instance is offline or returned preview-only data."
|
||||||
|
: "Checking custom HiFi API instance..."}
|
||||||
|
</p>)}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setShowCustomTidalApiDialog(false)}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
<Dialog open={showResetConfirm} onOpenChange={setShowResetConfirm}>
|
<Dialog open={showResetConfirm} onOpenChange={setShowResetConfirm}>
|
||||||
<DialogContent className="max-w-md [&>button]:hidden">
|
<DialogContent className="max-w-md [&>button]:hidden">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Reset to Default?</DialogTitle>
|
<DialogTitle>Reset to Default?</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
This will reset all settings to their default values. Your custom
|
This will reset all settings to their default values. Your custom
|
||||||
configurations will be lost.
|
font list will be kept.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
|
|||||||
@@ -6,18 +6,18 @@ import { ActivityIcon, type ActivityIconHandle } from "@/components/ui/activity"
|
|||||||
import { TerminalIcon } from "@/components/ui/terminal";
|
import { TerminalIcon } from "@/components/ui/terminal";
|
||||||
import { FileMusicIcon, type FileMusicIconHandle } from "@/components/ui/file-music";
|
import { FileMusicIcon, type FileMusicIconHandle } from "@/components/ui/file-music";
|
||||||
import { FilePenIcon, type FilePenIconHandle } from "@/components/ui/file-pen";
|
import { FilePenIcon, type FilePenIconHandle } from "@/components/ui/file-pen";
|
||||||
|
import { BugReportIcon } from "@/components/ui/bug-report-icon";
|
||||||
import { CoffeeIcon } from "@/components/ui/coffee";
|
import { CoffeeIcon } from "@/components/ui/coffee";
|
||||||
import { BadgeAlertIcon } from "@/components/ui/badge-alert";
|
|
||||||
import { GithubIcon } from "@/components/ui/github";
|
|
||||||
import { BlocksIcon } from "@/components/ui/blocks-icon";
|
import { BlocksIcon } from "@/components/ui/blocks-icon";
|
||||||
import { AudioLinesIcon, type AudioLinesIconHandle } from "@/components/ui/audio-lines";
|
import { AudioLinesIcon, type AudioLinesIconHandle } from "@/components/ui/audio-lines";
|
||||||
|
import { ToolCaseIcon } from "@/components/ui/tool-case";
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { openExternal } from "@/lib/utils";
|
import { openExternal } from "@/lib/utils";
|
||||||
export type PageType = "main" | "settings" | "debug" | "audio-analysis" | "audio-converter" | "audio-resampler" | "file-manager" | "about" | "history";
|
export type PageType = "main" | "settings" | "debug" | "audio-analysis" | "audio-converter" | "audio-resampler" | "file-manager" | "projects" | "support" | "history";
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
currentPage: PageType;
|
currentPage: PageType;
|
||||||
onPageChange: (page: PageType) => void;
|
onPageChange: (page: PageType) => void;
|
||||||
@@ -100,7 +100,7 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
|||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button variant={["audio-analysis", "audio-converter", "audio-resampler", "file-manager"].includes(currentPage) ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${["audio-analysis", "audio-converter", "audio-resampler", "file-manager"].includes(currentPage) ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`}>
|
<Button variant={["audio-analysis", "audio-converter", "audio-resampler", "file-manager"].includes(currentPage) ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${["audio-analysis", "audio-converter", "audio-resampler", "file-manager"].includes(currentPage) ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`}>
|
||||||
<BlocksIcon size={20} loop={true}/>
|
<ToolCaseIcon size={20}/>
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
@@ -134,7 +134,7 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
|||||||
<Tooltip delayDuration={0}>
|
<Tooltip delayDuration={0}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button variant="ghost" size="icon" className="h-10 w-10 hover:bg-primary/10 hover:text-primary" onClick={() => setIsIssuesDialogOpen(true)}>
|
<Button variant="ghost" size="icon" className="h-10 w-10 hover:bg-primary/10 hover:text-primary" onClick={() => setIsIssuesDialogOpen(true)}>
|
||||||
<GithubIcon size={20}/>
|
<BugReportIcon size={20} loop={true}/>
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="right">
|
<TooltipContent side="right">
|
||||||
@@ -176,23 +176,23 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
|||||||
|
|
||||||
<Tooltip delayDuration={0}>
|
<Tooltip delayDuration={0}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button variant={currentPage === "about" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "about" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("about")}>
|
<Button variant={currentPage === "projects" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "projects" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("projects")}>
|
||||||
<BadgeAlertIcon size={20}/>
|
<BlocksIcon size={20} loop={true}/>
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="right">
|
<TooltipContent side="right">
|
||||||
<p>About</p>
|
<p>Other Projects</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip delayDuration={0}>
|
<Tooltip delayDuration={0}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button variant="ghost" size="icon" className="h-10 w-10 hover:bg-primary/10 hover:text-primary" onClick={() => openExternal("https://ko-fi.com/afkarxyz")}>
|
<Button variant={currentPage === "support" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "support" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("support")}>
|
||||||
<CoffeeIcon size={20} loop={true}/>
|
<CoffeeIcon size={20} loop={true}/>
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="right">
|
<TooltipContent side="right">
|
||||||
<p>Support me on Ko-fi</p>
|
<p>Support Me</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { CircleCheck, Copy } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { openExternal } from "@/lib/utils";
|
||||||
|
import KofiLogo from "@/assets/ko-fi.gif";
|
||||||
|
import KofiSvg from "@/assets/kofi_symbol.svg";
|
||||||
|
import PatreonLogo from "@/assets/patreon.svg";
|
||||||
|
import PatreonSymbol from "@/assets/patreon_symbol.svg";
|
||||||
|
import UsdtBarcode from "@/assets/usdt.jpg";
|
||||||
|
|
||||||
|
export function SupportPage() {
|
||||||
|
const [copiedUsdt, setCopiedUsdt] = useState(false);
|
||||||
|
const [copiedEmail, setCopiedEmail] = useState(false);
|
||||||
|
return (<div className="flex flex-col space-y-3">
|
||||||
|
<div className="flex items-center justify-between shrink-0">
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight">Support Me</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center justify-center p-4">
|
||||||
|
<div className="grid w-full max-w-5xl overflow-hidden rounded-xl border bg-card shadow-sm md:grid-cols-3">
|
||||||
|
<div className="flex min-h-[22rem] flex-col items-center justify-between space-y-6 border-b p-6 md:border-b-0 md:border-r">
|
||||||
|
<div className="flex flex-col items-center space-y-4">
|
||||||
|
<div className="h-32 flex items-center justify-center w-full relative">
|
||||||
|
<img src={KofiLogo} className="w-72 absolute pointer-events-none" alt="Ko-fi"/>
|
||||||
|
</div>
|
||||||
|
<h4 className="font-semibold text-foreground">Support via Ko-fi</h4>
|
||||||
|
<p className="text-sm text-muted-foreground text-center px-4">
|
||||||
|
Buy me a coffee to help keep development going.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button className="h-10 w-full gap-2 bg-[#72a4f2] text-sm font-semibold text-white hover:bg-[#5f8cd6]" onClick={() => openExternal("https://ko-fi.com/afkarxyz")}>
|
||||||
|
<img src={KofiSvg} className="h-6 w-6 shrink-0" alt="" aria-hidden="true"/>
|
||||||
|
Support me on Ko-fi
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex min-h-[22rem] flex-col items-center justify-between space-y-6 border-b p-6 md:border-b-0 md:border-r">
|
||||||
|
<div className="flex flex-col items-center space-y-4 w-full">
|
||||||
|
<div className="h-32 flex items-center justify-center w-full px-4">
|
||||||
|
<img src={PatreonLogo} className="w-56 max-w-full brightness-0 dark:brightness-100" alt="Patreon"/>
|
||||||
|
</div>
|
||||||
|
<h4 className="font-semibold text-foreground">Support via Patreon</h4>
|
||||||
|
<p className="text-sm text-muted-foreground text-center px-4">
|
||||||
|
Join on Patreon to help fund the project and follow updates.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button className="h-10 w-full gap-2 bg-[#ff424d] text-sm font-semibold text-white hover:bg-[#e63945]" onClick={() => openExternal("https://www.patreon.com/cw/afkarxyz")}>
|
||||||
|
<img src={PatreonSymbol} className="h-5 w-5 shrink-0" alt="" aria-hidden="true"/>
|
||||||
|
Support me on Patreon
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex min-h-[22rem] flex-col items-center justify-between space-y-6 p-6">
|
||||||
|
<div className="flex flex-col items-center space-y-4 w-full">
|
||||||
|
<div className="h-32 flex items-center justify-center">
|
||||||
|
<div className="rounded-xl border bg-white p-2 shadow-sm">
|
||||||
|
<img src={UsdtBarcode} className="h-24 w-24 object-contain" alt="USDT Barcode"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h4 className="font-semibold text-foreground">USDT (TRC20)</h4>
|
||||||
|
<p className="text-sm text-muted-foreground text-center px-4">
|
||||||
|
Prefer crypto? Use the QR code or wallet address below.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex h-10 w-full items-center justify-between gap-2 rounded-lg border bg-muted/50 py-1.5 pl-3 pr-1.5">
|
||||||
|
<code className="truncate text-xs font-mono text-muted-foreground" title="THnzAAwZgp2Sq5CAXLP2njQDhTvgZG9EWs">
|
||||||
|
THnzAAwZgp2Sq5CAXLP2njQDhTvgZG9EWs
|
||||||
|
</code>
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0 hover:bg-background" onClick={() => {
|
||||||
|
navigator.clipboard.writeText("THnzAAwZgp2Sq5CAXLP2njQDhTvgZG9EWs");
|
||||||
|
setCopiedUsdt(true);
|
||||||
|
setTimeout(() => setCopiedUsdt(false), 500);
|
||||||
|
}}>
|
||||||
|
{copiedUsdt ? <CircleCheck className="h-3.5 w-3.5 text-green-500"/> : <Copy className="h-3.5 w-3.5"/>}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 w-full max-w-5xl rounded-xl border bg-muted/30 px-4 py-3 text-center text-sm text-muted-foreground">
|
||||||
|
If you have any questions or need help with donating, feel free to reach out via{" "}
|
||||||
|
<button type="button" className="font-medium text-foreground underline-offset-4 hover:underline" onClick={() => openExternal("https://t.me/afkarxyz")}>
|
||||||
|
Telegram
|
||||||
|
</button>{" "}
|
||||||
|
or{" "}
|
||||||
|
<button type="button" className="font-medium text-foreground underline-offset-4 hover:underline" onClick={() => {
|
||||||
|
navigator.clipboard.writeText("hi@afkarxyz.fyi");
|
||||||
|
setCopiedEmail(true);
|
||||||
|
setTimeout(() => setCopiedEmail(false), 500);
|
||||||
|
}}>
|
||||||
|
{copiedEmail ? "copied" : "hi@afkarxyz.fyi"}
|
||||||
|
</button>
|
||||||
|
.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>);
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import { X, Minus, Maximize, SlidersHorizontal, Globe, Eye, EyeOff } from "lucide-react";
|
import { X, Minus, Maximize, SlidersHorizontal, Globe, Eye, EyeOff } from "lucide-react";
|
||||||
import { WindowMinimise, WindowToggleMaximise, Quit } from "../../wailsjs/runtime/runtime";
|
import { WindowMinimise, WindowToggleMaximise, Quit } from "../../wailsjs/runtime/runtime";
|
||||||
import { Menubar, MenubarContent, MenubarMenu, MenubarItem, MenubarTrigger, MenubarLabel, MenubarSeparator } from "@/components/ui/menubar";
|
import { Menubar, MenubarContent, MenubarMenu, MenubarItem, MenubarTrigger, MenubarLabel, MenubarSeparator } from "@/components/ui/menubar";
|
||||||
|
import { Slider } from "@/components/ui/slider";
|
||||||
|
import { getSettings, updateSettings } from "@/lib/settings";
|
||||||
|
import { PREVIEW_VOLUME_CHANGED_EVENT } from "@/lib/preview";
|
||||||
import { fetchCurrentIPInfo } from "@/lib/api";
|
import { fetchCurrentIPInfo } from "@/lib/api";
|
||||||
import type { CurrentIPInfo } from "@/types/api";
|
import type { CurrentIPInfo } from "@/types/api";
|
||||||
import { openExternal } from "@/lib/utils";
|
import { openExternal } from "@/lib/utils";
|
||||||
@@ -24,7 +27,12 @@ const SPOTIFY_BLOCKED_COUNTRY_CODES = new Set([
|
|||||||
"TM",
|
"TM",
|
||||||
"YE",
|
"YE",
|
||||||
]);
|
]);
|
||||||
|
interface SettingsUpdatedDetail {
|
||||||
|
previewVolume?: number;
|
||||||
|
}
|
||||||
export function TitleBar() {
|
export function TitleBar() {
|
||||||
|
const initialSettings = getSettings();
|
||||||
|
const [previewVolume, setPreviewVolume] = useState(initialSettings.previewVolume ?? 100);
|
||||||
const [currentIPInfo, setCurrentIPInfo] = useState<CurrentIPInfo | null>(null);
|
const [currentIPInfo, setCurrentIPInfo] = useState<CurrentIPInfo | null>(null);
|
||||||
const [isLoadingCurrentIPInfo, setIsLoadingCurrentIPInfo] = useState(false);
|
const [isLoadingCurrentIPInfo, setIsLoadingCurrentIPInfo] = useState(false);
|
||||||
const [currentIPInfoError, setCurrentIPInfoError] = useState("");
|
const [currentIPInfoError, setCurrentIPInfoError] = useState("");
|
||||||
@@ -33,6 +41,16 @@ export function TitleBar() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
currentIPInfoRef.current = currentIPInfo;
|
currentIPInfoRef.current = currentIPInfo;
|
||||||
}, [currentIPInfo]);
|
}, [currentIPInfo]);
|
||||||
|
useEffect(() => {
|
||||||
|
const handleSettingsUpdate = (event: Event) => {
|
||||||
|
const updatedSettings = (event as CustomEvent<SettingsUpdatedDetail>).detail;
|
||||||
|
if (updatedSettings && typeof updatedSettings.previewVolume === "number") {
|
||||||
|
setPreviewVolume(updatedSettings.previewVolume);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("settingsUpdated", handleSettingsUpdate);
|
||||||
|
return () => window.removeEventListener("settingsUpdated", handleSettingsUpdate);
|
||||||
|
}, []);
|
||||||
const loadCurrentIPInfo = async (options?: {
|
const loadCurrentIPInfo = async (options?: {
|
||||||
silent?: boolean;
|
silent?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
@@ -88,6 +106,22 @@ export function TitleBar() {
|
|||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
Quit();
|
Quit();
|
||||||
};
|
};
|
||||||
|
const handlePreviewVolumeChange = (value: number[]) => {
|
||||||
|
const nextValue = value[0];
|
||||||
|
if (typeof nextValue !== "number" || Number.isNaN(nextValue)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPreviewVolume(nextValue);
|
||||||
|
window.dispatchEvent(new CustomEvent(PREVIEW_VOLUME_CHANGED_EVENT, { detail: nextValue }));
|
||||||
|
};
|
||||||
|
const handlePreviewVolumeCommit = (value: number[]) => {
|
||||||
|
const nextValue = value[0];
|
||||||
|
if (typeof nextValue !== "number" || Number.isNaN(nextValue)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPreviewVolume(nextValue);
|
||||||
|
void updateSettings({ previewVolume: nextValue });
|
||||||
|
};
|
||||||
const detectedCountryCode = currentIPInfo?.country_code?.toUpperCase() || "";
|
const detectedCountryCode = currentIPInfo?.country_code?.toUpperCase() || "";
|
||||||
const detectedFlagPath = detectedCountryCode ? `/assets/flags/${detectedCountryCode.toLowerCase()}.svg` : "";
|
const detectedFlagPath = detectedCountryCode ? `/assets/flags/${detectedCountryCode.toLowerCase()}.svg` : "";
|
||||||
const isSpotifyBlockedCountry = detectedCountryCode !== "" && SPOTIFY_BLOCKED_COUNTRY_CODES.has(detectedCountryCode);
|
const isSpotifyBlockedCountry = detectedCountryCode !== "" && SPOTIFY_BLOCKED_COUNTRY_CODES.has(detectedCountryCode);
|
||||||
@@ -102,7 +136,17 @@ export function TitleBar() {
|
|||||||
<MenubarTrigger className="cursor-pointer w-8 h-7 p-0 flex items-center justify-center hover:bg-muted transition-colors rounded data-[state=open]:bg-muted">
|
<MenubarTrigger className="cursor-pointer w-8 h-7 p-0 flex items-center justify-center hover:bg-muted transition-colors rounded data-[state=open]:bg-muted">
|
||||||
<SlidersHorizontal className="w-3.5 h-3.5"/>
|
<SlidersHorizontal className="w-3.5 h-3.5"/>
|
||||||
</MenubarTrigger>
|
</MenubarTrigger>
|
||||||
<MenubarContent align="end" className="min-w-[280px]">
|
<MenubarContent align="end" className="min-w-70">
|
||||||
|
<div className="px-2 py-1.5 space-y-2">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<MenubarLabel className="p-0">Preview Volume</MenubarLabel>
|
||||||
|
<span className="text-xs font-medium text-muted-foreground tabular-nums">
|
||||||
|
{previewVolume}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Slider value={[previewVolume]} min={0} max={100} step={5} onValueChange={handlePreviewVolumeChange} onValueCommit={handlePreviewVolumeCommit} aria-label="Preview volume"/>
|
||||||
|
</div>
|
||||||
|
<MenubarSeparator />
|
||||||
<div className="flex items-center gap-1.5 px-2 py-1.5">
|
<div className="flex items-center gap-1.5 px-2 py-1.5">
|
||||||
<MenubarLabel className="p-0">Network</MenubarLabel>
|
<MenubarLabel className="p-0">Network</MenubarLabel>
|
||||||
{isSpotifyBlockedCountry && (<span className="text-xs font-medium text-destructive">
|
{isSpotifyBlockedCountry && (<span className="text-xs font-medium text-destructive">
|
||||||
@@ -112,7 +156,7 @@ export function TitleBar() {
|
|||||||
<div className="px-2 py-1.5 space-y-1">
|
<div className="px-2 py-1.5 space-y-1">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
{detectedFlagPath ? (<img src={detectedFlagPath} alt={detectedCountryCode} className="h-3.5 w-[18px] rounded-[2px] border object-cover bg-muted"/>) : (<Globe className="w-4 h-4 opacity-70"/>)}
|
{detectedFlagPath ? (<img src={detectedFlagPath} alt={detectedCountryCode} className="h-3.5 w-4.5 rounded-[2px] border object-cover bg-muted"/>) : (<Globe className="w-4 h-4 opacity-70"/>)}
|
||||||
<span className="font-mono text-xs truncate">
|
<span className="font-mono text-xs truncate">
|
||||||
{isLoadingCurrentIPInfo
|
{isLoadingCurrentIPInfo
|
||||||
? "Detecting..."
|
? "Detecting..."
|
||||||
@@ -132,7 +176,7 @@ export function TitleBar() {
|
|||||||
</div>)}
|
</div>)}
|
||||||
</div>
|
</div>
|
||||||
<MenubarSeparator />
|
<MenubarSeparator />
|
||||||
<MenubarItem onClick={() => openExternal("https://afkarxyz.qzz.io")} className="gap-2">
|
<MenubarItem onClick={() => openExternal("https://afkarxyz.fyi")} className="gap-2">
|
||||||
<Globe className="w-4 h-4 opacity-70"/>
|
<Globe className="w-4 h-4 opacity-70"/>
|
||||||
<span>Website</span>
|
<span>Website</span>
|
||||||
</MenubarItem>
|
</MenubarItem>
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
"use client";
|
|
||||||
import type { Variants } from "motion/react";
|
|
||||||
import { motion, useAnimation } from "motion/react";
|
|
||||||
import type { HTMLAttributes } from "react";
|
|
||||||
import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
export interface BadgeAlertIconHandle {
|
|
||||||
startAnimation: () => void;
|
|
||||||
stopAnimation: () => void;
|
|
||||||
}
|
|
||||||
interface BadgeAlertIconProps extends HTMLAttributes<HTMLDivElement> {
|
|
||||||
size?: number;
|
|
||||||
}
|
|
||||||
const ICON_VARIANTS: Variants = {
|
|
||||||
normal: { scale: 1, rotate: 0 },
|
|
||||||
animate: {
|
|
||||||
scale: [1, 1.1, 1.1, 1.1, 1],
|
|
||||||
rotate: [0, -3, 3, -2, 2, 0],
|
|
||||||
transition: {
|
|
||||||
duration: 0.5,
|
|
||||||
times: [0, 0.2, 0.4, 0.6, 1],
|
|
||||||
ease: "easeInOut",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const BadgeAlertIcon = forwardRef<BadgeAlertIconHandle, BadgeAlertIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
|
|
||||||
const controls = useAnimation();
|
|
||||||
const isControlledRef = useRef(false);
|
|
||||||
useImperativeHandle(ref, () => {
|
|
||||||
isControlledRef.current = true;
|
|
||||||
return {
|
|
||||||
startAnimation: () => controls.start("animate"),
|
|
||||||
stopAnimation: () => controls.start("normal"),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
const handleMouseEnter = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
|
||||||
if (isControlledRef.current) {
|
|
||||||
onMouseEnter?.(e);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
controls.start("animate");
|
|
||||||
}
|
|
||||||
}, [controls, onMouseEnter]);
|
|
||||||
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
|
||||||
if (isControlledRef.current) {
|
|
||||||
onMouseLeave?.(e);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
controls.start("normal");
|
|
||||||
}
|
|
||||||
}, [controls, onMouseLeave]);
|
|
||||||
return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
|
|
||||||
<motion.svg animate={controls} fill="none" height={size} stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" variants={ICON_VARIANTS} viewBox="0 0 24 24" width={size} xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z"/>
|
|
||||||
<line x1="12" x2="12" y1="8" y2="12"/>
|
|
||||||
<line x1="12" x2="12.01" y1="16" y2="16"/>
|
|
||||||
</motion.svg>
|
|
||||||
</div>);
|
|
||||||
});
|
|
||||||
BadgeAlertIcon.displayName = "BadgeAlertIcon";
|
|
||||||
export { BadgeAlertIcon };
|
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { Transition, Variants } from "motion/react";
|
||||||
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
|
import { useEffect, useState, type HTMLAttributes } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type ReportIconMode = "bug" | "bulb";
|
||||||
|
|
||||||
|
interface BugReportIconProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
size?: number;
|
||||||
|
loop?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LOOP_INTERVAL_MS = 2200;
|
||||||
|
|
||||||
|
const GROUP_VARIANTS: Variants = {
|
||||||
|
hidden: {
|
||||||
|
opacity: 0,
|
||||||
|
},
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
transition: {
|
||||||
|
duration: 0.2,
|
||||||
|
ease: [0, 0, 0.2, 1],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
exit: {
|
||||||
|
opacity: 0,
|
||||||
|
transition: {
|
||||||
|
duration: 0.18,
|
||||||
|
ease: [0.4, 0, 1, 1],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const DRAW_VARIANTS: Variants = {
|
||||||
|
hidden: {
|
||||||
|
pathLength: 0,
|
||||||
|
opacity: 0,
|
||||||
|
},
|
||||||
|
visible: {
|
||||||
|
pathLength: 1,
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
exit: {
|
||||||
|
pathLength: 1,
|
||||||
|
opacity: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function createDrawTransition(delay = 0, duration = 0.36): Transition {
|
||||||
|
return {
|
||||||
|
duration,
|
||||||
|
delay,
|
||||||
|
ease: [0.4, 0, 0.2, 1],
|
||||||
|
opacity: { delay },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function BugPaths() {
|
||||||
|
return (<>
|
||||||
|
<motion.path d="m8 2 1.88 1.88" transition={createDrawTransition(0)} variants={DRAW_VARIANTS}/>
|
||||||
|
<motion.path d="M14.12 3.88 16 2" transition={createDrawTransition(0.04)} variants={DRAW_VARIANTS}/>
|
||||||
|
<motion.path d="M9 7.13V6a3 3 0 1 1 6 0v1.13" transition={createDrawTransition(0.08)} variants={DRAW_VARIANTS}/>
|
||||||
|
<motion.path d="M6.53 9A4 4 0 0 1 3 5" transition={createDrawTransition(0.14)} variants={DRAW_VARIANTS}/>
|
||||||
|
<motion.path d="M17.47 9A4 4 0 0 0 21 5" transition={createDrawTransition(0.18)} variants={DRAW_VARIANTS}/>
|
||||||
|
<motion.path d="M12 20v-9" transition={createDrawTransition(0.24)} variants={DRAW_VARIANTS}/>
|
||||||
|
<motion.path d="M14 7a4 4 0 0 1 4 4v3a6 6 0 0 1-12 0v-3a4 4 0 0 1 4-4z" transition={createDrawTransition(0.3, 0.42)} variants={DRAW_VARIANTS}/>
|
||||||
|
<motion.path d="M22 13h-4" transition={createDrawTransition(0.42)} variants={DRAW_VARIANTS}/>
|
||||||
|
<motion.path d="M6 13H2" transition={createDrawTransition(0.46)} variants={DRAW_VARIANTS}/>
|
||||||
|
<motion.path d="M21 21a4 4 0 0 0-3.81-4" transition={createDrawTransition(0.52)} variants={DRAW_VARIANTS}/>
|
||||||
|
<motion.path d="M3 21a4 4 0 0 1 3.81-4" transition={createDrawTransition(0.56)} variants={DRAW_VARIANTS}/>
|
||||||
|
</>);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BulbPaths() {
|
||||||
|
return (<>
|
||||||
|
<motion.path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5" transition={createDrawTransition(0, 0.46)} variants={DRAW_VARIANTS}/>
|
||||||
|
<motion.path d="M9 18h6" transition={createDrawTransition(0.16)} variants={DRAW_VARIANTS}/>
|
||||||
|
<motion.path d="M10 22h4" transition={createDrawTransition(0.24)} variants={DRAW_VARIANTS}/>
|
||||||
|
</>);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReportIconGroup({ mode }: { mode: ReportIconMode }) {
|
||||||
|
return (<motion.g animate="visible" exit="exit" initial="hidden" variants={GROUP_VARIANTS}>
|
||||||
|
{mode === "bug" ? <BugPaths/> : <BulbPaths/>}
|
||||||
|
</motion.g>);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StaticBugIcon() {
|
||||||
|
return (<g>
|
||||||
|
<path d="m8 2 1.88 1.88"/>
|
||||||
|
<path d="M14.12 3.88 16 2"/>
|
||||||
|
<path d="M9 7.13V6a3 3 0 1 1 6 0v1.13"/>
|
||||||
|
<path d="M6.53 9A4 4 0 0 1 3 5"/>
|
||||||
|
<path d="M17.47 9A4 4 0 0 0 21 5"/>
|
||||||
|
<path d="M12 20v-9"/>
|
||||||
|
<path d="M14 7a4 4 0 0 1 4 4v3a6 6 0 0 1-12 0v-3a4 4 0 0 1 4-4z"/>
|
||||||
|
<path d="M22 13h-4"/>
|
||||||
|
<path d="M6 13H2"/>
|
||||||
|
<path d="M21 21a4 4 0 0 0-3.81-4"/>
|
||||||
|
<path d="M3 21a4 4 0 0 1 3.81-4"/>
|
||||||
|
</g>);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BugReportIcon({ className, size = 28, loop = false, ...props }: BugReportIconProps) {
|
||||||
|
const [mode, setMode] = useState<ReportIconMode>("bug");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loop) {
|
||||||
|
setMode("bug");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const intervalId = window.setInterval(() => {
|
||||||
|
setMode((currentMode) => currentMode === "bug" ? "bulb" : "bug");
|
||||||
|
}, LOOP_INTERVAL_MS);
|
||||||
|
|
||||||
|
return () => window.clearInterval(intervalId);
|
||||||
|
}, [loop]);
|
||||||
|
|
||||||
|
return (<div className={cn("flex items-center justify-center", className)} {...props}>
|
||||||
|
<svg fill="none" height={size} stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" viewBox="0 0 24 24" width={size} xmlns="http://www.w3.org/2000/svg">
|
||||||
|
{loop ? (<AnimatePresence>
|
||||||
|
<ReportIconGroup key={mode} mode={mode}/>
|
||||||
|
</AnimatePresence>) : (<StaticBugIcon/>)}
|
||||||
|
</svg>
|
||||||
|
</div>);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { BugReportIcon };
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
"use client";
|
|
||||||
import type { Variants } from "motion/react";
|
|
||||||
import { motion, useAnimation } from "motion/react";
|
|
||||||
import type { HTMLAttributes } from "react";
|
|
||||||
import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
export interface GithubIconHandle {
|
|
||||||
startAnimation: () => void;
|
|
||||||
stopAnimation: () => void;
|
|
||||||
}
|
|
||||||
interface GithubIconProps extends HTMLAttributes<HTMLDivElement> {
|
|
||||||
size?: number;
|
|
||||||
}
|
|
||||||
const BODY_VARIANTS: Variants = {
|
|
||||||
normal: {
|
|
||||||
opacity: 1,
|
|
||||||
pathLength: 1,
|
|
||||||
scale: 1,
|
|
||||||
transition: {
|
|
||||||
duration: 0.3,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
animate: {
|
|
||||||
opacity: [0, 1],
|
|
||||||
pathLength: [0, 1],
|
|
||||||
scale: [0.9, 1],
|
|
||||||
transition: {
|
|
||||||
duration: 0.4,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const TAIL_VARIANTS: Variants = {
|
|
||||||
normal: {
|
|
||||||
pathLength: 1,
|
|
||||||
rotate: 0,
|
|
||||||
transition: {
|
|
||||||
duration: 0.3,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
draw: {
|
|
||||||
pathLength: [0, 1],
|
|
||||||
rotate: 0,
|
|
||||||
transition: {
|
|
||||||
duration: 0.5,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
wag: {
|
|
||||||
pathLength: 1,
|
|
||||||
rotate: [0, -15, 15, -10, 10, -5, 5],
|
|
||||||
transition: {
|
|
||||||
duration: 2.5,
|
|
||||||
ease: "easeInOut",
|
|
||||||
repeat: Number.POSITIVE_INFINITY,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const GithubIcon = forwardRef<GithubIconHandle, GithubIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
|
|
||||||
const bodyControls = useAnimation();
|
|
||||||
const tailControls = useAnimation();
|
|
||||||
const isControlledRef = useRef(false);
|
|
||||||
useImperativeHandle(ref, () => {
|
|
||||||
isControlledRef.current = true;
|
|
||||||
return {
|
|
||||||
startAnimation: async () => {
|
|
||||||
bodyControls.start("animate");
|
|
||||||
await tailControls.start("draw");
|
|
||||||
tailControls.start("wag");
|
|
||||||
},
|
|
||||||
stopAnimation: () => {
|
|
||||||
bodyControls.start("normal");
|
|
||||||
tailControls.start("normal");
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
const handleMouseEnter = useCallback(async (e: React.MouseEvent<HTMLDivElement>) => {
|
|
||||||
if (isControlledRef.current) {
|
|
||||||
onMouseEnter?.(e);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
bodyControls.start("animate");
|
|
||||||
await tailControls.start("draw");
|
|
||||||
tailControls.start("wag");
|
|
||||||
}
|
|
||||||
}, [bodyControls, onMouseEnter, tailControls]);
|
|
||||||
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
|
||||||
if (isControlledRef.current) {
|
|
||||||
onMouseLeave?.(e);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
bodyControls.start("normal");
|
|
||||||
tailControls.start("normal");
|
|
||||||
}
|
|
||||||
}, [bodyControls, tailControls, onMouseLeave]);
|
|
||||||
return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
|
|
||||||
<svg fill="none" height={size} stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" viewBox="0 0 24 24" width={size} xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<motion.path animate={bodyControls} d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4" initial="normal" variants={BODY_VARIANTS}/>
|
|
||||||
<motion.path animate={tailControls} d="M9 18c-4.51 2-5-2-7-2" initial="normal" variants={TAIL_VARIANTS}/>
|
|
||||||
</svg>
|
|
||||||
</div>);
|
|
||||||
});
|
|
||||||
GithubIcon.displayName = "GithubIcon";
|
|
||||||
export { GithubIcon };
|
|
||||||
@@ -37,14 +37,24 @@ function SelectContent({ className, children, position = "popper", align = "cent
|
|||||||
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
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}/>);
|
return (<SelectPrimitive.Label data-slot="select-label" className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)} {...props}/>);
|
||||||
}
|
}
|
||||||
function SelectItem({ className, children, ...props }: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
function SelectItem({ className, children, indicatorPosition = "right", trailingAction, ...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}>
|
indicatorPosition?: "right" | "inline";
|
||||||
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
trailingAction?: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
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 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", indicatorPosition === "right" ? "pr-8" : "pr-2", trailingAction ? "pr-10" : undefined, className)} {...props}>
|
||||||
|
<span className="flex min-w-0 items-center gap-2">
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
{indicatorPosition === "inline" && (<SelectPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="size-4"/>
|
||||||
|
</SelectPrimitive.ItemIndicator>)}
|
||||||
|
</span>
|
||||||
|
{trailingAction ? (<span className="absolute right-2 flex items-center justify-center">
|
||||||
|
{trailingAction}
|
||||||
|
</span>) : indicatorPosition === "right" ? (<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||||
<SelectPrimitive.ItemIndicator>
|
<SelectPrimitive.ItemIndicator>
|
||||||
<CheckIcon className="size-4"/>
|
<CheckIcon className="size-4"/>
|
||||||
</SelectPrimitive.ItemIndicator>
|
</SelectPrimitive.ItemIndicator>
|
||||||
</span>
|
</span>) : null}
|
||||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
|
||||||
</SelectPrimitive.Item>);
|
</SelectPrimitive.Item>);
|
||||||
}
|
}
|
||||||
function SelectSeparator({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
function SelectSeparator({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import * as SliderPrimitive from "@radix-ui/react-slider";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
function Slider({ className, defaultValue, value, min = 0, max = 100, ...props }: React.ComponentProps<typeof SliderPrimitive.Root>) {
|
||||||
|
const values = Array.isArray(value)
|
||||||
|
? value
|
||||||
|
: Array.isArray(defaultValue)
|
||||||
|
? defaultValue
|
||||||
|
: [min];
|
||||||
|
return (<SliderPrimitive.Root data-slot="slider" defaultValue={defaultValue} value={value} min={min} max={max} className={cn("relative flex w-full touch-none select-none items-center data-[disabled]:opacity-50", className)} {...props}>
|
||||||
|
<SliderPrimitive.Track data-slot="slider-track" className="relative h-2 w-full grow overflow-hidden rounded-full bg-muted">
|
||||||
|
<SliderPrimitive.Range data-slot="slider-range" className="absolute h-full rounded-full bg-primary"/>
|
||||||
|
</SliderPrimitive.Track>
|
||||||
|
{values.map((_, index) => (<SliderPrimitive.Thumb key={index} data-slot="slider-thumb" className="block size-4 shrink-0 rounded-full border-2 border-primary bg-background shadow-sm transition-[color,box-shadow] hover:shadow-md focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-ring/40 disabled:pointer-events-none disabled:opacity-50"/>))}
|
||||||
|
</SliderPrimitive.Root>);
|
||||||
|
}
|
||||||
|
export { Slider };
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
'use client';
|
||||||
|
import type { Variants } from 'motion/react';
|
||||||
|
import type { HTMLAttributes } from 'react';
|
||||||
|
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
|
||||||
|
import { motion, useAnimation } from 'motion/react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export interface ToolCaseIconHandle {
|
||||||
|
startAnimation: () => void;
|
||||||
|
stopAnimation: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToolCaseIconProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DRAW_VARIANTS: Variants = {
|
||||||
|
normal: {
|
||||||
|
pathLength: 1,
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
animate: {
|
||||||
|
pathLength: [0, 1],
|
||||||
|
opacity: [0, 1],
|
||||||
|
transition: {
|
||||||
|
duration: 0.6,
|
||||||
|
ease: 'easeInOut',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const HANDLE_VARIANTS: Variants = {
|
||||||
|
normal: {
|
||||||
|
scaleX: 1,
|
||||||
|
originX: '50%',
|
||||||
|
},
|
||||||
|
animate: {
|
||||||
|
scaleX: [0.6, 1.1, 1],
|
||||||
|
originX: '50%',
|
||||||
|
transition: {
|
||||||
|
duration: 0.45,
|
||||||
|
ease: 'easeInOut',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const ToolCaseIcon = forwardRef<ToolCaseIconHandle, ToolCaseIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
|
||||||
|
const controls = useAnimation();
|
||||||
|
const isControlledRef = useRef(false);
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => {
|
||||||
|
isControlledRef.current = true;
|
||||||
|
return {
|
||||||
|
startAnimation: () => controls.start('animate'),
|
||||||
|
stopAnimation: () => controls.start('normal'),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleMouseEnter = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (!isControlledRef.current) {
|
||||||
|
controls.start('animate');
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
onMouseEnter?.(e);
|
||||||
|
}
|
||||||
|
}, [controls, onMouseEnter]);
|
||||||
|
|
||||||
|
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (!isControlledRef.current) {
|
||||||
|
controls.start('normal');
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
onMouseLeave?.(e);
|
||||||
|
}
|
||||||
|
}, [controls, onMouseLeave]);
|
||||||
|
|
||||||
|
return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<motion.path d="M10 15h4" variants={HANDLE_VARIANTS} animate={controls} initial="normal"/>
|
||||||
|
<motion.path d="m14.817 10.995-.971-1.45 1.034-1.232a2 2 0 0 0-2.025-3.238l-1.82.364L9.91 3.885a2 2 0 0 0-3.625.748L6.141 6.55l-1.725.426a2 2 0 0 0-.19 3.756l.657.27" variants={DRAW_VARIANTS} animate={controls} initial="normal"/>
|
||||||
|
<motion.path d="m18.822 10.995 2.26-5.38a1 1 0 0 0-.557-1.318L16.954 2.9a1 1 0 0 0-1.281.533l-.924 2.122" variants={DRAW_VARIANTS} animate={controls} initial="normal"/>
|
||||||
|
<motion.path d="M4 12.006A1 1 0 0 1 4.994 11H19a1 1 0 0 1 1 1v7a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2z" variants={DRAW_VARIANTS} animate={controls} initial="normal"/>
|
||||||
|
</svg>
|
||||||
|
</div>);
|
||||||
|
});
|
||||||
|
|
||||||
|
ToolCaseIcon.displayName = 'ToolCaseIcon';
|
||||||
|
|
||||||
|
export { ToolCaseIcon };
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { API_SOURCES, checkAllApiStatuses, ensureApiStatusCheckStarted, getApiStatusState, subscribeApiStatus, } from "@/lib/api-status";
|
import { API_SOURCES, checkApiStatus, checkCurrentApiStatusesOnly, checkSpotiFLACNextStatusesOnly, getApiStatusState, subscribeApiStatus, } from "@/lib/api-status";
|
||||||
export function useApiStatus() {
|
export function useApiStatus() {
|
||||||
const [state, setState] = useState(getApiStatusState);
|
const [state, setState] = useState(getApiStatusState);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
ensureApiStatusCheckStarted();
|
|
||||||
return subscribeApiStatus(() => {
|
return subscribeApiStatus(() => {
|
||||||
setState(getApiStatusState());
|
setState(getApiStatusState());
|
||||||
});
|
});
|
||||||
@@ -11,6 +10,8 @@ export function useApiStatus() {
|
|||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
sources: API_SOURCES,
|
sources: API_SOURCES,
|
||||||
refreshAll: () => checkAllApiStatuses(true),
|
checkOne: (sourceId: string) => checkApiStatus(sourceId),
|
||||||
|
checkAllCurrent: () => checkCurrentApiStatusesOnly(),
|
||||||
|
checkAllNext: () => checkSpotiFLACNextStatusesOnly(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useRef } from "react";
|
import { useState, useRef } from "react";
|
||||||
import { downloadTrack, fetchSpotifyMetadata } from "@/lib/api";
|
import { downloadTrack, fetchSpotifyMetadata } from "@/lib/api";
|
||||||
import { getSettings, parseTemplate, type TemplateData } from "@/lib/settings";
|
import { getSettings, hasConfiguredCustomTidalApi, parseTemplate, sanitizeAutoOrder, type TemplateData } from "@/lib/settings";
|
||||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||||
import { joinPath, sanitizePath, getFirstArtist } from "@/lib/utils";
|
import { joinPath, sanitizePath, getFirstArtist } from "@/lib/utils";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
@@ -36,13 +36,17 @@ const GetTrackISRC = (spotifyId: string): Promise<string> => (window as any)["go
|
|||||||
async function resolveTemplateISRC(settings: {
|
async function resolveTemplateISRC(settings: {
|
||||||
folderTemplate?: string;
|
folderTemplate?: string;
|
||||||
filenameTemplate?: string;
|
filenameTemplate?: string;
|
||||||
|
existingFileCheckMode?: string;
|
||||||
}, spotifyId?: string): Promise<string> {
|
}, spotifyId?: string): Promise<string> {
|
||||||
if (!spotifyId) {
|
if (!spotifyId) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
const folderTemplate = settings.folderTemplate || "";
|
const folderTemplate = settings.folderTemplate || "";
|
||||||
const filenameTemplate = settings.filenameTemplate || "";
|
const filenameTemplate = settings.filenameTemplate || "";
|
||||||
if (!folderTemplate.includes("{isrc}") && !filenameTemplate.includes("{isrc}")) {
|
const shouldResolveISRC = settings.existingFileCheckMode === "isrc" ||
|
||||||
|
folderTemplate.includes("{isrc}") ||
|
||||||
|
filenameTemplate.includes("{isrc}");
|
||||||
|
if (!shouldResolveISRC) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -52,8 +56,18 @@ async function resolveTemplateISRC(settings: {
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
function getTidalAudioFormat(settings: any, mode: "single" | "auto"): "LOSSLESS" | "HI_RES_LOSSLESS" {
|
||||||
|
if (mode === "auto") {
|
||||||
|
return (settings.autoQuality || "24") === "24" ? "HI_RES_LOSSLESS" : "LOSSLESS";
|
||||||
|
}
|
||||||
|
return settings.tidalQuality || "LOSSLESS";
|
||||||
|
}
|
||||||
|
function shouldFetchStreamingURLs(order: string[]): boolean {
|
||||||
|
return order.includes("amazon") || order.includes("tidal");
|
||||||
|
}
|
||||||
export function useDownload(region: string) {
|
export function useDownload(region: string) {
|
||||||
const [downloadProgress, setDownloadProgress] = useState<number>(0);
|
const [downloadProgress, setDownloadProgress] = useState<number>(0);
|
||||||
|
const [downloadRemainingCount, setDownloadRemainingCount] = useState<number>(0);
|
||||||
const [isDownloading, setIsDownloading] = useState(false);
|
const [isDownloading, setIsDownloading] = useState(false);
|
||||||
const [downloadingTrack, setDownloadingTrack] = useState<string | null>(null);
|
const [downloadingTrack, setDownloadingTrack] = useState<string | null>(null);
|
||||||
const [bulkDownloadType, setBulkDownloadType] = useState<"all" | "selected" | null>(null);
|
const [bulkDownloadType, setBulkDownloadType] = useState<"all" | "selected" | null>(null);
|
||||||
@@ -65,10 +79,20 @@ export function useDownload(region: string) {
|
|||||||
artists: string;
|
artists: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const shouldStopDownloadRef = useRef(false);
|
const shouldStopDownloadRef = useRef(false);
|
||||||
|
const updateBatchProgress = (completedCount: number, totalCount: number) => {
|
||||||
|
const safeTotalCount = Math.max(0, totalCount);
|
||||||
|
const safeCompletedCount = Math.min(Math.max(0, completedCount), safeTotalCount);
|
||||||
|
setDownloadProgress(safeTotalCount > 0 ? Math.min(100, Math.round((safeCompletedCount / safeTotalCount) * 100)) : 0);
|
||||||
|
setDownloadRemainingCount(Math.max(0, safeTotalCount - safeCompletedCount));
|
||||||
|
};
|
||||||
const downloadWithAutoFallback = async (id: string, settings: any, trackName?: string, artistName?: string, albumName?: string, playlistName?: string, position?: number, spotifyId?: string, durationMs?: number, releaseYear?: string, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => {
|
const downloadWithAutoFallback = async (id: string, settings: any, trackName?: string, artistName?: string, albumName?: string, playlistName?: string, position?: number, spotifyId?: string, durationMs?: number, releaseYear?: string, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => {
|
||||||
const service = settings.downloader;
|
const allowTidal = hasConfiguredCustomTidalApi(settings.customTidalApi);
|
||||||
|
const service = settings.downloader === "tidal" && !allowTidal ? "auto" : settings.downloader;
|
||||||
const query = trackName && artistName ? `${trackName} ${artistName} ` : undefined;
|
const query = trackName && artistName ? `${trackName} ${artistName} ` : undefined;
|
||||||
const os = settings.operatingSystem;
|
const os = settings.operatingSystem;
|
||||||
|
const customTidalApi = allowTidal && typeof settings.customTidalApi === "string" && settings.customTidalApi.trim().startsWith("https://")
|
||||||
|
? settings.customTidalApi.trim().replace(/\/+$/g, "")
|
||||||
|
: undefined;
|
||||||
let outputDir = settings.downloadPath;
|
let outputDir = settings.downloadPath;
|
||||||
let useAlbumTrackNumber = false;
|
let useAlbumTrackNumber = false;
|
||||||
const placeholder = "__SLASH_PLACEHOLDER__";
|
const placeholder = "__SLASH_PLACEHOLDER__";
|
||||||
@@ -170,8 +194,9 @@ export function useDownload(region: string) {
|
|||||||
itemID = await AddToDownloadQueue(id, trackName || "", displayArtist || "", albumName || "");
|
itemID = await AddToDownloadQueue(id, trackName || "", displayArtist || "", albumName || "");
|
||||||
}
|
}
|
||||||
if (service === "auto") {
|
if (service === "auto") {
|
||||||
|
const order = sanitizeAutoOrder(settings.autoOrder, allowTidal).split("-");
|
||||||
let streamingURLs: any = null;
|
let streamingURLs: any = null;
|
||||||
if (spotifyId) {
|
if (spotifyId && shouldFetchStreamingURLs(order)) {
|
||||||
try {
|
try {
|
||||||
const { GetStreamingURLs } = await import("../../wailsjs/go/main/App");
|
const { GetStreamingURLs } = await import("../../wailsjs/go/main/App");
|
||||||
const urlsJson = await GetStreamingURLs(spotifyId, region);
|
const urlsJson = await GetStreamingURLs(spotifyId, region);
|
||||||
@@ -182,16 +207,15 @@ export function useDownload(region: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const durationSeconds = durationMs ? Math.round(durationMs / 1000) : undefined;
|
const durationSeconds = durationMs ? Math.round(durationMs / 1000) : undefined;
|
||||||
const order = (settings.autoOrder || "tidal-amazon-qobuz").split("-");
|
|
||||||
let lastResponse: any = { success: false, error: "No matching services found" };
|
let lastResponse: any = { success: false, error: "No matching services found" };
|
||||||
const fallbackErrors: string[] = [];
|
const fallbackErrors: string[] = [];
|
||||||
|
const tidalQuality = getTidalAudioFormat(settings, "auto");
|
||||||
const is24Bit = (settings.autoQuality || "24") === "24";
|
const is24Bit = (settings.autoQuality || "24") === "24";
|
||||||
const tidalQuality = is24Bit ? "HI_RES_LOSSLESS" : "LOSSLESS";
|
|
||||||
const qobuzQuality = is24Bit ? "27" : "6";
|
const qobuzQuality = is24Bit ? "27" : "6";
|
||||||
for (const s of order) {
|
for (const s of order) {
|
||||||
if (s === "tidal" && streamingURLs?.tidal_url) {
|
if (s === "tidal" && streamingURLs?.tidal_url) {
|
||||||
try {
|
try {
|
||||||
logger.debug(`trying tidal for: ${trackName} - ${artistName}`);
|
logger.debug(`trying Tidal for: ${trackName} - ${artistName}`);
|
||||||
const response = await downloadTrack({
|
const response = await downloadTrack({
|
||||||
service: "tidal",
|
service: "tidal",
|
||||||
query,
|
query,
|
||||||
@@ -209,10 +233,11 @@ export function useDownload(region: string) {
|
|||||||
spotify_id: spotifyId,
|
spotify_id: spotifyId,
|
||||||
embed_lyrics: settings.embedLyrics,
|
embed_lyrics: settings.embedLyrics,
|
||||||
embed_max_quality_cover: settings.embedMaxQualityCover,
|
embed_max_quality_cover: settings.embedMaxQualityCover,
|
||||||
service_url: streamingURLs.tidal_url,
|
service_url: streamingURLs?.tidal_url,
|
||||||
duration: durationSeconds,
|
duration: durationSeconds,
|
||||||
item_id: itemID,
|
item_id: itemID,
|
||||||
audio_format: tidalQuality,
|
audio_format: tidalQuality,
|
||||||
|
tidal_api_url: customTidalApi,
|
||||||
spotify_track_number: spotifyTrackNumber,
|
spotify_track_number: spotifyTrackNumber,
|
||||||
spotify_disc_number: spotifyDiscNumber,
|
spotify_disc_number: spotifyDiscNumber,
|
||||||
spotify_total_tracks: spotifyTotalTracks,
|
spotify_total_tracks: spotifyTotalTracks,
|
||||||
@@ -225,16 +250,16 @@ export function useDownload(region: string) {
|
|||||||
embed_genre: settings.embedGenre,
|
embed_genre: settings.embedGenre,
|
||||||
});
|
});
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
logger.success(`tidal: ${trackName} - ${artistName}`);
|
logger.success(`Tidal: ${trackName} - ${artistName}`);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
const errMsg = response.error || response.message || "Failed";
|
const errMsg = response.error || response.message || "Failed";
|
||||||
fallbackErrors.push(`[Tidal] ${errMsg}`);
|
fallbackErrors.push(`[Tidal] ${errMsg}`);
|
||||||
lastResponse = response;
|
lastResponse = response;
|
||||||
logger.warning(`tidal failed, trying next...`);
|
logger.warning(`Tidal failed, trying next...`);
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
logger.error(`tidal error: ${err}`);
|
logger.error(`Tidal error: ${err}`);
|
||||||
fallbackErrors.push(`[Tidal] ${String(err)}`);
|
fallbackErrors.push(`[Tidal] ${String(err)}`);
|
||||||
lastResponse = { success: false, error: String(err) };
|
lastResponse = { success: false, error: String(err) };
|
||||||
}
|
}
|
||||||
@@ -344,7 +369,7 @@ export function useDownload(region: string) {
|
|||||||
const durationSecondsForFallback = durationMs ? Math.round(durationMs / 1000) : undefined;
|
const durationSecondsForFallback = durationMs ? Math.round(durationMs / 1000) : undefined;
|
||||||
let audioFormat: string | undefined;
|
let audioFormat: string | undefined;
|
||||||
if (service === "tidal") {
|
if (service === "tidal") {
|
||||||
audioFormat = settings.tidalQuality || "LOSSLESS";
|
audioFormat = getTidalAudioFormat(settings, "single");
|
||||||
}
|
}
|
||||||
else if (service === "qobuz") {
|
else if (service === "qobuz") {
|
||||||
audioFormat = settings.qobuzQuality || "6";
|
audioFormat = settings.qobuzQuality || "6";
|
||||||
@@ -373,6 +398,7 @@ export function useDownload(region: string) {
|
|||||||
duration: durationSecondsForFallback,
|
duration: durationSecondsForFallback,
|
||||||
item_id: itemID,
|
item_id: itemID,
|
||||||
audio_format: audioFormat,
|
audio_format: audioFormat,
|
||||||
|
tidal_api_url: service === "tidal" ? customTidalApi : undefined,
|
||||||
spotify_track_number: spotifyTrackNumber,
|
spotify_track_number: spotifyTrackNumber,
|
||||||
spotify_disc_number: spotifyDiscNumber,
|
spotify_disc_number: spotifyDiscNumber,
|
||||||
spotify_total_tracks: spotifyTotalTracks,
|
spotify_total_tracks: spotifyTotalTracks,
|
||||||
@@ -380,6 +406,7 @@ export function useDownload(region: string) {
|
|||||||
isrc: resolvedTemplateISRC || undefined,
|
isrc: resolvedTemplateISRC || undefined,
|
||||||
copyright: copyright,
|
copyright: copyright,
|
||||||
publisher: publisher,
|
publisher: publisher,
|
||||||
|
use_first_artist_only: settings.useFirstArtistOnly,
|
||||||
use_single_genre: settings.useSingleGenre,
|
use_single_genre: settings.useSingleGenre,
|
||||||
embed_genre: settings.embedGenre,
|
embed_genre: settings.embedGenre,
|
||||||
});
|
});
|
||||||
@@ -390,7 +417,8 @@ export function useDownload(region: string) {
|
|||||||
return singleServiceResponse;
|
return singleServiceResponse;
|
||||||
};
|
};
|
||||||
const downloadWithItemID = async (settings: any, itemID: string, trackName?: string, artistName?: string, albumName?: string, folderName?: string, position?: number, spotifyId?: string, durationMs?: number, isAlbum?: boolean, releaseYear?: string, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => {
|
const downloadWithItemID = async (settings: any, itemID: string, trackName?: string, artistName?: string, albumName?: string, folderName?: string, position?: number, spotifyId?: string, durationMs?: number, isAlbum?: boolean, releaseYear?: string, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => {
|
||||||
const service = settings.downloader;
|
const allowTidal = hasConfiguredCustomTidalApi(settings.customTidalApi);
|
||||||
|
const service = settings.downloader === "tidal" && !allowTidal ? "auto" : settings.downloader;
|
||||||
const query = trackName && artistName ? `${trackName} ${artistName}` : undefined;
|
const query = trackName && artistName ? `${trackName} ${artistName}` : undefined;
|
||||||
const os = settings.operatingSystem;
|
const os = settings.operatingSystem;
|
||||||
let outputDir = settings.downloadPath;
|
let outputDir = settings.downloadPath;
|
||||||
@@ -451,8 +479,9 @@ export function useDownload(region: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (service === "auto") {
|
if (service === "auto") {
|
||||||
|
const order = sanitizeAutoOrder(settings.autoOrder, allowTidal).split("-");
|
||||||
let streamingURLs: any = null;
|
let streamingURLs: any = null;
|
||||||
if (spotifyId) {
|
if (spotifyId && shouldFetchStreamingURLs(order)) {
|
||||||
try {
|
try {
|
||||||
const { GetStreamingURLs } = await import("../../wailsjs/go/main/App");
|
const { GetStreamingURLs } = await import("../../wailsjs/go/main/App");
|
||||||
const urlsJson = await GetStreamingURLs(spotifyId, region);
|
const urlsJson = await GetStreamingURLs(spotifyId, region);
|
||||||
@@ -463,16 +492,15 @@ export function useDownload(region: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const durationSeconds = durationMs ? Math.round(durationMs / 1000) : undefined;
|
const durationSeconds = durationMs ? Math.round(durationMs / 1000) : undefined;
|
||||||
const order = (settings.autoOrder || "tidal-amazon-qobuz").split("-");
|
|
||||||
let lastResponse: any = { success: false, error: "No matching services found" };
|
let lastResponse: any = { success: false, error: "No matching services found" };
|
||||||
const fallbackErrors: string[] = [];
|
const fallbackErrors: string[] = [];
|
||||||
|
const tidalQuality = getTidalAudioFormat(settings, "auto");
|
||||||
const is24Bit = (settings.autoQuality || "24") === "24";
|
const is24Bit = (settings.autoQuality || "24") === "24";
|
||||||
const tidalQuality = is24Bit ? "HI_RES_LOSSLESS" : "LOSSLESS";
|
|
||||||
const qobuzQuality = is24Bit ? "27" : "6";
|
const qobuzQuality = is24Bit ? "27" : "6";
|
||||||
for (const s of order) {
|
for (const s of order) {
|
||||||
if (s === "tidal" && streamingURLs?.tidal_url) {
|
if (s === "tidal" && streamingURLs?.tidal_url) {
|
||||||
try {
|
try {
|
||||||
logger.debug(`trying tidal for: ${trackName} - ${artistName}`);
|
logger.debug(`trying Tidal for: ${trackName} - ${artistName}`);
|
||||||
const response = await downloadTrack({
|
const response = await downloadTrack({
|
||||||
service: "tidal",
|
service: "tidal",
|
||||||
query,
|
query,
|
||||||
@@ -490,7 +518,7 @@ export function useDownload(region: string) {
|
|||||||
spotify_id: spotifyId,
|
spotify_id: spotifyId,
|
||||||
embed_lyrics: settings.embedLyrics,
|
embed_lyrics: settings.embedLyrics,
|
||||||
embed_max_quality_cover: settings.embedMaxQualityCover,
|
embed_max_quality_cover: settings.embedMaxQualityCover,
|
||||||
service_url: streamingURLs.tidal_url,
|
service_url: streamingURLs?.tidal_url,
|
||||||
duration: durationSeconds,
|
duration: durationSeconds,
|
||||||
item_id: itemID,
|
item_id: itemID,
|
||||||
audio_format: tidalQuality,
|
audio_format: tidalQuality,
|
||||||
@@ -506,16 +534,16 @@ export function useDownload(region: string) {
|
|||||||
embed_genre: settings.embedGenre,
|
embed_genre: settings.embedGenre,
|
||||||
});
|
});
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
logger.success(`tidal: ${trackName} - ${artistName}`);
|
logger.success(`Tidal: ${trackName} - ${artistName}`);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
const errMsg = response.error || response.message || "Failed";
|
const errMsg = response.error || response.message || "Failed";
|
||||||
fallbackErrors.push(`[Tidal] ${errMsg}`);
|
fallbackErrors.push(`[Tidal] ${errMsg}`);
|
||||||
lastResponse = response;
|
lastResponse = response;
|
||||||
logger.warning(`tidal failed, trying next...`);
|
logger.warning(`Tidal failed, trying next...`);
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
logger.error(`tidal error: ${err}`);
|
logger.error(`Tidal error: ${err}`);
|
||||||
fallbackErrors.push(`[Tidal] ${String(err)}`);
|
fallbackErrors.push(`[Tidal] ${String(err)}`);
|
||||||
lastResponse = { success: false, error: String(err) };
|
lastResponse = { success: false, error: String(err) };
|
||||||
}
|
}
|
||||||
@@ -628,7 +656,7 @@ export function useDownload(region: string) {
|
|||||||
const durationSecondsForFallback = durationMs ? Math.round(durationMs / 1000) : undefined;
|
const durationSecondsForFallback = durationMs ? Math.round(durationMs / 1000) : undefined;
|
||||||
let audioFormat: string | undefined;
|
let audioFormat: string | undefined;
|
||||||
if (service === "tidal") {
|
if (service === "tidal") {
|
||||||
audioFormat = settings.tidalQuality || "LOSSLESS";
|
audioFormat = getTidalAudioFormat(settings, "single");
|
||||||
}
|
}
|
||||||
else if (service === "qobuz") {
|
else if (service === "qobuz") {
|
||||||
audioFormat = settings.qobuzQuality || "6";
|
audioFormat = settings.qobuzQuality || "6";
|
||||||
@@ -720,6 +748,8 @@ export function useDownload(region: string) {
|
|||||||
setIsDownloading(true);
|
setIsDownloading(true);
|
||||||
setBulkDownloadType("selected");
|
setBulkDownloadType("selected");
|
||||||
setDownloadProgress(0);
|
setDownloadProgress(0);
|
||||||
|
setDownloadRemainingCount(selectedTracks.length);
|
||||||
|
setCurrentDownloadInfo(null);
|
||||||
let outputDir = settings.downloadPath;
|
let outputDir = settings.downloadPath;
|
||||||
const os = settings.operatingSystem;
|
const os = settings.operatingSystem;
|
||||||
const useAlbumTag = settings.folderTemplate?.includes("{album}");
|
const useAlbumTag = settings.folderTemplate?.includes("{album}");
|
||||||
@@ -788,7 +818,7 @@ export function useDownload(region: string) {
|
|||||||
let errorCount = 0;
|
let errorCount = 0;
|
||||||
let skippedCount = existingSpotifyIDs.size;
|
let skippedCount = existingSpotifyIDs.size;
|
||||||
const total = selectedTracks.length;
|
const total = selectedTracks.length;
|
||||||
setDownloadProgress(Math.round((skippedCount / total) * 100));
|
updateBatchProgress(skippedCount, total);
|
||||||
for (let i = 0; i < tracksToDownload.length; i++) {
|
for (let i = 0; i < tracksToDownload.length; i++) {
|
||||||
if (shouldStopDownloadRef.current) {
|
if (shouldStopDownloadRef.current) {
|
||||||
toast.info(`Download stopped. ${successCount} tracks downloaded, ${tracksToDownload.length - i} remaining.`);
|
toast.info(`Download stopped. ${successCount} tracks downloaded, ${tracksToDownload.length - i} remaining.`);
|
||||||
@@ -841,12 +871,13 @@ export function useDownload(region: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const completedCount = skippedCount + successCount + errorCount;
|
const completedCount = skippedCount + successCount + errorCount;
|
||||||
setDownloadProgress(Math.min(100, Math.round((completedCount / total) * 100)));
|
updateBatchProgress(completedCount, total);
|
||||||
}
|
}
|
||||||
setDownloadingTrack(null);
|
setDownloadingTrack(null);
|
||||||
setCurrentDownloadInfo(null);
|
setCurrentDownloadInfo(null);
|
||||||
setIsDownloading(false);
|
setIsDownloading(false);
|
||||||
setBulkDownloadType(null);
|
setBulkDownloadType(null);
|
||||||
|
updateBatchProgress(0, 0);
|
||||||
shouldStopDownloadRef.current = false;
|
shouldStopDownloadRef.current = false;
|
||||||
const { CancelAllQueuedItems } = await import("../../wailsjs/go/main/App");
|
const { CancelAllQueuedItems } = await import("../../wailsjs/go/main/App");
|
||||||
await CancelAllQueuedItems();
|
await CancelAllQueuedItems();
|
||||||
@@ -895,6 +926,8 @@ export function useDownload(region: string) {
|
|||||||
setIsDownloading(true);
|
setIsDownloading(true);
|
||||||
setBulkDownloadType("all");
|
setBulkDownloadType("all");
|
||||||
setDownloadProgress(0);
|
setDownloadProgress(0);
|
||||||
|
setDownloadRemainingCount(tracksWithId.length);
|
||||||
|
setCurrentDownloadInfo(null);
|
||||||
let outputDir = settings.downloadPath;
|
let outputDir = settings.downloadPath;
|
||||||
const os = settings.operatingSystem;
|
const os = settings.operatingSystem;
|
||||||
const useAlbumTag = settings.folderTemplate?.includes("{album}");
|
const useAlbumTag = settings.folderTemplate?.includes("{album}");
|
||||||
@@ -958,7 +991,7 @@ export function useDownload(region: string) {
|
|||||||
let errorCount = 0;
|
let errorCount = 0;
|
||||||
let skippedCount = existingSpotifyIDs.size;
|
let skippedCount = existingSpotifyIDs.size;
|
||||||
const total = tracksWithId.length;
|
const total = tracksWithId.length;
|
||||||
setDownloadProgress(Math.round((skippedCount / total) * 100));
|
updateBatchProgress(skippedCount, total);
|
||||||
for (let i = 0; i < tracksToDownload.length; i++) {
|
for (let i = 0; i < tracksToDownload.length; i++) {
|
||||||
if (shouldStopDownloadRef.current) {
|
if (shouldStopDownloadRef.current) {
|
||||||
toast.info(`Download stopped. ${successCount} tracks downloaded, ${tracksToDownload.length - i} remaining.`);
|
toast.info(`Download stopped. ${successCount} tracks downloaded, ${tracksToDownload.length - i} remaining.`);
|
||||||
@@ -1008,12 +1041,13 @@ export function useDownload(region: string) {
|
|||||||
await MarkDownloadItemFailed(itemID, err instanceof Error ? err.message : String(err));
|
await MarkDownloadItemFailed(itemID, err instanceof Error ? err.message : String(err));
|
||||||
}
|
}
|
||||||
const completedCount = skippedCount + successCount + errorCount;
|
const completedCount = skippedCount + successCount + errorCount;
|
||||||
setDownloadProgress(Math.min(100, Math.round((completedCount / total) * 100)));
|
updateBatchProgress(completedCount, total);
|
||||||
}
|
}
|
||||||
setDownloadingTrack(null);
|
setDownloadingTrack(null);
|
||||||
setCurrentDownloadInfo(null);
|
setCurrentDownloadInfo(null);
|
||||||
setIsDownloading(false);
|
setIsDownloading(false);
|
||||||
setBulkDownloadType(null);
|
setBulkDownloadType(null);
|
||||||
|
updateBatchProgress(0, 0);
|
||||||
shouldStopDownloadRef.current = false;
|
shouldStopDownloadRef.current = false;
|
||||||
const { CancelAllQueuedItems: CancelQueued } = await import("../../wailsjs/go/main/App");
|
const { CancelAllQueuedItems: CancelQueued } = await import("../../wailsjs/go/main/App");
|
||||||
await CancelQueued();
|
await CancelQueued();
|
||||||
@@ -1060,6 +1094,7 @@ export function useDownload(region: string) {
|
|||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
downloadProgress,
|
downloadProgress,
|
||||||
|
downloadRemainingCount,
|
||||||
isDownloading,
|
isDownloading,
|
||||||
downloadingTrack,
|
downloadingTrack,
|
||||||
bulkDownloadType,
|
bulkDownloadType,
|
||||||
|
|||||||
@@ -9,13 +9,17 @@ const GetTrackISRC = (spotifyId: string): Promise<string> => (window as any)["go
|
|||||||
async function resolveTemplateISRC(settings: {
|
async function resolveTemplateISRC(settings: {
|
||||||
folderTemplate?: string;
|
folderTemplate?: string;
|
||||||
filenameTemplate?: string;
|
filenameTemplate?: string;
|
||||||
|
existingFileCheckMode?: string;
|
||||||
}, spotifyId?: string): Promise<string> {
|
}, spotifyId?: string): Promise<string> {
|
||||||
if (!spotifyId) {
|
if (!spotifyId) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
const folderTemplate = settings.folderTemplate || "";
|
const folderTemplate = settings.folderTemplate || "";
|
||||||
const filenameTemplate = settings.filenameTemplate || "";
|
const filenameTemplate = settings.filenameTemplate || "";
|
||||||
if (!folderTemplate.includes("{isrc}") && !filenameTemplate.includes("{isrc}")) {
|
const shouldResolveISRC = settings.existingFileCheckMode === "isrc" ||
|
||||||
|
folderTemplate.includes("{isrc}") ||
|
||||||
|
filenameTemplate.includes("{isrc}");
|
||||||
|
if (!shouldResolveISRC) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,32 +1,34 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { GetPreviewURL } from "@/../wailsjs/go/main/App";
|
import { GetPreviewURL } from "@/../wailsjs/go/main/App";
|
||||||
import { SPOTIFY_PREVIEW_VOLUME } from "@/lib/preview";
|
import { getPreviewVolume } from "@/lib/preview";
|
||||||
|
import { createPreviewPlayback, type PreviewPlayback } from "@/lib/preview-player";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
export function usePreview() {
|
export function usePreview() {
|
||||||
const [loadingPreview, setLoadingPreview] = useState<string | null>(null);
|
const [loadingPreview, setLoadingPreview] = useState<string | null>(null);
|
||||||
const [currentAudio, setCurrentAudio] = useState<HTMLAudioElement | null>(null);
|
|
||||||
const [playingTrack, setPlayingTrack] = useState<string | null>(null);
|
const [playingTrack, setPlayingTrack] = useState<string | null>(null);
|
||||||
|
const currentPlaybackRef = useRef<PreviewPlayback | null>(null);
|
||||||
|
const stopCurrentAudio = () => {
|
||||||
|
if (!currentPlaybackRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
currentPlaybackRef.current.destroy();
|
||||||
|
currentPlaybackRef.current = null;
|
||||||
|
};
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (currentAudio) {
|
stopCurrentAudio();
|
||||||
currentAudio.pause();
|
|
||||||
currentAudio.currentTime = 0;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}, [currentAudio]);
|
}, []);
|
||||||
const playPreview = async (trackId: string, trackName: string) => {
|
const playPreview = async (trackId: string, trackName: string) => {
|
||||||
try {
|
try {
|
||||||
|
const currentAudio = currentPlaybackRef.current?.audio;
|
||||||
if (playingTrack === trackId && currentAudio) {
|
if (playingTrack === trackId && currentAudio) {
|
||||||
currentAudio.pause();
|
stopCurrentAudio();
|
||||||
currentAudio.currentTime = 0;
|
|
||||||
setPlayingTrack(null);
|
setPlayingTrack(null);
|
||||||
setCurrentAudio(null);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (currentAudio) {
|
if (currentAudio) {
|
||||||
currentAudio.pause();
|
stopCurrentAudio();
|
||||||
currentAudio.currentTime = 0;
|
|
||||||
setCurrentAudio(null);
|
|
||||||
setPlayingTrack(null);
|
setPlayingTrack(null);
|
||||||
}
|
}
|
||||||
setLoadingPreview(trackId);
|
setLoadingPreview(trackId);
|
||||||
@@ -38,15 +40,18 @@ export function usePreview() {
|
|||||||
setLoadingPreview(null);
|
setLoadingPreview(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const audio = new Audio(previewURL);
|
const playback = await createPreviewPlayback(previewURL, getPreviewVolume());
|
||||||
audio.volume = SPOTIFY_PREVIEW_VOLUME;
|
const audio = playback.audio;
|
||||||
audio.addEventListener("loadeddata", () => {
|
audio.addEventListener("loadeddata", () => {
|
||||||
setLoadingPreview(null);
|
setLoadingPreview(null);
|
||||||
setPlayingTrack(trackId);
|
setPlayingTrack(trackId);
|
||||||
});
|
});
|
||||||
audio.addEventListener("ended", () => {
|
audio.addEventListener("ended", () => {
|
||||||
setPlayingTrack(null);
|
setPlayingTrack(null);
|
||||||
setCurrentAudio(null);
|
if (currentPlaybackRef.current?.audio === audio) {
|
||||||
|
currentPlaybackRef.current.destroy();
|
||||||
|
currentPlaybackRef.current = null;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
audio.addEventListener("error", () => {
|
audio.addEventListener("error", () => {
|
||||||
toast.error("Failed to play preview", {
|
toast.error("Failed to play preview", {
|
||||||
@@ -54,27 +59,27 @@ export function usePreview() {
|
|||||||
});
|
});
|
||||||
setLoadingPreview(null);
|
setLoadingPreview(null);
|
||||||
setPlayingTrack(null);
|
setPlayingTrack(null);
|
||||||
setCurrentAudio(null);
|
if (currentPlaybackRef.current?.audio === audio) {
|
||||||
|
currentPlaybackRef.current.destroy();
|
||||||
|
currentPlaybackRef.current = null;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
setCurrentAudio(audio);
|
currentPlaybackRef.current = playback;
|
||||||
await audio.play();
|
await audio.play();
|
||||||
}
|
}
|
||||||
catch (error: any) {
|
catch (error: unknown) {
|
||||||
|
stopCurrentAudio();
|
||||||
console.error("Preview error:", error);
|
console.error("Preview error:", error);
|
||||||
toast.error("Preview not available", {
|
toast.error("Preview not available", {
|
||||||
description: error?.message || `Could not load preview for "${trackName}"`,
|
description: error instanceof Error ? error.message : `Could not load preview for "${trackName}"`,
|
||||||
});
|
});
|
||||||
setLoadingPreview(null);
|
setLoadingPreview(null);
|
||||||
setPlayingTrack(null);
|
setPlayingTrack(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const stopPreview = () => {
|
const stopPreview = () => {
|
||||||
if (currentAudio) {
|
stopCurrentAudio();
|
||||||
currentAudio.pause();
|
|
||||||
currentAudio.currentTime = 0;
|
|
||||||
setCurrentAudio(null);
|
|
||||||
setPlayingTrack(null);
|
setPlayingTrack(null);
|
||||||
}
|
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
playPreview,
|
playPreview,
|
||||||
|
|||||||
@@ -73,6 +73,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
}
|
}
|
||||||
|
|||||||
+345
-101
@@ -1,160 +1,404 @@
|
|||||||
import { CheckAPIStatus, FetchUnifiedAPIStatus } from "../../wailsjs/go/main/App";
|
import { CheckAPIStatus, CheckCustomTidalAPI } from "../../wailsjs/go/main/App";
|
||||||
import { CHECK_TIMEOUT_MS, withTimeout } from "@/lib/async-timeout";
|
import { CHECK_TIMEOUT_MS, withTimeout } from "@/lib/async-timeout";
|
||||||
|
import { getSettings, hasConfiguredCustomTidalApi } from "@/lib/settings";
|
||||||
|
|
||||||
export type ApiCheckStatus = "checking" | "online" | "offline" | "idle";
|
export type ApiCheckStatus = "checking" | "online" | "offline" | "idle";
|
||||||
|
|
||||||
export interface ApiSource {
|
export interface ApiSource {
|
||||||
id: string;
|
id: string;
|
||||||
type: string;
|
type: string;
|
||||||
name: string;
|
name: string;
|
||||||
url: string;
|
url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SpotiFLACNextSource {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
statusKey?: string;
|
||||||
|
statusPrefix?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SpotiFLACNextStatusResponse = Partial<Record<string, string>>;
|
||||||
|
type ApiStatusTargetReport = {
|
||||||
|
target?: string;
|
||||||
|
label?: string;
|
||||||
|
online?: boolean;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
type ApiStatusReport = {
|
||||||
|
type?: string;
|
||||||
|
online?: boolean;
|
||||||
|
require_all?: boolean;
|
||||||
|
details?: ApiStatusTargetReport[];
|
||||||
|
};
|
||||||
|
|
||||||
export const API_SOURCES: ApiSource[] = [
|
export const API_SOURCES: ApiSource[] = [
|
||||||
{ id: "tidal1", type: "tidal", name: "Tidal A", url: "https://hifi-one.spotisaver.net" },
|
{ id: "tidal", type: "tidal", name: "Tidal", url: "" },
|
||||||
{ id: "tidal2", type: "tidal", name: "Tidal B", url: "https://hifi-two.spotisaver.net" },
|
{ id: "qobuz", type: "qobuz", name: "Qobuz", url: "" },
|
||||||
{ id: "tidal3", type: "tidal", name: "Tidal C", url: "https://eu-central.monochrome.tf" },
|
{ id: "amazon", type: "amazon", name: "Amazon Music", url: "" },
|
||||||
{ id: "tidal4", type: "tidal", name: "Tidal D", url: "https://us-west.monochrome.tf" },
|
|
||||||
{ id: "tidal5", type: "tidal", name: "Tidal E", url: "https://api.monochrome.tf" },
|
|
||||||
{ id: "tidal6", type: "tidal", name: "Tidal F", url: "https://monochrome-api.samidy.com" },
|
|
||||||
{ id: "tidal7", type: "tidal", name: "Tidal G", url: "https://tidal.kinoplus.online" },
|
|
||||||
{ id: "qobuz1", type: "qobuz", name: "Qobuz A", url: "https://dab.yeet.su" },
|
|
||||||
{ id: "qobuz2", type: "qobuz", name: "Qobuz B", url: "https://dabmusic.xyz" },
|
|
||||||
{ id: "qobuz3", type: "qbz", name: "Qobuz C", url: "https://qbz.afkarxyz.qzz.io" },
|
|
||||||
{ id: "amazon1", type: "amazon", name: "Amazon Music", url: "https://amzn.afkarxyz.qzz.io" },
|
|
||||||
{ id: "lrclib", type: "lrclib", name: "LRCLIB", url: "https://lrclib.net" },
|
|
||||||
{ id: "musicbrainz", type: "musicbrainz", name: "MusicBrainz", url: "https://musicbrainz.org" },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const SPOTIFLAC_NEXT_SOURCES: SpotiFLACNextSource[] = [
|
||||||
|
{ id: "tidal", name: "Tidal", statusKey: "tidal" },
|
||||||
|
{ id: "qobuz", name: "Qobuz", statusPrefix: "qobuz_" },
|
||||||
|
{ id: "amazon", name: "Amazon Music", statusPrefix: "amazon_" },
|
||||||
|
{ id: "deezer", name: "Deezer", statusPrefix: "deezer_" },
|
||||||
|
{ id: "apple", name: "Apple Music", statusKey: "apple" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SPOTIFLAC_STATUS_URL = "https://gist.githubusercontent.com/afkarxyz/6e57cd362cbd67f889e3a91a76254a5e/raw";
|
||||||
|
const SPOTIFLAC_CURRENT_AMAZON_STATUS_KEY = "amazon_a";
|
||||||
|
const SPOTIFLAC_STATUS_MAX_ATTEMPTS = 3;
|
||||||
|
const SPOTIFLAC_STATUS_RETRY_DELAY_MS = 1200;
|
||||||
|
const CheckAPIStatusReport = (apiType: string, apiURL: string): Promise<ApiStatusReport> => (window as any)["go"]["main"]["App"]["CheckAPIStatusReport"](apiType, apiURL);
|
||||||
|
const LogStatusConsole = (level: string, message: string): Promise<void> => (window as any)["go"]["main"]["App"]["LogStatusConsole"](level, message);
|
||||||
|
|
||||||
type ApiStatusState = {
|
type ApiStatusState = {
|
||||||
isCheckingAll: boolean;
|
checkingSources: Record<string, boolean>;
|
||||||
statuses: Record<string, ApiCheckStatus>;
|
statuses: Record<string, ApiCheckStatus>;
|
||||||
|
nextStatuses: Record<string, ApiCheckStatus>;
|
||||||
};
|
};
|
||||||
|
|
||||||
let apiStatusState: ApiStatusState = {
|
let apiStatusState: ApiStatusState = {
|
||||||
isCheckingAll: false,
|
checkingSources: {},
|
||||||
statuses: {},
|
statuses: {},
|
||||||
|
nextStatuses: {},
|
||||||
};
|
};
|
||||||
let activeCheckAll: Promise<void> | null = null;
|
|
||||||
|
let activeCheckCurrentOnly: Promise<void> | null = null;
|
||||||
|
let activeCheckNextOnly: Promise<void> | null = null;
|
||||||
|
let activeStatusPayloadFetch: Promise<SpotiFLACNextStatusResponse> | null = null;
|
||||||
|
|
||||||
|
const activeSourceChecks = new Map<string, Promise<void>>();
|
||||||
const listeners = new Set<() => void>();
|
const listeners = new Set<() => void>();
|
||||||
type SpotiFLACUnifiedStatusResponse = {
|
|
||||||
tidal?: string;
|
|
||||||
qobuz_a?: string;
|
|
||||||
qobuz_b?: string;
|
|
||||||
qobuz_c?: string;
|
|
||||||
amazon?: string;
|
|
||||||
lrclib?: string;
|
|
||||||
};
|
|
||||||
function emitApiStatusChange() {
|
function emitApiStatusChange() {
|
||||||
for (const listener of listeners) {
|
for (const listener of listeners) {
|
||||||
listener();
|
listener();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setApiStatusState(updater: (current: ApiStatusState) => ApiStatusState) {
|
function setApiStatusState(updater: (current: ApiStatusState) => ApiStatusState) {
|
||||||
apiStatusState = updater(apiStatusState);
|
apiStatusState = updater(apiStatusState);
|
||||||
emitApiStatusChange();
|
emitApiStatusChange();
|
||||||
}
|
}
|
||||||
function statusFromUnifiedValue(value: string | undefined): ApiCheckStatus {
|
|
||||||
return value === "up" ? "online" : "offline";
|
function delay(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||||
}
|
}
|
||||||
async function fetchUnifiedStatuses(forceRefresh: boolean): Promise<Pick<ApiStatusState, "statuses">> {
|
function sendStatusConsole(level: "info" | "warning" | "error", message: string): void {
|
||||||
const response = await FetchUnifiedAPIStatus(forceRefresh);
|
|
||||||
const payload = JSON.parse(response) as SpotiFLACUnifiedStatusResponse;
|
|
||||||
const tidalStatus = statusFromUnifiedValue(payload.tidal);
|
|
||||||
return {
|
|
||||||
statuses: {
|
|
||||||
tidal1: tidalStatus,
|
|
||||||
tidal2: tidalStatus,
|
|
||||||
tidal3: tidalStatus,
|
|
||||||
tidal4: tidalStatus,
|
|
||||||
tidal5: tidalStatus,
|
|
||||||
tidal6: tidalStatus,
|
|
||||||
tidal7: tidalStatus,
|
|
||||||
qobuz1: statusFromUnifiedValue(payload.qobuz_a),
|
|
||||||
qobuz2: statusFromUnifiedValue(payload.qobuz_b),
|
|
||||||
qobuz3: statusFromUnifiedValue(payload.qobuz_c),
|
|
||||||
amazon1: statusFromUnifiedValue(payload.amazon),
|
|
||||||
lrclib: statusFromUnifiedValue(payload.lrclib),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
async function checkMusicBrainzStatus(): Promise<ApiCheckStatus> {
|
|
||||||
try {
|
try {
|
||||||
const isOnline = await withTimeout(CheckAPIStatus("musicbrainz", "https://musicbrainz.org"), CHECK_TIMEOUT_MS, "API status check timed out after 10 seconds for MusicBrainz");
|
void LogStatusConsole(level, message);
|
||||||
return isOnline ? "online" : "offline";
|
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function logStatusInfo(message: string): void {
|
||||||
|
sendStatusConsole("info", message);
|
||||||
|
}
|
||||||
|
function logStatusWarning(message: string): void {
|
||||||
|
sendStatusConsole("warning", message);
|
||||||
|
}
|
||||||
|
function logStatusError(message: string): void {
|
||||||
|
sendStatusConsole("error", message);
|
||||||
|
}
|
||||||
|
function truncateStatusMessage(message?: string, maxLen = 180): string {
|
||||||
|
const trimmed = (message || "").trim();
|
||||||
|
if (trimmed.length <= maxLen) {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
return trimmed.slice(0, maxLen) + "...";
|
||||||
|
}
|
||||||
|
function logQobuzStatusReport(report: ApiStatusReport): void {
|
||||||
|
const details = Array.isArray(report.details) ? report.details : [];
|
||||||
|
if (details.length === 0) {
|
||||||
|
logStatusWarning("[Status][Qobuz] No provider details were returned.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const onlineCount = details.filter((detail) => detail.online === true).length;
|
||||||
|
logStatusInfo(`[Status][Qobuz] Provider check completed: ${onlineCount}/${details.length} providers online.`);
|
||||||
|
for (const detail of details) {
|
||||||
|
const label = detail.label || detail.target || "Unknown provider";
|
||||||
|
const suffix = detail.message ? ` - ${truncateStatusMessage(detail.message)}` : "";
|
||||||
|
if (detail.online) {
|
||||||
|
logStatusInfo(`[Status][Qobuz] ${label}: online${suffix}`);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
logStatusWarning(`[Status][Qobuz] ${label}: offline${suffix}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (report.online) {
|
||||||
|
logStatusInfo(`[Status][Qobuz] SpotiFLAC Qobuz is online (${onlineCount}/${details.length} providers online).`);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
logStatusWarning(`[Status][Qobuz] SpotiFLAC Qobuz marked maintenance because all ${details.length} providers are offline.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function anyNextVariantUp(values: Array<string | undefined>): ApiCheckStatus {
|
||||||
|
return values.some((value) => value === "up") ? "online" : "offline";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNextSourceValues(payload: SpotiFLACNextStatusResponse, source: SpotiFLACNextSource): string[] {
|
||||||
|
if (source.statusKey) {
|
||||||
|
const value = payload[source.statusKey];
|
||||||
|
return typeof value === "string" ? [value] : [];
|
||||||
|
}
|
||||||
|
if (!source.statusPrefix) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const values: string[] = [];
|
||||||
|
for (const [key, value] of Object.entries(payload)) {
|
||||||
|
if (key.startsWith(source.statusPrefix) && typeof value === "string") {
|
||||||
|
values.push(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCurrentAmazonStatus(payload: SpotiFLACNextStatusResponse): ApiCheckStatus {
|
||||||
|
return payload[SPOTIFLAC_CURRENT_AMAZON_STATUS_KEY] === "up" ? "online" : "offline";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSafeNextStatusesFallback(currentStatuses: Record<string, ApiCheckStatus>): Record<string, ApiCheckStatus> {
|
||||||
|
return SPOTIFLAC_NEXT_SOURCES.reduce<Record<string, ApiCheckStatus>>((acc, source) => {
|
||||||
|
const current = currentStatuses[source.id];
|
||||||
|
acc[source.id] = current === "online" || current === "offline" ? current : "idle";
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasCurrentResults(): boolean {
|
||||||
|
return API_SOURCES.some((source) => {
|
||||||
|
const status = apiStatusState.statuses[source.id];
|
||||||
|
return status === "online" || status === "offline";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasSpotiFLACNextResults(): boolean {
|
||||||
|
return SPOTIFLAC_NEXT_SOURCES.some((source) => {
|
||||||
|
const status = apiStatusState.nextStatuses[source.id];
|
||||||
|
return status === "online" || status === "offline";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchSpotiFLACStatusPayloadOnce(): Promise<SpotiFLACNextStatusResponse> {
|
||||||
|
const response = await withTimeout(fetch(SPOTIFLAC_STATUS_URL, {
|
||||||
|
method: "GET",
|
||||||
|
cache: "no-store",
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
},
|
||||||
|
}), CHECK_TIMEOUT_MS, "SpotiFLAC status check timed out after 10 seconds");
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`SpotiFLAC status returned ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await response.json()) as SpotiFLACNextStatusResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchSpotiFLACStatusPayload(): Promise<SpotiFLACNextStatusResponse> {
|
||||||
|
if (activeStatusPayloadFetch) {
|
||||||
|
return activeStatusPayloadFetch;
|
||||||
|
}
|
||||||
|
|
||||||
|
activeStatusPayloadFetch = (async () => {
|
||||||
|
let lastError: unknown = null;
|
||||||
|
for (let attempt = 1; attempt <= SPOTIFLAC_STATUS_MAX_ATTEMPTS; attempt++) {
|
||||||
|
try {
|
||||||
|
return await fetchSpotiFLACStatusPayloadOnce();
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
if (attempt < SPOTIFLAC_STATUS_MAX_ATTEMPTS) {
|
||||||
|
await delay(SPOTIFLAC_STATUS_RETRY_DELAY_MS * attempt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastError instanceof Error ? lastError : new Error("SpotiFLAC status check failed");
|
||||||
|
})();
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await activeStatusPayloadFetch;
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
activeStatusPayloadFetch = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkSourceStatus(source: ApiSource): Promise<ApiCheckStatus> {
|
||||||
|
try {
|
||||||
|
if (source.id === "tidal") {
|
||||||
|
const customTidalApi = getSettings().customTidalApi;
|
||||||
|
if (!hasConfiguredCustomTidalApi(customTidalApi)) {
|
||||||
|
logStatusWarning("[Status][Tidal] Marked maintenance because no custom Tidal instance is configured.");
|
||||||
|
return "offline";
|
||||||
|
}
|
||||||
|
const isOnline = await withTimeout(CheckCustomTidalAPI(customTidalApi), CHECK_TIMEOUT_MS, `API status check timed out after 10 seconds for ${source.name}`);
|
||||||
|
return isOnline ? "online" : "offline";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (source.id === "amazon") {
|
||||||
|
const payload = await fetchSpotiFLACStatusPayload();
|
||||||
|
return getCurrentAmazonStatus(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (source.id === "qobuz") {
|
||||||
|
logStatusInfo("[Status][Qobuz] Checking current SpotiFLAC providers...");
|
||||||
|
const report = await withTimeout(CheckAPIStatusReport(source.type, source.url), CHECK_TIMEOUT_MS, `API status report timed out after 10 seconds for ${source.name}`);
|
||||||
|
logQobuzStatusReport(report);
|
||||||
|
return report.online ? "online" : "offline";
|
||||||
|
}
|
||||||
|
|
||||||
|
const isOnline = await withTimeout(CheckAPIStatus(source.type, source.url), CHECK_TIMEOUT_MS, `API status check timed out after 10 seconds for ${source.name}`);
|
||||||
|
return isOnline ? "online" : "offline";
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
if (source.id === "qobuz") {
|
||||||
|
logStatusError(`[Status][Qobuz] Provider check failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
return "offline";
|
return "offline";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function checkSpotiFLACNextStatuses(): Promise<Record<string, ApiCheckStatus>> {
|
||||||
|
const payload = await fetchSpotiFLACStatusPayload();
|
||||||
|
return SPOTIFLAC_NEXT_SOURCES.reduce<Record<string, ApiCheckStatus>>((acc, source) => {
|
||||||
|
acc[source.id] = anyNextVariantUp(getNextSourceValues(payload, source));
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
export function getApiStatusState(): ApiStatusState {
|
export function getApiStatusState(): ApiStatusState {
|
||||||
return apiStatusState;
|
return apiStatusState;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function subscribeApiStatus(listener: () => void): () => void {
|
export function subscribeApiStatus(listener: () => void): () => void {
|
||||||
listeners.add(listener);
|
listeners.add(listener);
|
||||||
return () => {
|
return () => {
|
||||||
listeners.delete(listener);
|
listeners.delete(listener);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
export function hasApiStatusResults(): boolean {
|
|
||||||
return API_SOURCES.some((source) => {
|
export async function checkCurrentApiStatusesOnly(): Promise<void> {
|
||||||
const status = apiStatusState.statuses[source.id];
|
if (activeCheckCurrentOnly) {
|
||||||
return status === "online" || status === "offline";
|
return activeCheckCurrentOnly;
|
||||||
});
|
}
|
||||||
}
|
|
||||||
export function ensureApiStatusCheckStarted(): void {
|
activeCheckCurrentOnly = (async () => {
|
||||||
if (!activeCheckAll && !hasApiStatusResults()) {
|
await Promise.all(API_SOURCES.map((source) => checkApiStatus(source.id)));
|
||||||
void checkAllApiStatuses(false);
|
})();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await activeCheckCurrentOnly;
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
activeCheckCurrentOnly = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export async function checkAllApiStatuses(forceRefresh: boolean = false): Promise<void> {
|
|
||||||
if (activeCheckAll) {
|
export async function checkSpotiFLACNextStatusesOnly(): Promise<void> {
|
||||||
return activeCheckAll;
|
if (activeCheckNextOnly) {
|
||||||
|
return activeCheckNextOnly;
|
||||||
}
|
}
|
||||||
activeCheckAll = (async () => {
|
|
||||||
const checkingStatuses = Object.fromEntries(API_SOURCES.map((source) => [source.id, "checking" as ApiCheckStatus]));
|
activeCheckNextOnly = (async () => {
|
||||||
|
const checkingNextStatuses = Object.fromEntries(SPOTIFLAC_NEXT_SOURCES.map((source) => [source.id, "checking" as ApiCheckStatus]));
|
||||||
setApiStatusState((current) => ({
|
setApiStatusState((current) => ({
|
||||||
...current,
|
...current,
|
||||||
isCheckingAll: true,
|
nextStatuses: {
|
||||||
statuses: {
|
...current.nextStatuses,
|
||||||
...current.statuses,
|
...checkingNextStatuses,
|
||||||
...checkingStatuses,
|
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [unifiedResult, musicBrainzStatus] = await Promise.allSettled([
|
const nextStatuses = await checkSpotiFLACNextStatuses();
|
||||||
withTimeout(fetchUnifiedStatuses(forceRefresh), CHECK_TIMEOUT_MS, "Unified SpotiFLAC status check timed out after 10 seconds"),
|
setApiStatusState((current) => ({
|
||||||
checkMusicBrainzStatus(),
|
|
||||||
]);
|
|
||||||
setApiStatusState((current) => {
|
|
||||||
const nextStatuses = { ...current.statuses };
|
|
||||||
if (unifiedResult.status === "fulfilled") {
|
|
||||||
Object.assign(nextStatuses, unifiedResult.value.statuses);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
nextStatuses.tidal1 = "offline";
|
|
||||||
nextStatuses.tidal2 = "offline";
|
|
||||||
nextStatuses.tidal3 = "offline";
|
|
||||||
nextStatuses.tidal4 = "offline";
|
|
||||||
nextStatuses.tidal5 = "offline";
|
|
||||||
nextStatuses.tidal6 = "offline";
|
|
||||||
nextStatuses.tidal7 = "offline";
|
|
||||||
nextStatuses.qobuz1 = "offline";
|
|
||||||
nextStatuses.qobuz2 = "offline";
|
|
||||||
nextStatuses.qobuz3 = "offline";
|
|
||||||
nextStatuses.amazon1 = "offline";
|
|
||||||
nextStatuses.lrclib = "offline";
|
|
||||||
}
|
|
||||||
nextStatuses.musicbrainz =
|
|
||||||
musicBrainzStatus.status === "fulfilled" ? musicBrainzStatus.value : "offline";
|
|
||||||
return {
|
|
||||||
...current,
|
...current,
|
||||||
statuses: nextStatuses,
|
nextStatuses: {
|
||||||
};
|
...current.nextStatuses,
|
||||||
});
|
...nextStatuses,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
setApiStatusState((current) => ({
|
||||||
|
...current,
|
||||||
|
nextStatuses: getSafeNextStatusesFallback(current.nextStatuses),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await activeCheckNextOnly;
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
activeCheckNextOnly = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureApiStatusCheckStarted(): void {
|
||||||
|
if (!activeCheckCurrentOnly && !hasCurrentResults()) {
|
||||||
|
void checkCurrentApiStatusesOnly();
|
||||||
|
}
|
||||||
|
if (!activeCheckNextOnly && !hasSpotiFLACNextResults()) {
|
||||||
|
void checkSpotiFLACNextStatusesOnly();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureSpotiFLACNextStatusCheckStarted(): void {
|
||||||
|
ensureApiStatusCheckStarted();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkApiStatus(sourceId: string): Promise<void> {
|
||||||
|
const source = API_SOURCES.find((item) => item.id === sourceId);
|
||||||
|
if (!source) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeCheck = activeSourceChecks.get(sourceId);
|
||||||
|
if (activeCheck) {
|
||||||
|
return activeCheck;
|
||||||
|
}
|
||||||
|
|
||||||
|
const task = (async () => {
|
||||||
|
setApiStatusState((current) => ({
|
||||||
|
...current,
|
||||||
|
checkingSources: {
|
||||||
|
...current.checkingSources,
|
||||||
|
[sourceId]: true,
|
||||||
|
},
|
||||||
|
statuses: {
|
||||||
|
...current.statuses,
|
||||||
|
[sourceId]: "checking",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const status = await checkSourceStatus(source);
|
||||||
|
setApiStatusState((current) => ({
|
||||||
|
...current,
|
||||||
|
statuses: {
|
||||||
|
...current.statuses,
|
||||||
|
[sourceId]: status,
|
||||||
|
},
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
setApiStatusState((current) => ({
|
setApiStatusState((current) => ({
|
||||||
...current,
|
...current,
|
||||||
isCheckingAll: false,
|
checkingSources: {
|
||||||
|
...current.checkingSources,
|
||||||
|
[sourceId]: false,
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
activeCheckAll = null;
|
activeSourceChecks.delete(sourceId);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
return activeCheckAll;
|
|
||||||
|
activeSourceChecks.set(sourceId, task);
|
||||||
|
return task;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { getPreviewVolume, PREVIEW_VOLUME_CHANGED_EVENT } from "@/lib/preview";
|
||||||
|
export interface PreviewPlayback {
|
||||||
|
audio: HTMLAudioElement;
|
||||||
|
destroy: () => void;
|
||||||
|
}
|
||||||
|
export async function createPreviewPlayback(url: string, volume: number): Promise<PreviewPlayback> {
|
||||||
|
const audio = new Audio(url);
|
||||||
|
const applyVolume = (nextVolume: number) => {
|
||||||
|
if (!Number.isFinite(nextVolume)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
audio.volume = Math.min(1, Math.max(0, nextVolume));
|
||||||
|
};
|
||||||
|
applyVolume(volume);
|
||||||
|
const handleSettingsUpdated = () => {
|
||||||
|
applyVolume(getPreviewVolume());
|
||||||
|
};
|
||||||
|
const handlePreviewVolumeChanged = (event: Event) => {
|
||||||
|
const nextVolumePercent = (event as CustomEvent<number>).detail;
|
||||||
|
if (!Number.isFinite(nextVolumePercent)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
applyVolume(nextVolumePercent / 100);
|
||||||
|
};
|
||||||
|
window.addEventListener("settingsUpdated", handleSettingsUpdated);
|
||||||
|
window.addEventListener(PREVIEW_VOLUME_CHANGED_EVENT, handlePreviewVolumeChanged);
|
||||||
|
return {
|
||||||
|
audio,
|
||||||
|
destroy: () => {
|
||||||
|
window.removeEventListener("settingsUpdated", handleSettingsUpdated);
|
||||||
|
window.removeEventListener(PREVIEW_VOLUME_CHANGED_EVENT, handlePreviewVolumeChanged);
|
||||||
|
audio.pause();
|
||||||
|
audio.removeAttribute("src");
|
||||||
|
audio.load();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1 +1,10 @@
|
|||||||
|
import { getSettings } from "@/lib/settings";
|
||||||
export const SPOTIFY_PREVIEW_VOLUME = 1;
|
export const SPOTIFY_PREVIEW_VOLUME = 1;
|
||||||
|
export const PREVIEW_VOLUME_CHANGED_EVENT = "previewVolumeChanged";
|
||||||
|
export function getPreviewVolume(): number {
|
||||||
|
const previewVolume = getSettings().previewVolume;
|
||||||
|
if (!Number.isFinite(previewVolume)) {
|
||||||
|
return SPOTIFY_PREVIEW_VOLUME;
|
||||||
|
}
|
||||||
|
return Math.min(1, Math.max(0, previewVolume / 100));
|
||||||
|
}
|
||||||
|
|||||||
+605
-229
@@ -1,15 +1,32 @@
|
|||||||
import { GetDefaults, LoadSettings, SaveSettings as SaveToBackend } from "../../wailsjs/go/main/App";
|
import { GetDefaults, LoadFonts as LoadFontsFromBackend, LoadSettings, SaveFonts as SaveFontsToBackend, SaveSettings as SaveToBackend, } from "../../wailsjs/go/main/App";
|
||||||
export type FontFamily = "google-sans" | "inter" | "poppins" | "roboto" | "dm-sans" | "plus-jakarta-sans" | "manrope" | "space-grotesk" | "noto-sans" | "nunito-sans" | "figtree" | "raleway" | "public-sans" | "outfit" | "jetbrains-mono" | "geist-sans" | "bricolage-grotesque";
|
export type BuiltInFontFamily = "google-sans" | "inter" | "poppins" | "roboto" | "dm-sans" | "plus-jakarta-sans" | "manrope" | "space-grotesk" | "noto-sans" | "nunito-sans" | "figtree" | "raleway" | "public-sans" | "outfit" | "jetbrains-mono" | "geist-sans" | "bricolage-grotesque";
|
||||||
|
export type CustomFontFamily = `custom-${string}`;
|
||||||
|
export type FontFamily = BuiltInFontFamily | CustomFontFamily;
|
||||||
|
export interface CustomFontOption {
|
||||||
|
value: CustomFontFamily;
|
||||||
|
label: string;
|
||||||
|
fontFamily: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
export type FontOption = {
|
||||||
|
value: FontFamily;
|
||||||
|
label: string;
|
||||||
|
fontFamily: string;
|
||||||
|
url?: string;
|
||||||
|
};
|
||||||
export type FolderPreset = "none" | "artist" | "album" | "year-album" | "year-artist-album" | "artist-album" | "artist-year-album" | "artist-year-nested-album" | "album-artist" | "album-artist-album" | "album-artist-year-album" | "album-artist-year-nested-album" | "year" | "year-artist" | "custom";
|
export type FolderPreset = "none" | "artist" | "album" | "year-album" | "year-artist-album" | "artist-album" | "artist-year-album" | "artist-year-nested-album" | "album-artist" | "album-artist-album" | "album-artist-year-album" | "album-artist-year-nested-album" | "year" | "year-artist" | "custom";
|
||||||
export type FilenamePreset = "title" | "title-artist" | "artist-title" | "track-title" | "track-title-artist" | "track-artist-title" | "title-album-artist" | "track-title-album-artist" | "artist-album-title" | "track-dash-title" | "disc-track-title" | "disc-track-title-artist" | "custom";
|
export type FilenamePreset = "title" | "title-artist" | "artist-title" | "track-title" | "track-title-artist" | "track-artist-title" | "title-album-artist" | "track-title-album-artist" | "artist-album-title" | "track-dash-title" | "disc-track-title" | "disc-track-title-artist" | "custom";
|
||||||
|
export type ExistingFileCheckMode = "filename" | "isrc";
|
||||||
export interface Settings {
|
export interface Settings {
|
||||||
downloadPath: string;
|
downloadPath: string;
|
||||||
downloader: "auto" | "tidal" | "qobuz" | "amazon";
|
downloader: "auto" | "tidal" | "qobuz" | "amazon";
|
||||||
|
customTidalApi: string;
|
||||||
linkResolver: "songstats" | "songlink";
|
linkResolver: "songstats" | "songlink";
|
||||||
allowResolverFallback: boolean;
|
allowResolverFallback: boolean;
|
||||||
theme: string;
|
theme: string;
|
||||||
themeMode: "auto" | "light" | "dark";
|
themeMode: "auto" | "light" | "dark";
|
||||||
fontFamily: FontFamily;
|
fontFamily: FontFamily;
|
||||||
|
customFonts: CustomFontOption[];
|
||||||
folderPreset: FolderPreset;
|
folderPreset: FolderPreset;
|
||||||
folderTemplate: string;
|
folderTemplate: string;
|
||||||
filenamePreset: FilenamePreset;
|
filenamePreset: FilenamePreset;
|
||||||
@@ -31,6 +48,8 @@ export interface Settings {
|
|||||||
createPlaylistFolder: boolean;
|
createPlaylistFolder: boolean;
|
||||||
playlistOwnerFolderName: boolean;
|
playlistOwnerFolderName: boolean;
|
||||||
createM3u8File: boolean;
|
createM3u8File: boolean;
|
||||||
|
previewVolume: number;
|
||||||
|
existingFileCheckMode: ExistingFileCheckMode;
|
||||||
useFirstArtistOnly: boolean;
|
useFirstArtistOnly: boolean;
|
||||||
useSingleGenre: boolean;
|
useSingleGenre: boolean;
|
||||||
embedGenre: boolean;
|
embedGenre: boolean;
|
||||||
@@ -41,54 +60,105 @@ export const FOLDER_PRESETS: Record<FolderPreset, {
|
|||||||
label: string;
|
label: string;
|
||||||
template: string;
|
template: string;
|
||||||
}> = {
|
}> = {
|
||||||
"none": { label: "No Subfolder", template: "" },
|
none: { label: "No Subfolder", template: "" },
|
||||||
"artist": { label: "Artist", template: "{artist}" },
|
artist: { label: "Artist", template: "{artist}" },
|
||||||
"album": { label: "Album", template: "{album}" },
|
album: { label: "Album", template: "{album}" },
|
||||||
"year-album": { label: "[Year] Album", template: "[{year}] {album}" },
|
"year-album": { label: "[Year] Album", template: "[{year}] {album}" },
|
||||||
"year-artist-album": { label: "[Year] Artist - Album", template: "[{year}] {artist} - {album}" },
|
"year-artist-album": {
|
||||||
|
label: "[Year] Artist - Album",
|
||||||
|
template: "[{year}] {artist} - {album}",
|
||||||
|
},
|
||||||
"artist-album": { label: "Artist / Album", template: "{artist}/{album}" },
|
"artist-album": { label: "Artist / Album", template: "{artist}/{album}" },
|
||||||
"artist-year-album": { label: "Artist / [Year] Album", template: "{artist}/[{year}] {album}" },
|
"artist-year-album": {
|
||||||
"artist-year-nested-album": { label: "Artist / Year / Album", template: "{artist}/{year}/{album}" },
|
label: "Artist / [Year] Album",
|
||||||
|
template: "{artist}/[{year}] {album}",
|
||||||
|
},
|
||||||
|
"artist-year-nested-album": {
|
||||||
|
label: "Artist / Year / Album",
|
||||||
|
template: "{artist}/{year}/{album}",
|
||||||
|
},
|
||||||
"album-artist": { label: "Album Artist", template: "{album_artist}" },
|
"album-artist": { label: "Album Artist", template: "{album_artist}" },
|
||||||
"album-artist-album": { label: "Album Artist / Album", template: "{album_artist}/{album}" },
|
"album-artist-album": {
|
||||||
"album-artist-year-album": { label: "Album Artist / [Year] Album", template: "{album_artist}/[{year}] {album}" },
|
label: "Album Artist / Album",
|
||||||
"album-artist-year-nested-album": { label: "Album Artist / Year / Album", template: "{album_artist}/{year}/{album}" },
|
template: "{album_artist}/{album}",
|
||||||
"year": { label: "Year", template: "{year}" },
|
},
|
||||||
|
"album-artist-year-album": {
|
||||||
|
label: "Album Artist / [Year] Album",
|
||||||
|
template: "{album_artist}/[{year}] {album}",
|
||||||
|
},
|
||||||
|
"album-artist-year-nested-album": {
|
||||||
|
label: "Album Artist / Year / Album",
|
||||||
|
template: "{album_artist}/{year}/{album}",
|
||||||
|
},
|
||||||
|
year: { label: "Year", template: "{year}" },
|
||||||
"year-artist": { label: "Year / Artist", template: "{year}/{artist}" },
|
"year-artist": { label: "Year / Artist", template: "{year}/{artist}" },
|
||||||
"custom": { label: "Custom...", template: "{artist}/{album}" },
|
custom: { label: "Custom...", template: "{artist}/{album}" },
|
||||||
};
|
};
|
||||||
export const FILENAME_PRESETS: Record<FilenamePreset, {
|
export const FILENAME_PRESETS: Record<FilenamePreset, {
|
||||||
label: string;
|
label: string;
|
||||||
template: string;
|
template: string;
|
||||||
}> = {
|
}> = {
|
||||||
"title": { label: "Title", template: "{title}" },
|
title: { label: "Title", template: "{title}" },
|
||||||
"title-artist": { label: "Title - Artist", template: "{title} - {artist}" },
|
"title-artist": { label: "Title - Artist", template: "{title} - {artist}" },
|
||||||
"artist-title": { label: "Artist - Title", template: "{artist} - {title}" },
|
"artist-title": { label: "Artist - Title", template: "{artist} - {title}" },
|
||||||
"track-title": { label: "Track. Title", template: "{track}. {title}" },
|
"track-title": { label: "Track. Title", template: "{track}. {title}" },
|
||||||
"track-title-artist": { label: "Track. Title - Artist", template: "{track}. {title} - {artist}" },
|
"track-title-artist": {
|
||||||
"track-artist-title": { label: "Track. Artist - Title", template: "{track}. {artist} - {title}" },
|
label: "Track. Title - Artist",
|
||||||
"title-album-artist": { label: "Title - Album Artist", template: "{title} - {album_artist}" },
|
template: "{track}. {title} - {artist}",
|
||||||
"track-title-album-artist": { label: "Track. Title - Album Artist", template: "{track}. {title} - {album_artist}" },
|
},
|
||||||
"artist-album-title": { label: "Artist - Album - Title", template: "{artist} - {album} - {title}" },
|
"track-artist-title": {
|
||||||
|
label: "Track. Artist - Title",
|
||||||
|
template: "{track}. {artist} - {title}",
|
||||||
|
},
|
||||||
|
"title-album-artist": {
|
||||||
|
label: "Title - Album Artist",
|
||||||
|
template: "{title} - {album_artist}",
|
||||||
|
},
|
||||||
|
"track-title-album-artist": {
|
||||||
|
label: "Track. Title - Album Artist",
|
||||||
|
template: "{track}. {title} - {album_artist}",
|
||||||
|
},
|
||||||
|
"artist-album-title": {
|
||||||
|
label: "Artist - Album - Title",
|
||||||
|
template: "{artist} - {album} - {title}",
|
||||||
|
},
|
||||||
"track-dash-title": { label: "Track - Title", template: "{track} - {title}" },
|
"track-dash-title": { label: "Track - Title", template: "{track} - {title}" },
|
||||||
"disc-track-title": { label: "Disc-Track. Title", template: "{disc}-{track}. {title}" },
|
"disc-track-title": {
|
||||||
"disc-track-title-artist": { label: "Disc-Track. Title - Artist", template: "{disc}-{track}. {title} - {artist}" },
|
label: "Disc-Track. Title",
|
||||||
"custom": { label: "Custom...", template: "{title} - {artist}" },
|
template: "{disc}-{track}. {title}",
|
||||||
|
},
|
||||||
|
"disc-track-title-artist": {
|
||||||
|
label: "Disc-Track. Title - Artist",
|
||||||
|
template: "{disc}-{track}. {title} - {artist}",
|
||||||
|
},
|
||||||
|
custom: { label: "Custom...", template: "{title} - {artist}" },
|
||||||
};
|
};
|
||||||
export const TEMPLATE_VARIABLES = [
|
export const TEMPLATE_VARIABLES = [
|
||||||
{ key: "{title}", description: "Track title", example: "Shake It Off" },
|
{ key: "{title}", description: "Track title", example: "Shake It Off" },
|
||||||
{ key: "{artist}", description: "Track artist", example: "Taylor Swift" },
|
{ key: "{artist}", description: "Track artist", example: "Taylor Swift" },
|
||||||
{ key: "{album}", description: "Album name", example: "1989" },
|
{ key: "{album}", description: "Album name", example: "1989" },
|
||||||
{ key: "{album_artist}", description: "Album artist", example: "Taylor Swift" },
|
{
|
||||||
|
key: "{album_artist}",
|
||||||
|
description: "Album artist",
|
||||||
|
example: "Taylor Swift",
|
||||||
|
},
|
||||||
{ key: "{track}", description: "Track number", example: "01" },
|
{ key: "{track}", description: "Track number", example: "01" },
|
||||||
{ key: "{disc}", description: "Disc number", example: "1" },
|
{ key: "{disc}", description: "Disc number", example: "1" },
|
||||||
{ key: "{year}", description: "Release year", example: "2014" },
|
{ key: "{year}", description: "Release year", example: "2014" },
|
||||||
{ key: "{date}", description: "Release date (YYYY-MM-DD)", example: "2014-10-27" },
|
{
|
||||||
{ key: "{isrc}", description: "Track ISRC", example: "USUM71412345" },
|
key: "{date}",
|
||||||
|
description: "Release date (YYYY-MM-DD)",
|
||||||
|
example: "2014-10-27",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "{isrc}",
|
||||||
|
description: "Track ISRC",
|
||||||
|
example: "USUM71412345",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
function detectOS(): "Windows" | "linux/MacOS" {
|
function detectOS(): "Windows" | "linux/MacOS" {
|
||||||
const platform = window.navigator.platform.toLowerCase();
|
const platform = window.navigator.platform.toLowerCase();
|
||||||
if (platform.includes('win')) {
|
if (platform.includes("win")) {
|
||||||
return "Windows";
|
return "Windows";
|
||||||
}
|
}
|
||||||
return "linux/MacOS";
|
return "linux/MacOS";
|
||||||
@@ -96,11 +166,13 @@ function detectOS(): "Windows" | "linux/MacOS" {
|
|||||||
export const DEFAULT_SETTINGS: Settings = {
|
export const DEFAULT_SETTINGS: Settings = {
|
||||||
downloadPath: "",
|
downloadPath: "",
|
||||||
downloader: "auto",
|
downloader: "auto",
|
||||||
|
customTidalApi: "",
|
||||||
linkResolver: "songlink",
|
linkResolver: "songlink",
|
||||||
allowResolverFallback: true,
|
allowResolverFallback: true,
|
||||||
theme: "yellow",
|
theme: "yellow",
|
||||||
themeMode: "auto",
|
themeMode: "auto",
|
||||||
fontFamily: "google-sans",
|
fontFamily: "google-sans",
|
||||||
|
customFonts: [],
|
||||||
folderPreset: "none",
|
folderPreset: "none",
|
||||||
folderTemplate: "",
|
folderTemplate: "",
|
||||||
filenamePreset: "title-artist",
|
filenamePreset: "title-artist",
|
||||||
@@ -113,48 +185,497 @@ export const DEFAULT_SETTINGS: Settings = {
|
|||||||
tidalQuality: "LOSSLESS",
|
tidalQuality: "LOSSLESS",
|
||||||
qobuzQuality: "6",
|
qobuzQuality: "6",
|
||||||
amazonQuality: "original",
|
amazonQuality: "original",
|
||||||
autoOrder: "tidal-qobuz-amazon",
|
autoOrder: "qobuz-amazon",
|
||||||
autoQuality: "16",
|
autoQuality: "16",
|
||||||
allowFallback: true,
|
allowFallback: true,
|
||||||
createPlaylistFolder: true,
|
createPlaylistFolder: true,
|
||||||
playlistOwnerFolderName: false,
|
playlistOwnerFolderName: false,
|
||||||
createM3u8File: false,
|
createM3u8File: false,
|
||||||
|
previewVolume: 100,
|
||||||
|
existingFileCheckMode: "filename",
|
||||||
useFirstArtistOnly: false,
|
useFirstArtistOnly: false,
|
||||||
useSingleGenre: false,
|
useSingleGenre: false,
|
||||||
embedGenre: false,
|
embedGenre: false,
|
||||||
redownloadWithSuffix: false,
|
redownloadWithSuffix: false,
|
||||||
separator: "semicolon"
|
separator: "semicolon",
|
||||||
};
|
};
|
||||||
export const FONT_OPTIONS: {
|
export const FONT_OPTIONS: FontOption[] = [
|
||||||
value: FontFamily;
|
{
|
||||||
label: string;
|
value: "bricolage-grotesque",
|
||||||
fontFamily: string;
|
label: "Bricolage Grotesque",
|
||||||
}[] = [
|
fontFamily: '"Bricolage Grotesque", system-ui, sans-serif',
|
||||||
{ value: "bricolage-grotesque", label: "Bricolage Grotesque", fontFamily: '"Bricolage Grotesque", system-ui, sans-serif' },
|
},
|
||||||
{ value: "dm-sans", label: "DM Sans", fontFamily: '"DM Sans", system-ui, sans-serif' },
|
{
|
||||||
{ value: "figtree", label: "Figtree", fontFamily: '"Figtree", system-ui, sans-serif' },
|
value: "dm-sans",
|
||||||
{ value: "geist-sans", label: "Geist Sans", fontFamily: '"Geist", system-ui, sans-serif' },
|
label: "DM Sans",
|
||||||
{ value: "google-sans", label: "Google Sans", fontFamily: '"Google Sans", system-ui, sans-serif' },
|
fontFamily: '"DM Sans", system-ui, sans-serif',
|
||||||
{ value: "inter", label: "Inter", fontFamily: '"Inter", system-ui, sans-serif' },
|
},
|
||||||
{ value: "jetbrains-mono", label: "JetBrains Mono", fontFamily: '"JetBrains Mono", ui-monospace, monospace' },
|
{
|
||||||
{ value: "manrope", label: "Manrope", fontFamily: '"Manrope", system-ui, sans-serif' },
|
value: "figtree",
|
||||||
{ value: "noto-sans", label: "Noto Sans", fontFamily: '"Noto Sans", system-ui, sans-serif' },
|
label: "Figtree",
|
||||||
{ value: "nunito-sans", label: "Nunito Sans", fontFamily: '"Nunito Sans", system-ui, sans-serif' },
|
fontFamily: '"Figtree", system-ui, sans-serif',
|
||||||
{ value: "outfit", label: "Outfit", fontFamily: '"Outfit", system-ui, sans-serif' },
|
},
|
||||||
{ value: "plus-jakarta-sans", label: "Plus Jakarta Sans", fontFamily: '"Plus Jakarta Sans", system-ui, sans-serif' },
|
{
|
||||||
{ value: "poppins", label: "Poppins", fontFamily: '"Poppins", system-ui, sans-serif' },
|
value: "geist-sans",
|
||||||
{ value: "public-sans", label: "Public Sans", fontFamily: '"Public Sans", system-ui, sans-serif' },
|
label: "Geist Sans",
|
||||||
{ value: "raleway", label: "Raleway", fontFamily: '"Raleway", system-ui, sans-serif' },
|
fontFamily: '"Geist", system-ui, sans-serif',
|
||||||
{ value: "roboto", label: "Roboto", fontFamily: '"Roboto", system-ui, sans-serif' },
|
},
|
||||||
{ value: "space-grotesk", label: "Space Grotesk", fontFamily: '"Space Grotesk", system-ui, sans-serif' },
|
{
|
||||||
|
value: "google-sans",
|
||||||
|
label: "Google Sans",
|
||||||
|
fontFamily: '"Google Sans", system-ui, sans-serif',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "inter",
|
||||||
|
label: "Inter",
|
||||||
|
fontFamily: '"Inter", system-ui, sans-serif',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "jetbrains-mono",
|
||||||
|
label: "JetBrains Mono",
|
||||||
|
fontFamily: '"JetBrains Mono", ui-monospace, monospace',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "manrope",
|
||||||
|
label: "Manrope",
|
||||||
|
fontFamily: '"Manrope", system-ui, sans-serif',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "noto-sans",
|
||||||
|
label: "Noto Sans",
|
||||||
|
fontFamily: '"Noto Sans", system-ui, sans-serif',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "nunito-sans",
|
||||||
|
label: "Nunito Sans",
|
||||||
|
fontFamily: '"Nunito Sans", system-ui, sans-serif',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "outfit",
|
||||||
|
label: "Outfit",
|
||||||
|
fontFamily: '"Outfit", system-ui, sans-serif',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "plus-jakarta-sans",
|
||||||
|
label: "Plus Jakarta Sans",
|
||||||
|
fontFamily: '"Plus Jakarta Sans", system-ui, sans-serif',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "poppins",
|
||||||
|
label: "Poppins",
|
||||||
|
fontFamily: '"Poppins", system-ui, sans-serif',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "public-sans",
|
||||||
|
label: "Public Sans",
|
||||||
|
fontFamily: '"Public Sans", system-ui, sans-serif',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "raleway",
|
||||||
|
label: "Raleway",
|
||||||
|
fontFamily: '"Raleway", system-ui, sans-serif',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "roboto",
|
||||||
|
label: "Roboto",
|
||||||
|
fontFamily: '"Roboto", system-ui, sans-serif',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "space-grotesk",
|
||||||
|
label: "Space Grotesk",
|
||||||
|
fontFamily: '"Space Grotesk", system-ui, sans-serif',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
export function applyFont(fontFamily: FontFamily): void {
|
const BUILT_IN_FONT_VALUES = new Set(FONT_OPTIONS.map((font) => font.value));
|
||||||
const font = FONT_OPTIONS.find(f => f.value === fontFamily);
|
const GOOGLE_FONT_LINK_ID_PREFIX = "spotiflac-custom-font-";
|
||||||
|
const GOOGLE_FONTS_CSS_HOST = "fonts.googleapis.com";
|
||||||
|
const GOOGLE_FONTS_SPECIMEN_HOST = "fonts.google.com";
|
||||||
|
const SETTINGS_KEY = "spotiflac-settings";
|
||||||
|
let cachedSettings: Settings | null = null;
|
||||||
|
type SettingsPayload = Partial<Settings> & {
|
||||||
|
darkMode?: boolean;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
const KNOWN_SETTINGS_KEYS = Object.keys(DEFAULT_SETTINGS) as Array<keyof Settings>;
|
||||||
|
function extractGoogleFontInputUrl(input: string): string {
|
||||||
|
const trimmed = input.trim();
|
||||||
|
const hrefMatch = trimmed.match(/\bhref=["']([^"']+)["']/i);
|
||||||
|
if (hrefMatch?.[1]) {
|
||||||
|
return hrefMatch[1];
|
||||||
|
}
|
||||||
|
const importMatch = trimmed.match(/@import\s+url\(["']?([^"')]+)["']?\)/i);
|
||||||
|
if (importMatch?.[1]) {
|
||||||
|
return importMatch[1];
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
function coerceGoogleFontUrl(rawUrl: string): string {
|
||||||
|
const trimmed = rawUrl.trim();
|
||||||
|
if (/^https?:\/\//i.test(trimmed)) {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
if (/^(fonts\.googleapis\.com|fonts\.google\.com)\//i.test(trimmed)) {
|
||||||
|
return `https://${trimmed}`;
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
function normalizeFontLabel(label: string): string {
|
||||||
|
return label.replace(/\+/g, " ").replace(/\s+/g, " ").trim();
|
||||||
|
}
|
||||||
|
function slugifyFontLabel(label: string): string {
|
||||||
|
return label.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "font";
|
||||||
|
}
|
||||||
|
function toFontFamilyCss(label: string): string {
|
||||||
|
const escapedLabel = label.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||||||
|
return `"${escapedLabel}", system-ui, sans-serif`;
|
||||||
|
}
|
||||||
|
function buildGoogleFontsCssUrl(label: string): string {
|
||||||
|
const url = new URL("https://fonts.googleapis.com/css2");
|
||||||
|
url.searchParams.set("family", label);
|
||||||
|
url.searchParams.set("display", "swap");
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
function extractSpecimenFontLabel(parsed: URL): string {
|
||||||
|
const segments = parsed.pathname.split("/").filter(Boolean);
|
||||||
|
const specimenIndex = segments.findIndex((segment) => segment.toLowerCase() === "specimen");
|
||||||
|
const specimenName = specimenIndex >= 0 ? segments[specimenIndex + 1] : "";
|
||||||
|
return normalizeFontLabel(decodeURIComponent(specimenName || ""));
|
||||||
|
}
|
||||||
|
function normalizeGoogleFontCssUrl(rawUrl: string): string | null {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(coerceGoogleFontUrl(extractGoogleFontInputUrl(rawUrl)));
|
||||||
|
if (parsed.protocol !== "https:") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (parsed.hostname === GOOGLE_FONTS_SPECIMEN_HOST) {
|
||||||
|
const label = extractSpecimenFontLabel(parsed);
|
||||||
|
return label ? buildGoogleFontsCssUrl(label) : null;
|
||||||
|
}
|
||||||
|
if (parsed.hostname !== GOOGLE_FONTS_CSS_HOST ||
|
||||||
|
(parsed.pathname !== "/css" && parsed.pathname !== "/css2")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (parsed.searchParams.getAll("family").length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!parsed.searchParams.has("display")) {
|
||||||
|
parsed.searchParams.set("display", "swap");
|
||||||
|
}
|
||||||
|
return parsed.toString();
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export function parseGoogleFontUrl(rawUrl: string): CustomFontOption | null {
|
||||||
|
const normalizedUrl = normalizeGoogleFontCssUrl(rawUrl);
|
||||||
|
if (!normalizedUrl) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const parsed = new URL(normalizedUrl);
|
||||||
|
const family = parsed.searchParams.getAll("family")[0];
|
||||||
|
const label = normalizeFontLabel((family || "").split(":")[0] || "");
|
||||||
|
if (!label) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
value: `custom-${slugifyFontLabel(label)}` as CustomFontFamily,
|
||||||
|
label,
|
||||||
|
fontFamily: toFontFamilyCss(label),
|
||||||
|
url: normalizedUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function normalizeCustomFonts(customFonts: unknown): CustomFontOption[] {
|
||||||
|
if (!Array.isArray(customFonts)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const normalizedFonts: CustomFontOption[] = [];
|
||||||
|
const seenValues = new Set<string>();
|
||||||
|
const seenUrls = new Set<string>();
|
||||||
|
for (const item of customFonts) {
|
||||||
|
if (!item || typeof item !== "object") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const rawUrl = (item as {
|
||||||
|
url?: unknown;
|
||||||
|
}).url;
|
||||||
|
if (typeof rawUrl !== "string") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const parsed = parseGoogleFontUrl(rawUrl);
|
||||||
|
if (!parsed || seenValues.has(parsed.value) || seenUrls.has(parsed.url)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seenValues.add(parsed.value);
|
||||||
|
seenUrls.add(parsed.url);
|
||||||
|
normalizedFonts.push(parsed);
|
||||||
|
}
|
||||||
|
return normalizedFonts;
|
||||||
|
}
|
||||||
|
function normalizeFontFamily(fontFamily: unknown, customFonts: CustomFontOption[]): FontFamily {
|
||||||
|
if (typeof fontFamily !== "string") {
|
||||||
|
return DEFAULT_SETTINGS.fontFamily;
|
||||||
|
}
|
||||||
|
if (BUILT_IN_FONT_VALUES.has(fontFamily as BuiltInFontFamily)) {
|
||||||
|
return fontFamily as BuiltInFontFamily;
|
||||||
|
}
|
||||||
|
const customFont = customFonts.find((font) => font.value === fontFamily);
|
||||||
|
return customFont ? customFont.value : DEFAULT_SETTINGS.fontFamily;
|
||||||
|
}
|
||||||
|
export function getFontOptions(customFonts: CustomFontOption[] = []): FontOption[] {
|
||||||
|
return [...FONT_OPTIONS, ...normalizeCustomFonts(customFonts)];
|
||||||
|
}
|
||||||
|
export function loadGoogleFontUrl(url: string, id = `${GOOGLE_FONT_LINK_ID_PREFIX}preview`): void {
|
||||||
|
const normalizedUrl = normalizeGoogleFontCssUrl(url);
|
||||||
|
if (!normalizedUrl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let link = document.getElementById(id) as HTMLLinkElement | null;
|
||||||
|
if (!link) {
|
||||||
|
link = document.createElement("link");
|
||||||
|
link.id = id;
|
||||||
|
link.rel = "stylesheet";
|
||||||
|
document.head.appendChild(link);
|
||||||
|
}
|
||||||
|
if (link.href !== normalizedUrl) {
|
||||||
|
link.href = normalizedUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function loadCustomFontStylesheets(customFonts: CustomFontOption[]): void {
|
||||||
|
for (const font of normalizeCustomFonts(customFonts)) {
|
||||||
|
loadGoogleFontUrl(font.url, `${GOOGLE_FONT_LINK_ID_PREFIX}${font.value}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export function applyFont(fontFamily: FontFamily, customFonts: CustomFontOption[] = []): void {
|
||||||
|
const fontOptions = getFontOptions(customFonts);
|
||||||
|
loadCustomFontStylesheets(customFonts);
|
||||||
|
const font = fontOptions.find((option) => option.value === fontFamily) ||
|
||||||
|
FONT_OPTIONS.find((option) => option.value === DEFAULT_SETTINGS.fontFamily);
|
||||||
if (font) {
|
if (font) {
|
||||||
document.documentElement.style.setProperty('--font-sans', font.fontFamily);
|
document.documentElement.style.setProperty("--font-sans", font.fontFamily);
|
||||||
document.body.style.fontFamily = font.fontFamily;
|
document.body.style.fontFamily = font.fontFamily;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
async function persistCustomFontsInternal(customFonts: CustomFontOption[]): Promise<CustomFontOption[]> {
|
||||||
|
const normalizedFonts = normalizeCustomFonts(customFonts);
|
||||||
|
await SaveFontsToBackend(normalizedFonts as unknown as Array<Record<string, unknown>>);
|
||||||
|
if (cachedSettings) {
|
||||||
|
cachedSettings = toNormalizedSettings({
|
||||||
|
...cachedSettings,
|
||||||
|
customFonts: normalizedFonts,
|
||||||
|
});
|
||||||
|
localStorage.setItem(SETTINGS_KEY, JSON.stringify(cachedSettings));
|
||||||
|
window.dispatchEvent(new CustomEvent("settingsUpdated", { detail: cachedSettings }));
|
||||||
|
}
|
||||||
|
return normalizedFonts;
|
||||||
|
}
|
||||||
|
async function loadStoredCustomFonts(fallbackFonts?: unknown): Promise<CustomFontOption[]> {
|
||||||
|
try {
|
||||||
|
const storedFonts = await LoadFontsFromBackend();
|
||||||
|
if (storedFonts !== null) {
|
||||||
|
return normalizeCustomFonts(storedFonts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error("Failed to load custom fonts:", error);
|
||||||
|
}
|
||||||
|
const migratedFonts = normalizeCustomFonts(fallbackFonts);
|
||||||
|
if (migratedFonts.length > 0) {
|
||||||
|
try {
|
||||||
|
return await persistCustomFontsInternal(migratedFonts);
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error("Failed to migrate custom fonts:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return migratedFonts;
|
||||||
|
}
|
||||||
|
export async function loadCustomFonts(): Promise<CustomFontOption[]> {
|
||||||
|
return loadStoredCustomFonts(getSettings().customFonts);
|
||||||
|
}
|
||||||
|
export async function saveCustomFonts(customFonts: CustomFontOption[]): Promise<CustomFontOption[]> {
|
||||||
|
return persistCustomFontsInternal(customFonts);
|
||||||
|
}
|
||||||
|
function keepKnownSettings(settings: SettingsPayload): SettingsPayload {
|
||||||
|
const normalized: Record<string, unknown> = {};
|
||||||
|
for (const key of KNOWN_SETTINGS_KEYS) {
|
||||||
|
if (key in settings) {
|
||||||
|
normalized[key] = settings[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return normalized as SettingsPayload;
|
||||||
|
}
|
||||||
|
function normalizePreviewVolume(volume: unknown): number {
|
||||||
|
const parsed = typeof volume === "number"
|
||||||
|
? volume
|
||||||
|
: typeof volume === "string"
|
||||||
|
? Number.parseFloat(volume)
|
||||||
|
: Number.NaN;
|
||||||
|
if (!Number.isFinite(parsed)) {
|
||||||
|
return DEFAULT_SETTINGS.previewVolume;
|
||||||
|
}
|
||||||
|
return Math.min(100, Math.max(0, Math.round(parsed)));
|
||||||
|
}
|
||||||
|
function normalizeCustomTidalApi(value: unknown): string {
|
||||||
|
return typeof value === "string"
|
||||||
|
? value.trim().replace(/\/+$/g, "")
|
||||||
|
: "";
|
||||||
|
}
|
||||||
|
export function hasConfiguredCustomTidalApi(value: unknown): boolean {
|
||||||
|
return normalizeCustomTidalApi(value).startsWith("https://");
|
||||||
|
}
|
||||||
|
export function sanitizeAutoOrder(order: unknown, allowTidal: boolean): string {
|
||||||
|
const allowedServices = allowTidal
|
||||||
|
? new Set(["tidal", "qobuz", "amazon"])
|
||||||
|
: new Set(["qobuz", "amazon"]);
|
||||||
|
const fallbackOrder = allowTidal ? "tidal-qobuz-amazon" : "qobuz-amazon";
|
||||||
|
if (typeof order !== "string") {
|
||||||
|
return fallbackOrder;
|
||||||
|
}
|
||||||
|
const normalized = order
|
||||||
|
.split("-")
|
||||||
|
.map((part) => part.trim().toLowerCase())
|
||||||
|
.filter((part, index, parts) => part !== "" && allowedServices.has(part) && parts.indexOf(part) === index);
|
||||||
|
return normalized.length >= 2 ? normalized.join("-") : fallbackOrder;
|
||||||
|
}
|
||||||
|
function normalizeDownloader(value: unknown, allowTidal: boolean): Settings["downloader"] {
|
||||||
|
const normalized = typeof value === "string" ? value.trim().toLowerCase() : "";
|
||||||
|
if (normalized === "tidal") {
|
||||||
|
return allowTidal ? "tidal" : "auto";
|
||||||
|
}
|
||||||
|
if (normalized === "qobuz" || normalized === "amazon" || normalized === "auto") {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
return DEFAULT_SETTINGS.downloader;
|
||||||
|
}
|
||||||
|
function normalizeExistingFileCheckMode(mode: unknown): ExistingFileCheckMode {
|
||||||
|
switch (typeof mode === "string" ? mode.trim().toLowerCase() : "") {
|
||||||
|
case "isrc":
|
||||||
|
case "upc":
|
||||||
|
return "isrc";
|
||||||
|
default:
|
||||||
|
return "filename";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function normalizeSettingsPayload(settings: SettingsPayload): SettingsPayload {
|
||||||
|
const normalized: SettingsPayload = { ...settings };
|
||||||
|
if ("darkMode" in normalized && !("themeMode" in normalized)) {
|
||||||
|
normalized.themeMode = normalized.darkMode ? "dark" : "light";
|
||||||
|
delete normalized.darkMode;
|
||||||
|
}
|
||||||
|
if (!("folderPreset" in normalized) &&
|
||||||
|
("artistSubfolder" in normalized || "albumSubfolder" in normalized)) {
|
||||||
|
const hasArtist = Boolean(normalized.artistSubfolder);
|
||||||
|
const hasAlbum = Boolean(normalized.albumSubfolder);
|
||||||
|
if (hasArtist && hasAlbum) {
|
||||||
|
normalized.folderPreset = "artist-album";
|
||||||
|
normalized.folderTemplate = "{artist}/{album}";
|
||||||
|
}
|
||||||
|
else if (hasArtist) {
|
||||||
|
normalized.folderPreset = "artist";
|
||||||
|
normalized.folderTemplate = "{artist}";
|
||||||
|
}
|
||||||
|
else if (hasAlbum) {
|
||||||
|
normalized.folderPreset = "album";
|
||||||
|
normalized.folderTemplate = "{album}";
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
normalized.folderPreset = "none";
|
||||||
|
normalized.folderTemplate = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!("filenamePreset" in normalized) && "filenameFormat" in normalized) {
|
||||||
|
const format = normalized.filenameFormat;
|
||||||
|
if (format === "title-artist") {
|
||||||
|
normalized.filenamePreset = "artist-title";
|
||||||
|
normalized.filenameTemplate = "{artist} - {title}";
|
||||||
|
}
|
||||||
|
else if (format === "artist-title") {
|
||||||
|
normalized.filenamePreset = "artist-title";
|
||||||
|
normalized.filenameTemplate = "{artist} - {title}";
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
normalized.filenamePreset = "title";
|
||||||
|
normalized.filenameTemplate = "{title}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delete normalized.tidalVariant;
|
||||||
|
if (!("tidalQuality" in normalized)) {
|
||||||
|
normalized.tidalQuality = "LOSSLESS";
|
||||||
|
}
|
||||||
|
if (!("qobuzQuality" in normalized)) {
|
||||||
|
normalized.qobuzQuality = "6";
|
||||||
|
}
|
||||||
|
if (!("amazonQuality" in normalized)) {
|
||||||
|
normalized.amazonQuality = "original";
|
||||||
|
}
|
||||||
|
if (!("autoOrder" in normalized)) {
|
||||||
|
normalized.autoOrder = DEFAULT_SETTINGS.autoOrder;
|
||||||
|
}
|
||||||
|
if (!("autoQuality" in normalized)) {
|
||||||
|
normalized.autoQuality = "16";
|
||||||
|
}
|
||||||
|
normalized.customTidalApi = normalizeCustomTidalApi(normalized.customTidalApi);
|
||||||
|
const allowTidal = hasConfiguredCustomTidalApi(normalized.customTidalApi);
|
||||||
|
normalized.downloader = normalizeDownloader(normalized.downloader, allowTidal);
|
||||||
|
normalized.autoOrder = sanitizeAutoOrder(normalized.autoOrder, allowTidal);
|
||||||
|
if (!("allowFallback" in normalized)) {
|
||||||
|
normalized.allowFallback = true;
|
||||||
|
}
|
||||||
|
if (!("linkResolver" in normalized)) {
|
||||||
|
normalized.linkResolver = "songlink";
|
||||||
|
}
|
||||||
|
if (!("allowResolverFallback" in normalized)) {
|
||||||
|
normalized.allowResolverFallback = true;
|
||||||
|
}
|
||||||
|
if (!("createPlaylistFolder" in normalized)) {
|
||||||
|
normalized.createPlaylistFolder = true;
|
||||||
|
}
|
||||||
|
if (!("playlistOwnerFolderName" in normalized)) {
|
||||||
|
normalized.playlistOwnerFolderName = false;
|
||||||
|
}
|
||||||
|
if (!("createM3u8File" in normalized)) {
|
||||||
|
normalized.createM3u8File = false;
|
||||||
|
}
|
||||||
|
normalized.previewVolume = normalizePreviewVolume(normalized.previewVolume);
|
||||||
|
normalized.existingFileCheckMode = normalizeExistingFileCheckMode(normalized.existingFileCheckMode);
|
||||||
|
if (!("useFirstArtistOnly" in normalized)) {
|
||||||
|
normalized.useFirstArtistOnly = false;
|
||||||
|
}
|
||||||
|
if (!("useSingleGenre" in normalized)) {
|
||||||
|
normalized.useSingleGenre = false;
|
||||||
|
}
|
||||||
|
if (!("embedGenre" in normalized)) {
|
||||||
|
normalized.embedGenre = false;
|
||||||
|
}
|
||||||
|
if (!("separator" in normalized)) {
|
||||||
|
normalized.separator = "semicolon";
|
||||||
|
}
|
||||||
|
if (!("redownloadWithSuffix" in normalized)) {
|
||||||
|
normalized.redownloadWithSuffix = false;
|
||||||
|
}
|
||||||
|
normalized.operatingSystem = detectOS();
|
||||||
|
const normalizedCustomFonts = normalizeCustomFonts(normalized.customFonts);
|
||||||
|
normalized.customFonts = normalizedCustomFonts;
|
||||||
|
normalized.fontFamily = normalizeFontFamily(normalized.fontFamily, normalizedCustomFonts);
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
function toNormalizedSettings(settings: SettingsPayload): Settings {
|
||||||
|
return {
|
||||||
|
...DEFAULT_SETTINGS,
|
||||||
|
...keepKnownSettings(normalizeSettingsPayload(settings)),
|
||||||
|
} as Settings;
|
||||||
|
}
|
||||||
|
async function persistSettingsInternal(settings: Settings, notify = true): Promise<void> {
|
||||||
|
cachedSettings = settings;
|
||||||
|
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
|
||||||
|
const settingsForBackend = { ...settings } as Record<string, unknown>;
|
||||||
|
delete settingsForBackend.customFonts;
|
||||||
|
await SaveToBackend(settingsForBackend);
|
||||||
|
if (notify) {
|
||||||
|
window.dispatchEvent(new CustomEvent("settingsUpdated", { detail: settings }));
|
||||||
|
}
|
||||||
|
}
|
||||||
async function fetchDefaultPath(): Promise<string> {
|
async function fetchDefaultPath(): Promise<string> {
|
||||||
try {
|
try {
|
||||||
const data = await GetDefaults();
|
const data = await GetDefaults();
|
||||||
@@ -165,87 +686,11 @@ async function fetchDefaultPath(): Promise<string> {
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const SETTINGS_KEY = "spotiflac-settings";
|
|
||||||
let cachedSettings: Settings | null = null;
|
|
||||||
function getSettingsFromLocalStorage(): Settings {
|
function getSettingsFromLocalStorage(): Settings {
|
||||||
try {
|
try {
|
||||||
const stored = localStorage.getItem(SETTINGS_KEY);
|
const stored = localStorage.getItem(SETTINGS_KEY);
|
||||||
if (stored) {
|
if (stored) {
|
||||||
const parsed = JSON.parse(stored);
|
return toNormalizedSettings(JSON.parse(stored) as SettingsPayload);
|
||||||
if ('darkMode' in parsed && !('themeMode' in parsed)) {
|
|
||||||
parsed.themeMode = parsed.darkMode ? 'dark' : 'light';
|
|
||||||
delete parsed.darkMode;
|
|
||||||
}
|
|
||||||
if (!('folderPreset' in parsed) && ('artistSubfolder' in parsed || 'albumSubfolder' in parsed)) {
|
|
||||||
const hasArtist = parsed.artistSubfolder;
|
|
||||||
const hasAlbum = parsed.albumSubfolder;
|
|
||||||
if (hasArtist && hasAlbum) {
|
|
||||||
parsed.folderPreset = "artist-album";
|
|
||||||
parsed.folderTemplate = "{artist}/{album}";
|
|
||||||
}
|
|
||||||
else if (hasArtist) {
|
|
||||||
parsed.folderPreset = "artist";
|
|
||||||
parsed.folderTemplate = "{artist}";
|
|
||||||
}
|
|
||||||
else if (hasAlbum) {
|
|
||||||
parsed.folderPreset = "album";
|
|
||||||
parsed.folderTemplate = "{album}";
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
parsed.folderPreset = "none";
|
|
||||||
parsed.folderTemplate = "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!('filenamePreset' in parsed) && 'filenameFormat' in parsed) {
|
|
||||||
const format = parsed.filenameFormat;
|
|
||||||
if (format === "title-artist") {
|
|
||||||
parsed.filenamePreset = "artist-title";
|
|
||||||
parsed.filenameTemplate = "{artist} - {title}";
|
|
||||||
}
|
|
||||||
else if (format === "artist-title") {
|
|
||||||
parsed.filenamePreset = "artist-title";
|
|
||||||
parsed.filenameTemplate = "{artist} - {title}";
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
parsed.filenamePreset = "title";
|
|
||||||
parsed.filenameTemplate = "{title}";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
parsed.operatingSystem = detectOS();
|
|
||||||
if (!('tidalQuality' in parsed)) {
|
|
||||||
parsed.tidalQuality = "LOSSLESS";
|
|
||||||
}
|
|
||||||
if (!('qobuzQuality' in parsed)) {
|
|
||||||
parsed.qobuzQuality = "6";
|
|
||||||
}
|
|
||||||
if (!('amazonQuality' in parsed)) {
|
|
||||||
parsed.amazonQuality = "original";
|
|
||||||
}
|
|
||||||
if (!('autoOrder' in parsed)) {
|
|
||||||
parsed.autoOrder = "tidal-qobuz-amazon";
|
|
||||||
}
|
|
||||||
if (!('autoQuality' in parsed)) {
|
|
||||||
parsed.autoQuality = "16";
|
|
||||||
}
|
|
||||||
if (!('allowFallback' in parsed)) {
|
|
||||||
parsed.allowFallback = true;
|
|
||||||
}
|
|
||||||
if (!('linkResolver' in parsed)) {
|
|
||||||
parsed.linkResolver = "songlink";
|
|
||||||
}
|
|
||||||
if (!('allowResolverFallback' in parsed)) {
|
|
||||||
parsed.allowResolverFallback = true;
|
|
||||||
}
|
|
||||||
if (!('playlistOwnerFolderName' in parsed)) {
|
|
||||||
parsed.playlistOwnerFolderName = false;
|
|
||||||
}
|
|
||||||
if (!('separator' in parsed)) {
|
|
||||||
parsed.separator = "semicolon";
|
|
||||||
}
|
|
||||||
if (!('redownloadWithSuffix' in parsed)) {
|
|
||||||
parsed.redownloadWithSuffix = false;
|
|
||||||
}
|
|
||||||
return { ...DEFAULT_SETTINGS, ...parsed };
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
@@ -254,105 +699,25 @@ function getSettingsFromLocalStorage(): Settings {
|
|||||||
return DEFAULT_SETTINGS;
|
return DEFAULT_SETTINGS;
|
||||||
}
|
}
|
||||||
export function getSettings(): Settings {
|
export function getSettings(): Settings {
|
||||||
if (cachedSettings)
|
if (cachedSettings) {
|
||||||
return cachedSettings;
|
return cachedSettings;
|
||||||
|
}
|
||||||
return getSettingsFromLocalStorage();
|
return getSettingsFromLocalStorage();
|
||||||
}
|
}
|
||||||
export async function loadSettings(): Promise<Settings> {
|
export async function loadSettings(): Promise<Settings> {
|
||||||
try {
|
try {
|
||||||
const backendSettings = await LoadSettings();
|
const backendSettings = await LoadSettings();
|
||||||
if (backendSettings) {
|
if (backendSettings) {
|
||||||
const parsed = backendSettings as any;
|
const parsed = backendSettings as SettingsPayload;
|
||||||
if ('darkMode' in parsed && !('themeMode' in parsed)) {
|
const customFonts = await loadStoredCustomFonts(parsed.customFonts);
|
||||||
parsed.themeMode = parsed.darkMode ? 'dark' : 'light';
|
cachedSettings = toNormalizedSettings({
|
||||||
delete parsed.darkMode;
|
...parsed,
|
||||||
|
customFonts,
|
||||||
|
});
|
||||||
|
if ("customFonts" in parsed) {
|
||||||
|
await persistSettingsInternal(cachedSettings, false);
|
||||||
}
|
}
|
||||||
if (!('folderPreset' in parsed) && ('artistSubfolder' in parsed || 'albumSubfolder' in parsed)) {
|
return cachedSettings;
|
||||||
const hasArtist = parsed.artistSubfolder;
|
|
||||||
const hasAlbum = parsed.albumSubfolder;
|
|
||||||
if (hasArtist && hasAlbum) {
|
|
||||||
parsed.folderPreset = "artist-album";
|
|
||||||
parsed.folderTemplate = "{artist}/{album}";
|
|
||||||
}
|
|
||||||
else if (hasArtist) {
|
|
||||||
parsed.folderPreset = "artist";
|
|
||||||
parsed.folderTemplate = "{artist}";
|
|
||||||
}
|
|
||||||
else if (hasAlbum) {
|
|
||||||
parsed.folderPreset = "album";
|
|
||||||
parsed.folderTemplate = "{album}";
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
parsed.folderPreset = "none";
|
|
||||||
parsed.folderTemplate = "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!('filenamePreset' in parsed) && 'filenameFormat' in parsed) {
|
|
||||||
const format = parsed.filenameFormat;
|
|
||||||
if (format === "title-artist") {
|
|
||||||
parsed.filenamePreset = "artist-title";
|
|
||||||
parsed.filenameTemplate = "{artist} - {title}";
|
|
||||||
}
|
|
||||||
else if (format === "artist-title") {
|
|
||||||
parsed.filenamePreset = "artist-title";
|
|
||||||
parsed.filenameTemplate = "{artist} - {title}";
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
parsed.filenamePreset = "title";
|
|
||||||
parsed.filenameTemplate = "{title}";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
parsed.operatingSystem = detectOS();
|
|
||||||
if (!('tidalQuality' in parsed)) {
|
|
||||||
parsed.tidalQuality = "LOSSLESS";
|
|
||||||
}
|
|
||||||
if (!('qobuzQuality' in parsed)) {
|
|
||||||
parsed.qobuzQuality = "6";
|
|
||||||
}
|
|
||||||
if (!('amazonQuality' in parsed)) {
|
|
||||||
parsed.amazonQuality = "original";
|
|
||||||
}
|
|
||||||
if (!('autoOrder' in parsed)) {
|
|
||||||
parsed.autoOrder = "tidal-qobuz-amazon";
|
|
||||||
}
|
|
||||||
if (!('autoQuality' in parsed)) {
|
|
||||||
parsed.autoQuality = "16";
|
|
||||||
}
|
|
||||||
if (!('allowFallback' in parsed)) {
|
|
||||||
parsed.allowFallback = true;
|
|
||||||
}
|
|
||||||
if (!('linkResolver' in parsed)) {
|
|
||||||
parsed.linkResolver = "songlink";
|
|
||||||
}
|
|
||||||
if (!('allowResolverFallback' in parsed)) {
|
|
||||||
parsed.allowResolverFallback = true;
|
|
||||||
}
|
|
||||||
if (!('createPlaylistFolder' in parsed)) {
|
|
||||||
parsed.createPlaylistFolder = true;
|
|
||||||
}
|
|
||||||
if (!('playlistOwnerFolderName' in parsed)) {
|
|
||||||
parsed.playlistOwnerFolderName = false;
|
|
||||||
}
|
|
||||||
if (!('createM3u8File' in parsed)) {
|
|
||||||
parsed.createM3u8File = false;
|
|
||||||
}
|
|
||||||
if (!('useFirstArtistOnly' in parsed)) {
|
|
||||||
parsed.useFirstArtistOnly = false;
|
|
||||||
}
|
|
||||||
if (!('useSingleGenre' in parsed)) {
|
|
||||||
parsed.useSingleGenre = false;
|
|
||||||
}
|
|
||||||
if (!('embedGenre' in parsed)) {
|
|
||||||
parsed.embedGenre = false;
|
|
||||||
}
|
|
||||||
if (!('separator' in parsed)) {
|
|
||||||
parsed.separator = "semicolon";
|
|
||||||
}
|
|
||||||
if (!('redownloadWithSuffix' in parsed)) {
|
|
||||||
parsed.redownloadWithSuffix = false;
|
|
||||||
}
|
|
||||||
cachedSettings = { ...DEFAULT_SETTINGS, ...parsed };
|
|
||||||
return cachedSettings!;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
@@ -360,12 +725,19 @@ export async function loadSettings(): Promise<Settings> {
|
|||||||
}
|
}
|
||||||
const local = getSettingsFromLocalStorage();
|
const local = getSettingsFromLocalStorage();
|
||||||
try {
|
try {
|
||||||
await SaveToBackend(local as any);
|
const customFonts = await loadStoredCustomFonts(local.customFonts);
|
||||||
cachedSettings = local;
|
const localWithFonts = toNormalizedSettings({
|
||||||
|
...local,
|
||||||
|
customFonts,
|
||||||
|
});
|
||||||
|
await persistSettingsInternal(localWithFonts, false);
|
||||||
|
cachedSettings = localWithFonts;
|
||||||
|
return localWithFonts;
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
console.error("Failed to migrate settings to backend:", error);
|
console.error("Failed to migrate settings to backend:", error);
|
||||||
}
|
}
|
||||||
|
cachedSettings = local;
|
||||||
return local;
|
return local;
|
||||||
}
|
}
|
||||||
export interface TemplateData {
|
export interface TemplateData {
|
||||||
@@ -381,8 +753,9 @@ export interface TemplateData {
|
|||||||
playlist?: string;
|
playlist?: string;
|
||||||
}
|
}
|
||||||
export function parseTemplate(template: string, data: TemplateData): string {
|
export function parseTemplate(template: string, data: TemplateData): string {
|
||||||
if (!template)
|
if (!template) {
|
||||||
return "";
|
return "";
|
||||||
|
}
|
||||||
let result = template;
|
let result = template;
|
||||||
result = result.replace(/\{title\}/g, data.title || "Unknown Title");
|
result = result.replace(/\{title\}/g, data.title || "Unknown Title");
|
||||||
result = result.replace(/\{artist\}/g, data.artist || "Unknown Artist");
|
result = result.replace(/\{artist\}/g, data.artist || "Unknown Artist");
|
||||||
@@ -406,10 +779,8 @@ export async function getSettingsWithDefaults(): Promise<Settings> {
|
|||||||
}
|
}
|
||||||
export async function saveSettings(settings: Settings): Promise<void> {
|
export async function saveSettings(settings: Settings): Promise<void> {
|
||||||
try {
|
try {
|
||||||
cachedSettings = settings;
|
const normalizedSettings = toNormalizedSettings(settings as SettingsPayload);
|
||||||
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
|
await persistSettingsInternal(normalizedSettings);
|
||||||
await SaveToBackend(settings as any);
|
|
||||||
window.dispatchEvent(new CustomEvent('settingsUpdated', { detail: settings }));
|
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
console.error("Failed to save settings:", error);
|
console.error("Failed to save settings:", error);
|
||||||
@@ -423,7 +794,12 @@ export async function updateSettings(partial: Partial<Settings>): Promise<Settin
|
|||||||
}
|
}
|
||||||
export async function resetToDefaultSettings(): Promise<Settings> {
|
export async function resetToDefaultSettings(): Promise<Settings> {
|
||||||
const defaultPath = await fetchDefaultPath();
|
const defaultPath = await fetchDefaultPath();
|
||||||
const defaultSettings = { ...DEFAULT_SETTINGS, downloadPath: defaultPath };
|
const customFonts = await loadCustomFonts();
|
||||||
|
const defaultSettings = {
|
||||||
|
...DEFAULT_SETTINGS,
|
||||||
|
downloadPath: defaultPath,
|
||||||
|
customFonts,
|
||||||
|
};
|
||||||
await saveSettings(defaultSettings);
|
await saveSettings(defaultSettings);
|
||||||
return defaultSettings;
|
return defaultSettings;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,13 +9,14 @@ require (
|
|||||||
github.com/go-flac/go-flac v1.0.0
|
github.com/go-flac/go-flac v1.0.0
|
||||||
github.com/pquerna/otp v1.5.0
|
github.com/pquerna/otp v1.5.0
|
||||||
github.com/ulikunitz/xz v0.5.15
|
github.com/ulikunitz/xz v0.5.15
|
||||||
github.com/wailsapp/wails/v2 v2.11.0
|
github.com/wailsapp/wails/v2 v2.12.0
|
||||||
go.etcd.io/bbolt v1.4.3
|
go.etcd.io/bbolt v1.4.3
|
||||||
golang.org/x/image v0.12.0
|
golang.org/x/image v0.12.0
|
||||||
golang.org/x/text v0.31.0
|
golang.org/x/text v0.31.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
|
||||||
github.com/bep/debounce v1.2.1 // indirect
|
github.com/bep/debounce v1.2.1 // indirect
|
||||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
||||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA=
|
||||||
|
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc=
|
||||||
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||||
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
||||||
github.com/bogem/id3v2/v2 v2.1.4 h1:CEwe+lS2p6dd9UZRlPc1zbFNIha2mb2qzT1cCEoNWoI=
|
github.com/bogem/id3v2/v2 v2.1.4 h1:CEwe+lS2p6dd9UZRlPc1zbFNIha2mb2qzT1cCEoNWoI=
|
||||||
@@ -73,8 +75,8 @@ github.com/wailsapp/go-webview2 v1.0.23 h1:jmv8qhz1lHibCc79bMM/a/FqOnnzOGEisLav+
|
|||||||
github.com/wailsapp/go-webview2 v1.0.23/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
|
github.com/wailsapp/go-webview2 v1.0.23/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
|
||||||
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
|
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/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.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c=
|
||||||
github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k=
|
github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg=
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
|
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
|
||||||
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
|
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@
|
|||||||
},
|
},
|
||||||
"info": {
|
"info": {
|
||||||
"productName": "SpotiFLAC",
|
"productName": "SpotiFLAC",
|
||||||
"productVersion": "7.1.4",
|
"productVersion": "7.1.7",
|
||||||
"copyright": "© 2026 afkarxyz"
|
"copyright": "© 2026 afkarxyz"
|
||||||
},
|
},
|
||||||
"wailsjsdir": "./frontend",
|
"wailsjsdir": "./frontend",
|
||||||
|
|||||||
Reference in New Issue
Block a user