v7.1.5
This commit is contained in:
+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,10 +20,6 @@ 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.
|
||||||
|
|||||||
@@ -34,14 +34,6 @@ type CurrentIPInfo struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const checkOperationTimeout = 10 * time.Second
|
const checkOperationTimeout = 10 * time.Second
|
||||||
const unifiedStatusAPIURL = "https://api-status.afkarxyz.qzz.io/api/status/spotiflac/"
|
|
||||||
const unifiedStatusCacheTTL = 5 * time.Second
|
|
||||||
|
|
||||||
var (
|
|
||||||
unifiedStatusCacheMu sync.Mutex
|
|
||||||
unifiedStatusCacheBody string
|
|
||||||
unifiedStatusCacheExpiry time.Time
|
|
||||||
)
|
|
||||||
|
|
||||||
func NewApp() *App {
|
func NewApp() *App {
|
||||||
return &App{}
|
return &App{}
|
||||||
@@ -152,60 +144,6 @@ func previewResponseBody(body []byte, maxLen int) string {
|
|||||||
return preview
|
return preview
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchUnifiedStatusPayload(forceRefresh bool, endpoint string) (string, error) {
|
|
||||||
unifiedStatusCacheMu.Lock()
|
|
||||||
defer unifiedStatusCacheMu.Unlock()
|
|
||||||
|
|
||||||
if !forceRefresh && unifiedStatusCacheBody != "" && time.Now().Before(unifiedStatusCacheExpiry) {
|
|
||||||
return unifiedStatusCacheBody, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
client := &http.Client{Timeout: 10 * time.Second}
|
|
||||||
maxRetries := 3
|
|
||||||
var lastErr error
|
|
||||||
|
|
||||||
for i := 0; i < maxRetries; i++ {
|
|
||||||
req, err := http.NewRequest(http.MethodGet, endpoint, nil)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to create unified status request: %w", err)
|
|
||||||
}
|
|
||||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36")
|
|
||||||
req.Header.Set("Accept", "application/json")
|
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err == nil {
|
|
||||||
body, readErr := io.ReadAll(resp.Body)
|
|
||||||
resp.Body.Close()
|
|
||||||
if readErr != nil {
|
|
||||||
lastErr = fmt.Errorf("attempt %d: failed reading response: %w", i+1, readErr)
|
|
||||||
} else if resp.StatusCode != http.StatusOK {
|
|
||||||
lastErr = fmt.Errorf("attempt %d: returned status %d (%s)", i+1, resp.StatusCode, previewResponseBody(body, 200))
|
|
||||||
} else {
|
|
||||||
payload := strings.TrimSpace(string(body))
|
|
||||||
if payload == "" {
|
|
||||||
lastErr = fmt.Errorf("attempt %d: empty response body", i+1)
|
|
||||||
} else {
|
|
||||||
unifiedStatusCacheBody = payload
|
|
||||||
unifiedStatusCacheExpiry = time.Now().Add(unifiedStatusCacheTTL)
|
|
||||||
return payload, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
lastErr = fmt.Errorf("attempt %d: connection failed: %w", i+1, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if i < maxRetries-1 {
|
|
||||||
time.Sleep(1 * time.Second)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if lastErr == nil {
|
|
||||||
lastErr = fmt.Errorf("unknown error")
|
|
||||||
}
|
|
||||||
|
|
||||||
return "", fmt.Errorf("unified status API failed after %d retries: %w", maxRetries, lastErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
func fetchCurrentIPInfo() (CurrentIPInfo, error) {
|
func fetchCurrentIPInfo() (CurrentIPInfo, error) {
|
||||||
type ipwhoisResponse struct {
|
type ipwhoisResponse struct {
|
||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
@@ -313,10 +251,6 @@ func (a *App) GetCurrentIPInfo() (string, error) {
|
|||||||
return string(payload), nil
|
return string(payload), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) FetchUnifiedAPIStatus(forceRefresh bool) (string, error) {
|
|
||||||
return fetchUnifiedStatusPayload(forceRefresh, unifiedStatusAPIURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) getFirstArtist(artistString string) string {
|
func (a *App) getFirstArtist(artistString string) string {
|
||||||
if artistString == "" {
|
if artistString == "" {
|
||||||
return ""
|
return ""
|
||||||
@@ -342,6 +276,11 @@ func (a *App) startup(ctx context.Context) {
|
|||||||
if err := backend.InitProviderPriorityDB(); err != nil {
|
if err := backend.InitProviderPriorityDB(); err != nil {
|
||||||
fmt.Printf("Failed to init provider priority DB: %v\n", err)
|
fmt.Printf("Failed to init provider priority DB: %v\n", err)
|
||||||
}
|
}
|
||||||
|
go func() {
|
||||||
|
if err := backend.PrimeTidalAPIList(); err != nil {
|
||||||
|
fmt.Printf("Failed to prime Tidal API list: %v\n", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) shutdown(ctx context.Context) {
|
func (a *App) shutdown(ctx context.Context) {
|
||||||
@@ -368,6 +307,7 @@ type DownloadRequest struct {
|
|||||||
ReleaseDate string `json:"release_date,omitempty"`
|
ReleaseDate string `json:"release_date,omitempty"`
|
||||||
CoverURL string `json:"cover_url,omitempty"`
|
CoverURL string `json:"cover_url,omitempty"`
|
||||||
TidalAPIURL string `json:"tidal_api_url,omitempty"`
|
TidalAPIURL string `json:"tidal_api_url,omitempty"`
|
||||||
|
TidalVariant string `json:"tidal_variant,omitempty"`
|
||||||
OutputDir string `json:"output_dir,omitempty"`
|
OutputDir string `json:"output_dir,omitempty"`
|
||||||
AudioFormat string `json:"audio_format,omitempty"`
|
AudioFormat string `json:"audio_format,omitempty"`
|
||||||
FilenameFormat string `json:"filename_format,omitempty"`
|
FilenameFormat string `json:"filename_format,omitempty"`
|
||||||
@@ -722,7 +662,11 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "tidal":
|
case "tidal":
|
||||||
if req.TidalAPIURL == "" || req.TidalAPIURL == "auto" {
|
tidalVariant := strings.ToLower(strings.TrimSpace(req.TidalVariant))
|
||||||
|
if tidalVariant == "alt" {
|
||||||
|
downloader := backend.NewTidalDownloader("")
|
||||||
|
filename, err = downloader.DownloadAlt(req.SpotifyID, req.OutputDir, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, req.Composer, metadataSeparator, req.ISRC, spotifyURL, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre)
|
||||||
|
} else if req.TidalAPIURL == "" || req.TidalAPIURL == "auto" {
|
||||||
downloader := backend.NewTidalDownloader("")
|
downloader := backend.NewTidalDownloader("")
|
||||||
if req.ServiceURL != "" {
|
if req.ServiceURL != "" {
|
||||||
filename, err = downloader.DownloadByURLWithFallback(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, req.Composer, metadataSeparator, req.ISRC, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre)
|
filename, err = downloader.DownloadByURLWithFallback(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, req.Composer, metadataSeparator, req.ISRC, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre)
|
||||||
@@ -850,6 +794,11 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
|||||||
backend.CompleteDownloadItem(itemID, filename, 0)
|
backend.CompleteDownloadItem(itemID, filename, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
historySource := req.Service
|
||||||
|
if req.Service == "tidal" && strings.EqualFold(strings.TrimSpace(req.TidalVariant), "alt") {
|
||||||
|
historySource = "tidal alt"
|
||||||
|
}
|
||||||
|
|
||||||
go func(fPath, track, artist, album, sID, cover, format, source string) {
|
go func(fPath, track, artist, album, sID, cover, format, source string) {
|
||||||
time.Sleep(2 * time.Second)
|
time.Sleep(2 * time.Second)
|
||||||
|
|
||||||
@@ -895,7 +844,7 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
backend.AddHistoryItem(item, "SpotiFLAC")
|
backend.AddHistoryItem(item, "SpotiFLAC")
|
||||||
}(filename, req.TrackName, req.ArtistName, req.AlbumName, req.SpotifyID, req.CoverURL, req.AudioFormat, req.Service)
|
}(filename, req.TrackName, req.ArtistName, req.AlbumName, req.SpotifyID, req.CoverURL, req.AudioFormat, historySource)
|
||||||
}
|
}
|
||||||
|
|
||||||
return DownloadResponse{
|
return DownloadResponse{
|
||||||
@@ -1042,70 +991,28 @@ func (a *App) ExportFailedDownloads() (string, error) {
|
|||||||
|
|
||||||
func (a *App) CheckAPIStatus(apiType string, apiURL string) bool {
|
func (a *App) CheckAPIStatus(apiType string, apiURL string) bool {
|
||||||
isOnline, err := runWithTimeout(checkOperationTimeout, func() (bool, error) {
|
isOnline, err := runWithTimeout(checkOperationTimeout, func() (bool, error) {
|
||||||
var checkURL string
|
switch apiType {
|
||||||
if apiType == "tidal" {
|
case "tidal":
|
||||||
checkURL = fmt.Sprintf("%s/track/?id=441821360&quality=HI_RES_LOSSLESS", apiURL)
|
if checkGroupedAPIStatus("tidal", buildTidalStatusCheckURLs(apiURL)) {
|
||||||
} else if apiType == "qobuz" {
|
|
||||||
checkURL = fmt.Sprintf("%s/api/stream?trackId=360735657&quality=27", apiURL)
|
|
||||||
} else if apiType == "qbz" {
|
|
||||||
checkURL = fmt.Sprintf("%s/api/track/360735657?quality=27", apiURL)
|
|
||||||
} else if apiType == "amazon" {
|
|
||||||
checkURL = fmt.Sprintf("%s/status", apiURL)
|
|
||||||
} else if apiType == "lrclib" {
|
|
||||||
checkURL = fmt.Sprintf("%s/api/search?artist_name=Adele&track_name=Hello", strings.TrimRight(apiURL, "/"))
|
|
||||||
} else if apiType == "musicbrainz" {
|
|
||||||
checkURL = fmt.Sprintf("%s/ws/2/recording?query=%s&fmt=json&limit=1", strings.TrimRight(apiURL, "/"), url.QueryEscape(`recording:"Hello" AND artist:"Adele"`))
|
|
||||||
} else {
|
|
||||||
checkURL = apiURL
|
|
||||||
}
|
|
||||||
|
|
||||||
client := &http.Client{Timeout: 10 * time.Second}
|
|
||||||
req, err := http.NewRequest("GET", checkURL, nil)
|
|
||||||
if err != nil {
|
|
||||||
return false, 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")
|
|
||||||
req.Header.Set("Accept", "application/json")
|
|
||||||
|
|
||||||
maxRetries := 3
|
|
||||||
for i := 0; i < maxRetries; i++ {
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err == nil {
|
|
||||||
statusCode := resp.StatusCode
|
|
||||||
body, readErr := io.ReadAll(resp.Body)
|
|
||||||
resp.Body.Close()
|
|
||||||
if readErr != nil {
|
|
||||||
if i < maxRetries-1 {
|
|
||||||
time.Sleep(1 * time.Second)
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if apiType == "amazon" && statusCode == 200 && strings.Contains(string(body), `"amazonMusic":"up"`) {
|
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
if strings.TrimSpace(apiURL) == "" {
|
||||||
if (apiType == "qobuz" || apiType == "qbz") && statusCode == 200 && containsStreamingURL(body) {
|
if _, refreshErr := backend.RefreshTidalAPIList(true); refreshErr == nil && checkGroupedAPIStatus("tidal", buildTidalStatusCheckURLs("")) {
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if apiType == "lrclib" && statusCode == 200 && containsLRCLIBResults(body) {
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if apiType == "musicbrainz" && statusCode == 200 && containsMusicBrainzResults(body) {
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if apiType != "amazon" && apiType != "qobuz" && apiType != "qbz" && apiType != "lrclib" && apiType != "musicbrainz" && statusCode == 200 {
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if i < maxRetries-1 {
|
|
||||||
time.Sleep(1 * time.Second)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return false, nil
|
return false, nil
|
||||||
|
case "qobuz", "qbz":
|
||||||
|
return checkGroupedAPIStatus("qobuz", buildQobuzStatusCheckURLs(apiURL)), nil
|
||||||
|
case "amazon":
|
||||||
|
return checkGroupedAPIStatus("amazon", buildAmazonStatusCheckURLs(apiURL)), nil
|
||||||
|
case "lrclib":
|
||||||
|
return checkGroupedAPIStatus("lrclib", buildLRCLIBStatusCheckURLs(apiURL)), nil
|
||||||
|
case "musicbrainz":
|
||||||
|
return checkGroupedAPIStatus("musicbrainz", buildMusicBrainzStatusCheckURLs(apiURL)), nil
|
||||||
|
default:
|
||||||
|
return checkGroupedAPIStatus(apiType, []string{strings.TrimSpace(apiURL)}), nil
|
||||||
|
}
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if apiType == "musicbrainz" {
|
if apiType == "musicbrainz" {
|
||||||
@@ -1122,6 +1029,146 @@ func (a *App) CheckAPIStatus(apiType string, apiURL string) bool {
|
|||||||
return isOnline
|
return isOnline
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func buildTidalStatusCheckURLs(apiURL string) []string {
|
||||||
|
apiURL = strings.TrimRight(strings.TrimSpace(apiURL), "/")
|
||||||
|
if apiURL != "" {
|
||||||
|
return []string{fmt.Sprintf("%s/track/?id=441821360&quality=HI_RES_LOSSLESS", apiURL)}
|
||||||
|
}
|
||||||
|
|
||||||
|
apis, err := backend.GetRotatedTidalAPIList()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Warning: failed to load rotated Tidal API list for status check: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
urls := make([]string, 0, len(apis))
|
||||||
|
for _, baseURL := range apis {
|
||||||
|
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
||||||
|
if baseURL == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
urls = append(urls, fmt.Sprintf("%s/track/?id=441821360&quality=HI_RES_LOSSLESS", baseURL))
|
||||||
|
}
|
||||||
|
|
||||||
|
return urls
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildQobuzStatusCheckURLs(apiURL string) []string {
|
||||||
|
if trimmed := strings.TrimSpace(apiURL); trimmed != "" {
|
||||||
|
return []string{buildQobuzStatusCheckURL(trimmed)}
|
||||||
|
}
|
||||||
|
|
||||||
|
bases := backend.GetQobuzStreamAPIBaseURLs()
|
||||||
|
urls := make([]string, 0, len(bases))
|
||||||
|
for _, baseURL := range bases {
|
||||||
|
urls = append(urls, buildQobuzStatusCheckURL(baseURL))
|
||||||
|
}
|
||||||
|
return urls
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildQobuzStatusCheckURL(apiBase string) string {
|
||||||
|
apiBase = strings.TrimSpace(apiBase)
|
||||||
|
if strings.Contains(apiBase, "qobuz.spotbye.qzz.io") {
|
||||||
|
return fmt.Sprintf("%s360735657?quality=27", apiBase)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s360735657&quality=27", apiBase)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildAmazonStatusCheckURLs(apiURL string) []string {
|
||||||
|
baseURL := strings.TrimRight(strings.TrimSpace(apiURL), "/")
|
||||||
|
if baseURL == "" {
|
||||||
|
baseURL = backend.GetAmazonMusicAPIBaseURL()
|
||||||
|
}
|
||||||
|
return []string{fmt.Sprintf("%s/status", baseURL)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildLRCLIBStatusCheckURLs(apiURL string) []string {
|
||||||
|
baseURL := strings.TrimRight(strings.TrimSpace(apiURL), "/")
|
||||||
|
if baseURL == "" {
|
||||||
|
baseURL = "https://lrclib.net"
|
||||||
|
}
|
||||||
|
return []string{fmt.Sprintf("%s/api/search?artist_name=Adele&track_name=Hello", baseURL)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildMusicBrainzStatusCheckURLs(apiURL string) []string {
|
||||||
|
baseURL := strings.TrimRight(strings.TrimSpace(apiURL), "/")
|
||||||
|
if baseURL == "" {
|
||||||
|
baseURL = "https://musicbrainz.org"
|
||||||
|
}
|
||||||
|
return []string{fmt.Sprintf("%s/ws/2/recording?query=%s&fmt=json&limit=1", baseURL, url.QueryEscape(`recording:"Hello" AND artist:"Adele"`))}
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkGroupedAPIStatus(apiType string, checkURLs []string) bool {
|
||||||
|
filtered := make([]string, 0, len(checkURLs))
|
||||||
|
for _, rawURL := range checkURLs {
|
||||||
|
url := strings.TrimSpace(rawURL)
|
||||||
|
if url == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filtered = append(filtered, url)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(filtered) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make(chan bool, len(filtered))
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
for _, checkURL := range filtered {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(target string) {
|
||||||
|
defer wg.Done()
|
||||||
|
results <- checkSingleAPIStatus(apiType, target)
|
||||||
|
}(checkURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
wg.Wait()
|
||||||
|
close(results)
|
||||||
|
}()
|
||||||
|
|
||||||
|
for online := range results {
|
||||||
|
if online {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkSingleAPIStatus(apiType string, checkURL string) bool {
|
||||||
|
client := &http.Client{Timeout: 4 * time.Second}
|
||||||
|
req, err := backend.NewRequestWithDefaultHeaders(http.MethodGet, checkURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
statusCode := resp.StatusCode
|
||||||
|
switch apiType {
|
||||||
|
case "amazon":
|
||||||
|
return statusCode == http.StatusOK && strings.Contains(string(body), `"amazonMusic":"up"`)
|
||||||
|
case "qobuz", "qbz":
|
||||||
|
return statusCode == http.StatusOK && containsStreamingURL(body)
|
||||||
|
case "lrclib":
|
||||||
|
return statusCode == http.StatusOK && containsLRCLIBResults(body)
|
||||||
|
case "musicbrainz":
|
||||||
|
return statusCode == http.StatusOK && containsMusicBrainzResults(body)
|
||||||
|
default:
|
||||||
|
return statusCode == http.StatusOK
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) Quit() {
|
func (a *App) Quit() {
|
||||||
|
|
||||||
panic("quit")
|
panic("quit")
|
||||||
|
|||||||
+17
-5
@@ -56,12 +56,11 @@ 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")
|
|
||||||
|
|
||||||
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 +97,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 +288,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 +417,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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,17 @@
|
|||||||
|
package backend
|
||||||
|
|
||||||
|
const amazonMusicAPIBaseURL = "https://amazon.spotbye.qzz.io"
|
||||||
|
|
||||||
|
var defaultQobuzStreamAPIBaseURLs = []string{
|
||||||
|
"https://dab.yeet.su/api/stream?trackId=",
|
||||||
|
"https://dabmusic.xyz/api/stream?trackId=",
|
||||||
|
"https://qobuz.spotbye.qzz.io/api/track/",
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetQobuzStreamAPIBaseURLs() []string {
|
||||||
|
return append([]string(nil), defaultQobuzStreamAPIBaseURLs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetAmazonMusicAPIBaseURL() string {
|
||||||
|
return amazonMusicAPIBaseURL
|
||||||
|
}
|
||||||
+20
-9
@@ -139,7 +139,7 @@ func (q *QobuzDownloader) searchByISRC(isrc string) (*QobuzTrack, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func buildQobuzAPIURL(apiBase string, trackID int64, quality string) string {
|
func buildQobuzAPIURL(apiBase string, trackID int64, quality string) string {
|
||||||
if strings.Contains(apiBase, "qbz.afkarxyz.qzz.io") {
|
if strings.Contains(apiBase, "qobuz.spotbye.qzz.io") {
|
||||||
return fmt.Sprintf("%s%d?quality=%s", apiBase, trackID, quality)
|
return fmt.Sprintf("%s%d?quality=%s", apiBase, trackID, quality)
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("%s%d&quality=%s", apiBase, trackID, quality)
|
return fmt.Sprintf("%s%d&quality=%s", apiBase, trackID, quality)
|
||||||
@@ -147,7 +147,12 @@ func buildQobuzAPIURL(apiBase string, trackID int64, quality string) string {
|
|||||||
|
|
||||||
func (q *QobuzDownloader) DownloadFromStandard(apiBase string, trackID int64, quality string) (string, error) {
|
func (q *QobuzDownloader) DownloadFromStandard(apiBase string, trackID int64, quality string) (string, error) {
|
||||||
apiURL := buildQobuzAPIURL(apiBase, trackID, quality)
|
apiURL := buildQobuzAPIURL(apiBase, trackID, quality)
|
||||||
resp, err := q.client.Get(apiURL)
|
req, err := NewRequestWithDefaultHeaders(http.MethodGet, apiURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := q.client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -191,11 +196,7 @@ 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{
|
standardAPIs := prioritizeProviders("qobuz", GetQobuzStreamAPIBaseURLs())
|
||||||
"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 {
|
type Provider struct {
|
||||||
@@ -272,7 +273,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 +312,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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
+103
-343
@@ -9,7 +9,6 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -49,17 +48,9 @@ type TidalBTSManifest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NewTidalDownloader(apiURL string) *TidalDownloader {
|
func NewTidalDownloader(apiURL string) *TidalDownloader {
|
||||||
|
apiURL = strings.TrimRight(strings.TrimSpace(apiURL), "/")
|
||||||
if apiURL == "" {
|
if apiURL == "" {
|
||||||
downloader := &TidalDownloader{
|
apis, err := GetRotatedTidalAPIList()
|
||||||
client: &http.Client{
|
|
||||||
Timeout: 5 * time.Second,
|
|
||||||
},
|
|
||||||
timeout: 5 * time.Second,
|
|
||||||
maxRetries: 3,
|
|
||||||
apiURL: "",
|
|
||||||
}
|
|
||||||
|
|
||||||
apis, err := downloader.GetAvailableAPIs()
|
|
||||||
if err == nil && len(apis) > 0 {
|
if err == nil && len(apis) > 0 {
|
||||||
apiURL = apis[0]
|
apiURL = apis[0]
|
||||||
}
|
}
|
||||||
@@ -76,16 +67,12 @@ func NewTidalDownloader(apiURL string) *TidalDownloader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (t *TidalDownloader) GetAvailableAPIs() ([]string, error) {
|
func (t *TidalDownloader) GetAvailableAPIs() ([]string, error) {
|
||||||
apis := []string{
|
apis, err := GetRotatedTidalAPIList()
|
||||||
"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) {
|
||||||
@@ -129,14 +116,12 @@ func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (string,
|
|||||||
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)
|
||||||
@@ -194,13 +179,11 @@ func (t *TidalDownloader) DownloadFile(url, filepath string) error {
|
|||||||
return t.DownloadFromManifest(strings.TrimPrefix(url, "MANIFEST:"), filepath)
|
return t.DownloadFromManifest(strings.TrimPrefix(url, "MANIFEST:"), filepath)
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
||||||
@@ -241,11 +224,10 @@ func (t *TidalDownloader) DownloadFromManifest(manifestB64, outputPath string) e
|
|||||||
}
|
}
|
||||||
|
|
||||||
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 +399,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 +410,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 +421,29 @@ 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); err != nil {
|
||||||
return "", err
|
cleanupTidalDownloadArtifacts(outputFilename)
|
||||||
|
return outputFilename, err
|
||||||
}
|
}
|
||||||
|
if t.apiURL != "" {
|
||||||
isrc := strings.TrimSpace(isrcOverride)
|
if err := RememberTidalAPIUsage(t.apiURL); err != nil {
|
||||||
var mbMeta Metadata
|
fmt.Printf("Warning: failed to persist last used Tidal API: %v\n", err)
|
||||||
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
|
finalizeTidalDownload(outputFilename, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL, useSingleGenre, embedGenre)
|
||||||
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 +451,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 +462,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,7 +490,7 @@ 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)
|
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)
|
||||||
@@ -920,79 +658,101 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
|
|||||||
return "", initURL, mediaURLs, "", nil
|
return "", initURL, mediaURLs, "", 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 := GetRotatedTidalAPIList()
|
||||||
|
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); 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)
|
if err := RememberTidalAPIUsage(apiURL); err != nil {
|
||||||
resp.Body.Close()
|
fmt.Printf("Warning: failed to persist last used Tidal API: %v\n", err)
|
||||||
if err != nil {
|
|
||||||
lastError = err
|
|
||||||
recordProviderFailure("tidal", apiURL)
|
|
||||||
errors = append(errors, fmt.Sprintf("%s: read body failed", apiURL))
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var v2Response TidalAPIResponseV2
|
return apiURL, nil
|
||||||
if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" {
|
|
||||||
fmt.Printf("✓ Success with: %s\n", apiURL)
|
|
||||||
recordProviderSuccess("tidal", apiURL)
|
|
||||||
return apiURL, "MANIFEST:" + v2Response.Data.Manifest, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var v1Responses []TidalAPIResponse
|
if !refreshed {
|
||||||
if err := json.Unmarshal(body, &v1Responses); err == nil {
|
if _, refreshErr := RefreshTidalAPIList(true); refreshErr != nil {
|
||||||
for _, item := range v1Responses {
|
errors = append(errors, fmt.Sprintf("gist refresh failed: %v", refreshErr))
|
||||||
if item.OriginalTrackURL != "" {
|
} else {
|
||||||
fmt.Printf("✓ Success with: %s\n", apiURL)
|
fmt.Println("All cached Tidal APIs failed, refreshed gist list and retrying...")
|
||||||
recordProviderSuccess("tidal", apiURL)
|
return t.tryDownloadAcrossTidalAPIs(trackID, outputFilename, quality, true)
|
||||||
return apiURL, item.OriginalTrackURL, nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
lastError = fmt.Errorf("no download URL or manifest in response")
|
if lastErr == nil {
|
||||||
recordProviderFailure("tidal", apiURL)
|
lastErr = fmt.Errorf("all tidal apis failed")
|
||||||
errors = append(errors, fmt.Sprintf("%s: %v", apiURL, lastError))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("All APIs failed:")
|
fmt.Println("All Tidal APIs failed:")
|
||||||
for _, e := range errors {
|
for _, item := range errors {
|
||||||
fmt.Printf(" ✗ %s\n", e)
|
fmt.Printf(" ✗ %s\n", item)
|
||||||
}
|
}
|
||||||
|
|
||||||
return "", "", fmt.Errorf("all %d APIs failed. Last error: %v", len(apis), lastError)
|
return "", fmt.Errorf("all tidal apis failed for quality %s: %w", quality, lastErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanupTidalDownloadArtifacts(outputPath string) {
|
||||||
|
if outputPath == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = os.Remove(outputPath)
|
||||||
|
_ = os.Remove(outputPath + ".m4a.tmp")
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
|||||||
@@ -0,0 +1,238 @@
|
|||||||
|
package backend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const tidalAltDownloadAPIBaseURL = "https://tidal.spotbye.qzz.io/get"
|
||||||
|
|
||||||
|
type TidalAltAPIResponse struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Link string `json:"link"`
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (t *TidalDownloader) GetAltDownloadURLFromSpotify(spotifyTrackID string) (string, error) {
|
||||||
|
spotifyTrackID = strings.TrimSpace(spotifyTrackID)
|
||||||
|
if spotifyTrackID == "" {
|
||||||
|
return "", fmt.Errorf("spotify track ID is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
apiURL := fmt.Sprintf("%s/%s", tidalAltDownloadAPIBaseURL, spotifyTrackID)
|
||||||
|
fmt.Printf("Tidal Alt. API URL: %s\n", apiURL)
|
||||||
|
|
||||||
|
req, err := NewRequestWithDefaultHeaders(http.MethodGet, apiURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create Tidal Alt. request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := t.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to get Tidal Alt. download URL: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to read Tidal Alt. response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
preview := strings.TrimSpace(string(body))
|
||||||
|
if len(preview) > 200 {
|
||||||
|
preview = preview[:200] + "..."
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("Tidal Alt. returned status %d: %s", resp.StatusCode, preview)
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload TidalAltAPIResponse
|
||||||
|
if err := json.Unmarshal(body, &payload); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to decode Tidal Alt. response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadURL := strings.TrimSpace(payload.Link)
|
||||||
|
if downloadURL == "" {
|
||||||
|
return "", fmt.Errorf("Tidal Alt. response did not include a download link")
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("✓ Tidal Alt. download URL found")
|
||||||
|
return downloadURL, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *TidalDownloader) DownloadAlt(spotifyTrackID, outputDir, 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, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) {
|
||||||
|
spotifyTrackID = strings.TrimSpace(spotifyTrackID)
|
||||||
|
if spotifyTrackID == "" {
|
||||||
|
return "", fmt.Errorf("spotify track ID is required for Tidal Alt.")
|
||||||
|
}
|
||||||
|
|
||||||
|
outputFilename, alreadyExists, err := buildTidalOutputPath(outputDir, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyTrackNumber, spotifyDiscNumber, isrcOverride, useFirstArtistOnly)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if alreadyExists {
|
||||||
|
fmt.Printf("File already exists: %s (%.2f MB)\n", outputFilename, float64(mustFileSize(outputFilename))/(1024*1024))
|
||||||
|
return "EXISTS:" + outputFilename, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Using Tidal Alt. for Spotify track: %s\n", spotifyTrackID)
|
||||||
|
|
||||||
|
downloadURL, err := t.GetAltDownloadURLFromSpotify(spotifyTrackID)
|
||||||
|
if err != nil {
|
||||||
|
return outputFilename, err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Downloading to: %s\n", outputFilename)
|
||||||
|
if err := t.DownloadFile(downloadURL, outputFilename); err != nil {
|
||||||
|
cleanupTidalDownloadArtifacts(outputFilename)
|
||||||
|
return outputFilename, err
|
||||||
|
}
|
||||||
|
|
||||||
|
finalizeTidalDownload(outputFilename, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL, useSingleGenre, embedGenre)
|
||||||
|
|
||||||
|
fmt.Println("Done")
|
||||||
|
fmt.Println("✓ Downloaded successfully from Tidal Alt.")
|
||||||
|
return outputFilename, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
package backend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
tidalAPIListGistURL = "https://gist.githubusercontent.com/afkarxyz/2ce772b943321b9448b454f39403ce25/raw"
|
||||||
|
tidalAPIListCacheFile = "tidal-api-urls.json"
|
||||||
|
)
|
||||||
|
|
||||||
|
type tidalAPIListCache struct {
|
||||||
|
URLs []string `json:"urls"`
|
||||||
|
LastUsedURL string `json:"last_used_url,omitempty"`
|
||||||
|
UpdatedAt int64 `json:"updated_at_unix"`
|
||||||
|
Source string `json:"source,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
tidalAPIListMu sync.Mutex
|
||||||
|
tidalAPIListState *tidalAPIListCache
|
||||||
|
)
|
||||||
|
|
||||||
|
func loadTidalAPIListStateLocked() (*tidalAPIListCache, error) {
|
||||||
|
if tidalAPIListState != nil {
|
||||||
|
return cloneTidalAPIListState(tidalAPIListState), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
appDir, err := EnsureAppDir()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
cachePath := filepath.Join(appDir, tidalAPIListCacheFile)
|
||||||
|
data, err := os.ReadFile(cachePath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
state := &tidalAPIListCache{}
|
||||||
|
tidalAPIListState = cloneTidalAPIListState(state)
|
||||||
|
return cloneTidalAPIListState(state), nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("failed to read tidal api cache: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var state tidalAPIListCache
|
||||||
|
if err := json.Unmarshal(data, &state); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse tidal api cache: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
state.URLs = normalizeTidalAPIURLs(state.URLs)
|
||||||
|
|
||||||
|
tidalAPIListState = cloneTidalAPIListState(&state)
|
||||||
|
return cloneTidalAPIListState(&state), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveTidalAPIListStateLocked(state *tidalAPIListCache) error {
|
||||||
|
appDir, err := EnsureAppDir()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
cachePath := filepath.Join(appDir, tidalAPIListCacheFile)
|
||||||
|
payload, err := json.MarshalIndent(state, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to encode tidal api cache: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(cachePath, payload, 0o644); err != nil {
|
||||||
|
return fmt.Errorf("failed to write tidal api cache: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tidalAPIListState = cloneTidalAPIListState(state)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneTidalAPIListState(state *tidalAPIListCache) *tidalAPIListCache {
|
||||||
|
if state == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &tidalAPIListCache{
|
||||||
|
URLs: append([]string(nil), state.URLs...),
|
||||||
|
LastUsedURL: state.LastUsedURL,
|
||||||
|
UpdatedAt: state.UpdatedAt,
|
||||||
|
Source: state.Source,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeTidalAPIURLs(urls []string) []string {
|
||||||
|
seen := make(map[string]struct{}, len(urls))
|
||||||
|
normalized := make([]string, 0, len(urls))
|
||||||
|
|
||||||
|
for _, rawURL := range urls {
|
||||||
|
url := strings.TrimRight(strings.TrimSpace(rawURL), "/")
|
||||||
|
if url == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, exists := seen[url]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[url] = struct{}{}
|
||||||
|
normalized = append(normalized, url)
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchTidalAPIURLsFromGist() ([]string, error) {
|
||||||
|
client := &http.Client{Timeout: 12 * time.Second}
|
||||||
|
req, err := NewRequestWithDefaultHeaders(http.MethodGet, tidalAPIListGistURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create tidal api gist request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to fetch tidal api gist: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
preview, _ := io.ReadAll(io.LimitReader(resp.Body, 200))
|
||||||
|
return nil, fmt.Errorf("tidal api gist returned status %d: %s", resp.StatusCode, strings.TrimSpace(string(preview)))
|
||||||
|
}
|
||||||
|
|
||||||
|
var urls []string
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&urls); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode tidal api gist: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
urls = normalizeTidalAPIURLs(urls)
|
||||||
|
if len(urls) == 0 {
|
||||||
|
return nil, fmt.Errorf("tidal api gist returned no valid urls")
|
||||||
|
}
|
||||||
|
|
||||||
|
return urls, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func PrimeTidalAPIList() error {
|
||||||
|
_, err := RefreshTidalAPIList(true)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Warning: failed to refresh Tidal API list from gist: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tidalAPIListMu.Lock()
|
||||||
|
defer tidalAPIListMu.Unlock()
|
||||||
|
|
||||||
|
state, loadErr := loadTidalAPIListStateLocked()
|
||||||
|
if loadErr != nil {
|
||||||
|
return loadErr
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(state.URLs) == 0 {
|
||||||
|
return fmt.Errorf("tidal api cache is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
if state.UpdatedAt == 0 {
|
||||||
|
state.UpdatedAt = time.Now().Unix()
|
||||||
|
return saveTidalAPIListStateLocked(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func RefreshTidalAPIList(force bool) ([]string, error) {
|
||||||
|
tidalAPIListMu.Lock()
|
||||||
|
defer tidalAPIListMu.Unlock()
|
||||||
|
|
||||||
|
state, err := loadTidalAPIListStateLocked()
|
||||||
|
if err != nil {
|
||||||
|
state = &tidalAPIListCache{}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !force && len(state.URLs) > 0 {
|
||||||
|
return append([]string(nil), state.URLs...), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
urls, fetchErr := fetchTidalAPIURLsFromGist()
|
||||||
|
if fetchErr != nil {
|
||||||
|
if len(state.URLs) > 0 {
|
||||||
|
return append([]string(nil), state.URLs...), fetchErr
|
||||||
|
}
|
||||||
|
return nil, fetchErr
|
||||||
|
}
|
||||||
|
|
||||||
|
state.URLs = urls
|
||||||
|
state.UpdatedAt = time.Now().Unix()
|
||||||
|
state.Source = "gist"
|
||||||
|
|
||||||
|
if !containsString(state.URLs, state.LastUsedURL) {
|
||||||
|
state.LastUsedURL = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := saveTidalAPIListStateLocked(state); err != nil {
|
||||||
|
return append([]string(nil), state.URLs...), err
|
||||||
|
}
|
||||||
|
|
||||||
|
return append([]string(nil), state.URLs...), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetTidalAPIList() ([]string, error) {
|
||||||
|
tidalAPIListMu.Lock()
|
||||||
|
defer tidalAPIListMu.Unlock()
|
||||||
|
|
||||||
|
state, err := loadTidalAPIListStateLocked()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(state.URLs) == 0 {
|
||||||
|
return nil, fmt.Errorf("no cached tidal api urls")
|
||||||
|
}
|
||||||
|
|
||||||
|
return append([]string(nil), state.URLs...), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetRotatedTidalAPIList() ([]string, error) {
|
||||||
|
tidalAPIListMu.Lock()
|
||||||
|
defer tidalAPIListMu.Unlock()
|
||||||
|
|
||||||
|
state, err := loadTidalAPIListStateLocked()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
urls := state.URLs
|
||||||
|
if len(urls) == 0 {
|
||||||
|
return nil, fmt.Errorf("no cached tidal api urls")
|
||||||
|
}
|
||||||
|
|
||||||
|
return rotateTidalAPIURLs(urls, state.LastUsedURL), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func RememberTidalAPIUsage(apiURL string) error {
|
||||||
|
tidalAPIListMu.Lock()
|
||||||
|
defer tidalAPIListMu.Unlock()
|
||||||
|
|
||||||
|
state, err := loadTidalAPIListStateLocked()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
state.LastUsedURL = strings.TrimRight(strings.TrimSpace(apiURL), "/")
|
||||||
|
if state.UpdatedAt == 0 {
|
||||||
|
state.UpdatedAt = time.Now().Unix()
|
||||||
|
}
|
||||||
|
|
||||||
|
return saveTidalAPIListStateLocked(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
func rotateTidalAPIURLs(urls []string, lastUsedURL string) []string {
|
||||||
|
normalized := normalizeTidalAPIURLs(urls)
|
||||||
|
if len(normalized) < 2 {
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
lastUsedURL = strings.TrimRight(strings.TrimSpace(lastUsedURL), "/")
|
||||||
|
if lastUsedURL == "" {
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
lastIndex := -1
|
||||||
|
for idx, candidate := range normalized {
|
||||||
|
if candidate == lastUsedURL {
|
||||||
|
lastIndex = idx
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if lastIndex == -1 {
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
rotated := make([]string, 0, len(normalized))
|
||||||
|
rotated = append(rotated, normalized[lastIndex+1:]...)
|
||||||
|
rotated = append(rotated, normalized[:lastIndex+1]...)
|
||||||
|
return rotated
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsString(values []string, target string) bool {
|
||||||
|
target = strings.TrimRight(strings.TrimSpace(target), "/")
|
||||||
|
for _, value := range values {
|
||||||
|
if strings.TrimRight(strings.TrimSpace(value), "/") == target {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
+27
-11
@@ -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";
|
||||||
@@ -32,7 +32,7 @@ import { useMetadata } from "@/hooks/useMetadata";
|
|||||||
import { useLyrics } from "@/hooks/useLyrics";
|
import { useLyrics } from "@/hooks/useLyrics";
|
||||||
import { useCover } from "@/hooks/useCover";
|
import { useCover } from "@/hooks/useCover";
|
||||||
import { useAvailability } from "@/hooks/useAvailability";
|
import { useAvailability } from "@/hooks/useAvailability";
|
||||||
import { ensureApiStatusCheckStarted } from "@/lib/api-status";
|
import { ensureSpotiFLACNextStatusCheckStarted } from "@/lib/api-status";
|
||||||
import { useDownloadQueueDialog } from "@/hooks/useDownloadQueueDialog";
|
import { useDownloadQueueDialog } from "@/hooks/useDownloadQueueDialog";
|
||||||
import { useDownloadProgress } from "@/hooks/useDownloadProgress";
|
import { useDownloadProgress } from "@/hooks/useDownloadProgress";
|
||||||
import { buildPlaylistFolderName } from "@/lib/playlist";
|
import { buildPlaylistFolderName } from "@/lib/playlist";
|
||||||
@@ -125,6 +125,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("");
|
||||||
@@ -197,20 +198,33 @@ function App() {
|
|||||||
};
|
};
|
||||||
mediaQuery.addEventListener("change", handleChange);
|
mediaQuery.addEventListener("change", handleChange);
|
||||||
checkForUpdates();
|
checkForUpdates();
|
||||||
ensureApiStatusCheckStarted();
|
ensureSpotiFLACNextStatusCheckStarted();
|
||||||
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("");
|
||||||
@@ -584,16 +598,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}/>
|
||||||
|
|||||||
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 |
@@ -8,7 +8,6 @@ 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 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 KofiLogo from "@/assets/ko-fi.gif";
|
||||||
@@ -21,7 +20,12 @@ const browserExtensionItems = [
|
|||||||
{ 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" },
|
{ 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";
|
||||||
|
const projectCardHeaderClass = "px-5 gap-1.5";
|
||||||
|
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 AboutPage() {
|
export function AboutPage() {
|
||||||
const [activeTab, setActiveTab] = useState<"projects" | "support">("projects");
|
const [activeTab, setActiveTab] = useState<"projects" | "support">("projects");
|
||||||
const [repoStats, setRepoStats] = useState<Record<string, any>>({});
|
const [repoStats, setRepoStats] = useState<Record<string, any>>({});
|
||||||
@@ -44,8 +48,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,7 +179,7 @@ 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">About</h2>
|
||||||
</div>
|
</div>
|
||||||
@@ -195,17 +198,17 @@ export function AboutPage() {
|
|||||||
<div className="flex-1 min-h-0">
|
<div className="flex-1 min-h-0">
|
||||||
|
|
||||||
|
|
||||||
{activeTab === "projects" && (<div className="p-1 pr-2">
|
{activeTab === "projects" && (<div className="pr-1.5">
|
||||||
<div className="grid gap-2 grid-cols-4">
|
<div className="grid gap-2 grid-cols-3">
|
||||||
<Card className={`gap-2 ${projectCardClass}`} onClick={() => openExternal("https://github.com/spotbye/SpotiFLAC-Next")}>
|
<Card className={projectCardClass} onClick={() => openExternal("https://github.com/spotbye/SpotiFLAC-Next")}>
|
||||||
<CardHeader>
|
<CardHeader className={projectCardHeaderClass}>
|
||||||
<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 +216,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 +248,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 was created as a thank-you to everyone who has supported SpotiFLAC on Ko-fi.
|
||||||
</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 +270,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 +312,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.qzz.io/")}>
|
||||||
<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-[22px] w-[22px] 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,12 +327,12 @@ 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>
|
||||||
|
|||||||
@@ -1,34 +1,79 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { RefreshCw, CheckCircle2, XCircle, Loader2 } from "lucide-react";
|
import { SearchCheck, CheckCircle2, XCircle, Loader2 } from "lucide-react";
|
||||||
import { TidalIcon, QobuzIcon, AmazonIcon, LrclibIcon, MusicBrainzIcon } from "./PlatformIcons";
|
import { TidalIcon, QobuzIcon, AmazonIcon, MusicBrainzIcon, AppleMusicIcon, DeezerIcon } from "./PlatformIcons";
|
||||||
import { useApiStatus } from "@/hooks/useApiStatus";
|
import { useApiStatus } from "@/hooks/useApiStatus";
|
||||||
|
import { SPOTIFLAC_NEXT_SOURCES } from "@/lib/api-status";
|
||||||
|
function renderStatusIcon(status: "checking" | "online" | "offline" | "idle") {
|
||||||
|
if (status === "online") {
|
||||||
|
return <CheckCircle2 className="h-5 w-5 text-emerald-500"/>;
|
||||||
|
}
|
||||||
|
if (status === "offline") {
|
||||||
|
return <XCircle className="h-5 w-5 text-destructive"/>;
|
||||||
|
}
|
||||||
|
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 === "musicbrainz") {
|
||||||
|
return <MusicBrainzIcon 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, checkOne } = useApiStatus();
|
||||||
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">
|
<h3 className="text-sm font-semibold tracking-tight">SpotiFLAC Services</h3>
|
||||||
<RefreshCw className={`h-4 w-4 ${isCheckingAll ? "animate-spin" : ""}`}/>
|
|
||||||
Refresh All
|
|
||||||
</Button>
|
|
||||||
</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">
|
const isChecking = checkingSources[source.id] === true;
|
||||||
|
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">{renderStatusIcon(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>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => void checkOne(source.id)} disabled={isChecking} className="w-full gap-2">
|
||||||
|
{isChecking ? <Loader2 className="h-4 w-4 animate-spin"/> : <SearchCheck className="h-4 w-4"/>}
|
||||||
|
Check
|
||||||
|
</Button>
|
||||||
</div>);
|
</div>);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t"/>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-sm font-semibold tracking-tight">SpotiFLAC Next Services</h3>
|
||||||
|
|
||||||
|
<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">{renderStatusIcon(status)}</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}/>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,6 +102,9 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
|||||||
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 }));
|
||||||
};
|
};
|
||||||
|
const handleTidalVariantChange = (value: "tidal" | "alt") => {
|
||||||
|
setTempSettings((prev) => ({ ...prev, tidalVariant: value }));
|
||||||
|
};
|
||||||
const handleQobuzQualityChange = (value: "6" | "7" | "27") => {
|
const handleQobuzQualityChange = (value: "6" | "7" | "27") => {
|
||||||
setTempSettings((prev) => ({ ...prev, qobuzQuality: value }));
|
setTempSettings((prev) => ({ ...prev, qobuzQuality: value }));
|
||||||
};
|
};
|
||||||
@@ -424,7 +427,9 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
|||||||
</Select>
|
</Select>
|
||||||
</>)}
|
</>)}
|
||||||
|
|
||||||
{tempSettings.downloader === "tidal" && (<Select value={tempSettings.tidalQuality} onValueChange={handleTidalQualityChange}>
|
{tempSettings.downloader === "tidal" && (tempSettings.tidalVariant === "alt" ? (<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/44.1kHz
|
||||||
|
</div>) : (<Select value={tempSettings.tidalQuality} onValueChange={handleTidalQualityChange}>
|
||||||
<SelectTrigger className="h-9 w-fit">
|
<SelectTrigger className="h-9 w-fit">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -434,7 +439,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
|||||||
24-bit/48kHz
|
24-bit/48kHz
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>)}
|
</Select>))}
|
||||||
|
|
||||||
{tempSettings.downloader === "qobuz" && (<Select value={tempSettings.qobuzQuality} onValueChange={handleQobuzQualityChange}>
|
{tempSettings.downloader === "qobuz" && (<Select value={tempSettings.qobuzQuality} onValueChange={handleQobuzQualityChange}>
|
||||||
<SelectTrigger className="h-9 w-fit">
|
<SelectTrigger className="h-9 w-fit">
|
||||||
@@ -452,7 +457,21 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{(tempSettings.downloader === "tidal" || tempSettings.downloader === "auto") && (<div className="space-y-2 pt-2">
|
||||||
|
<Label htmlFor="tidal-variant">Tidal Variant</Label>
|
||||||
|
<Select value={tempSettings.tidalVariant || "tidal"} onValueChange={handleTidalVariantChange}>
|
||||||
|
<SelectTrigger id="tidal-variant" className="h-9 w-fit min-w-[160px]">
|
||||||
|
<SelectValue placeholder="Select Tidal variant"/>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="tidal">Tidal</SelectItem>
|
||||||
|
<SelectItem value="alt">Tidal Alt.</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>)}
|
||||||
|
|
||||||
{((tempSettings.downloader === "tidal" &&
|
{((tempSettings.downloader === "tidal" &&
|
||||||
|
tempSettings.tidalVariant !== "alt" &&
|
||||||
tempSettings.tidalQuality === "HI_RES_LOSSLESS") ||
|
tempSettings.tidalQuality === "HI_RES_LOSSLESS") ||
|
||||||
(tempSettings.downloader === "qobuz" &&
|
(tempSettings.downloader === "qobuz" &&
|
||||||
tempSettings.qobuzQuality === "27") ||
|
tempSettings.qobuzQuality === "27") ||
|
||||||
|
|||||||
@@ -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, 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,6 @@ export function useApiStatus() {
|
|||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
sources: API_SOURCES,
|
sources: API_SOURCES,
|
||||||
refreshAll: () => checkAllApiStatuses(true),
|
checkOne: (sourceId: string) => checkApiStatus(sourceId),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,24 @@ async function resolveTemplateISRC(settings: {
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
function getTidalVariant(settings: any): "tidal" | "alt" {
|
||||||
|
return settings?.tidalVariant === "alt" ? "alt" : "tidal";
|
||||||
|
}
|
||||||
|
function isTidalAltVariant(settings: any): boolean {
|
||||||
|
return getTidalVariant(settings) === "alt";
|
||||||
|
}
|
||||||
|
function getTidalAudioFormat(settings: any, mode: "single" | "auto"): "LOSSLESS" | "HI_RES_LOSSLESS" {
|
||||||
|
if (isTidalAltVariant(settings)) {
|
||||||
|
return "LOSSLESS";
|
||||||
|
}
|
||||||
|
if (mode === "auto") {
|
||||||
|
return (settings.autoQuality || "24") === "24" ? "HI_RES_LOSSLESS" : "LOSSLESS";
|
||||||
|
}
|
||||||
|
return settings.tidalQuality || "LOSSLESS";
|
||||||
|
}
|
||||||
|
function shouldFetchStreamingURLs(order: string[], settings: any): boolean {
|
||||||
|
return order.includes("amazon") || (order.includes("tidal") && !isTidalAltVariant(settings));
|
||||||
|
}
|
||||||
export function useDownload(region: string) {
|
export function useDownload(region: string) {
|
||||||
const [downloadProgress, setDownloadProgress] = useState<number>(0);
|
const [downloadProgress, setDownloadProgress] = useState<number>(0);
|
||||||
const [isDownloading, setIsDownloading] = useState(false);
|
const [isDownloading, setIsDownloading] = useState(false);
|
||||||
@@ -170,8 +188,11 @@ 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 = (settings.autoOrder || "tidal-amazon-qobuz").split("-");
|
||||||
|
const tidalVariant = getTidalVariant(settings);
|
||||||
|
const tidalLabel = tidalVariant === "alt" ? "Tidal Alt." : "Tidal";
|
||||||
let streamingURLs: any = null;
|
let streamingURLs: any = null;
|
||||||
if (spotifyId) {
|
if (spotifyId && shouldFetchStreamingURLs(order, settings)) {
|
||||||
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 +203,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" && ((tidalVariant === "alt" && spotifyId) || streamingURLs?.tidal_url)) {
|
||||||
try {
|
try {
|
||||||
logger.debug(`trying tidal for: ${trackName} - ${artistName}`);
|
logger.debug(`trying ${tidalLabel} for: ${trackName} - ${artistName}`);
|
||||||
const response = await downloadTrack({
|
const response = await downloadTrack({
|
||||||
service: "tidal",
|
service: "tidal",
|
||||||
query,
|
query,
|
||||||
@@ -209,7 +229,8 @@ 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: tidalVariant === "alt" ? undefined : streamingURLs?.tidal_url,
|
||||||
|
tidal_variant: tidalVariant,
|
||||||
duration: durationSeconds,
|
duration: durationSeconds,
|
||||||
item_id: itemID,
|
item_id: itemID,
|
||||||
audio_format: tidalQuality,
|
audio_format: tidalQuality,
|
||||||
@@ -225,17 +246,17 @@ 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(`${tidalLabel}: ${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(`[${tidalLabel}] ${errMsg}`);
|
||||||
lastResponse = response;
|
lastResponse = response;
|
||||||
logger.warning(`tidal failed, trying next...`);
|
logger.warning(`${tidalLabel} failed, trying next...`);
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
logger.error(`tidal error: ${err}`);
|
logger.error(`${tidalLabel} error: ${err}`);
|
||||||
fallbackErrors.push(`[Tidal] ${String(err)}`);
|
fallbackErrors.push(`[${tidalLabel}] ${String(err)}`);
|
||||||
lastResponse = { success: false, error: String(err) };
|
lastResponse = { success: false, error: String(err) };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -344,7 +365,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 +394,7 @@ export function useDownload(region: string) {
|
|||||||
duration: durationSecondsForFallback,
|
duration: durationSecondsForFallback,
|
||||||
item_id: itemID,
|
item_id: itemID,
|
||||||
audio_format: audioFormat,
|
audio_format: audioFormat,
|
||||||
|
tidal_variant: service === "tidal" ? getTidalVariant(settings) : 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 +402,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,
|
||||||
});
|
});
|
||||||
@@ -451,8 +474,11 @@ export function useDownload(region: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (service === "auto") {
|
if (service === "auto") {
|
||||||
|
const order = (settings.autoOrder || "tidal-amazon-qobuz").split("-");
|
||||||
|
const tidalVariant = getTidalVariant(settings);
|
||||||
|
const tidalLabel = tidalVariant === "alt" ? "Tidal Alt." : "Tidal";
|
||||||
let streamingURLs: any = null;
|
let streamingURLs: any = null;
|
||||||
if (spotifyId) {
|
if (spotifyId && shouldFetchStreamingURLs(order, settings)) {
|
||||||
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 +489,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" && ((tidalVariant === "alt" && spotifyId) || streamingURLs?.tidal_url)) {
|
||||||
try {
|
try {
|
||||||
logger.debug(`trying tidal for: ${trackName} - ${artistName}`);
|
logger.debug(`trying ${tidalLabel} for: ${trackName} - ${artistName}`);
|
||||||
const response = await downloadTrack({
|
const response = await downloadTrack({
|
||||||
service: "tidal",
|
service: "tidal",
|
||||||
query,
|
query,
|
||||||
@@ -490,7 +515,8 @@ 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: tidalVariant === "alt" ? undefined : streamingURLs?.tidal_url,
|
||||||
|
tidal_variant: tidalVariant,
|
||||||
duration: durationSeconds,
|
duration: durationSeconds,
|
||||||
item_id: itemID,
|
item_id: itemID,
|
||||||
audio_format: tidalQuality,
|
audio_format: tidalQuality,
|
||||||
@@ -506,17 +532,17 @@ 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(`${tidalLabel}: ${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(`[${tidalLabel}] ${errMsg}`);
|
||||||
lastResponse = response;
|
lastResponse = response;
|
||||||
logger.warning(`tidal failed, trying next...`);
|
logger.warning(`${tidalLabel} failed, trying next...`);
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
logger.error(`tidal error: ${err}`);
|
logger.error(`${tidalLabel} error: ${err}`);
|
||||||
fallbackErrors.push(`[Tidal] ${String(err)}`);
|
fallbackErrors.push(`[${tidalLabel}] ${String(err)}`);
|
||||||
lastResponse = { success: false, error: String(err) };
|
lastResponse = { success: false, error: String(err) };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -628,7 +654,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";
|
||||||
@@ -653,6 +679,7 @@ export function useDownload(region: string) {
|
|||||||
duration: durationSecondsForFallback,
|
duration: durationSecondsForFallback,
|
||||||
item_id: itemID,
|
item_id: itemID,
|
||||||
audio_format: audioFormat,
|
audio_format: audioFormat,
|
||||||
|
tidal_variant: service === "tidal" ? getTidalVariant(settings) : 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,
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
+171
-104
@@ -1,4 +1,4 @@
|
|||||||
import { CheckAPIStatus, FetchUnifiedAPIStatus } from "../../wailsjs/go/main/App";
|
import { CheckAPIStatus } from "../../wailsjs/go/main/App";
|
||||||
import { CHECK_TIMEOUT_MS, withTimeout } from "@/lib/async-timeout";
|
import { CHECK_TIMEOUT_MS, withTimeout } from "@/lib/async-timeout";
|
||||||
export type ApiCheckStatus = "checking" | "online" | "offline" | "idle";
|
export type ApiCheckStatus = "checking" | "online" | "offline" | "idle";
|
||||||
export interface ApiSource {
|
export interface ApiSource {
|
||||||
@@ -7,39 +7,51 @@ export interface ApiSource {
|
|||||||
name: string;
|
name: string;
|
||||||
url: string;
|
url: string;
|
||||||
}
|
}
|
||||||
export const API_SOURCES: ApiSource[] = [
|
interface SpotiFLACNextSource {
|
||||||
{ id: "tidal1", type: "tidal", name: "Tidal A", url: "https://hifi-one.spotisaver.net" },
|
id: string;
|
||||||
{ id: "tidal2", type: "tidal", name: "Tidal B", url: "https://hifi-two.spotisaver.net" },
|
name: string;
|
||||||
{ id: "tidal3", type: "tidal", name: "Tidal C", url: "https://eu-central.monochrome.tf" },
|
}
|
||||||
{ id: "tidal4", type: "tidal", name: "Tidal D", url: "https://us-west.monochrome.tf" },
|
type SpotiFLACNextStatusResponse = {
|
||||||
{ 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" },
|
|
||||||
];
|
|
||||||
type ApiStatusState = {
|
|
||||||
isCheckingAll: boolean;
|
|
||||||
statuses: Record<string, ApiCheckStatus>;
|
|
||||||
};
|
|
||||||
let apiStatusState: ApiStatusState = {
|
|
||||||
isCheckingAll: false,
|
|
||||||
statuses: {},
|
|
||||||
};
|
|
||||||
let activeCheckAll: Promise<void> | null = null;
|
|
||||||
const listeners = new Set<() => void>();
|
|
||||||
type SpotiFLACUnifiedStatusResponse = {
|
|
||||||
tidal?: string;
|
tidal?: string;
|
||||||
qobuz_a?: string;
|
qobuz_a?: string;
|
||||||
qobuz_b?: string;
|
qobuz_b?: string;
|
||||||
qobuz_c?: string;
|
qobuz_c?: string;
|
||||||
amazon?: string;
|
deezer_a?: string;
|
||||||
lrclib?: string;
|
deezer_b?: string;
|
||||||
|
amazon_a?: string;
|
||||||
|
amazon_b?: string;
|
||||||
|
amazon_c?: string;
|
||||||
|
apple?: string;
|
||||||
};
|
};
|
||||||
|
export const API_SOURCES: ApiSource[] = [
|
||||||
|
{ id: "tidal", type: "tidal", name: "Tidal", url: "" },
|
||||||
|
{ id: "qobuz", type: "qobuz", name: "Qobuz", url: "" },
|
||||||
|
{ id: "amazon", type: "amazon", name: "Amazon Music", url: "" },
|
||||||
|
{ id: "musicbrainz", type: "musicbrainz", name: "MusicBrainz", url: "https://musicbrainz.org" },
|
||||||
|
];
|
||||||
|
export const SPOTIFLAC_NEXT_SOURCES: SpotiFLACNextSource[] = [
|
||||||
|
{ id: "tidal", name: "Tidal" },
|
||||||
|
{ id: "qobuz", name: "Qobuz" },
|
||||||
|
{ id: "amazon", name: "Amazon Music" },
|
||||||
|
{ id: "deezer", name: "Deezer" },
|
||||||
|
{ id: "apple", name: "Apple Music" },
|
||||||
|
];
|
||||||
|
const SPOTIFLAC_NEXT_STATUS_URL = "https://status.spotbye.qzz.io/status";
|
||||||
|
const SPOTIFLAC_NEXT_MAX_ATTEMPTS = 3;
|
||||||
|
const SPOTIFLAC_NEXT_RETRY_DELAY_MS = 1200;
|
||||||
|
type ApiStatusState = {
|
||||||
|
checkingSources: Record<string, boolean>;
|
||||||
|
statuses: Record<string, ApiCheckStatus>;
|
||||||
|
nextStatuses: Record<string, ApiCheckStatus>;
|
||||||
|
};
|
||||||
|
let apiStatusState: ApiStatusState = {
|
||||||
|
checkingSources: {},
|
||||||
|
statuses: {},
|
||||||
|
nextStatuses: {},
|
||||||
|
};
|
||||||
|
let activeCheckNextOnly: Promise<void> | null = null;
|
||||||
|
const activeSourceChecks = new Map<string, Promise<void>>();
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
function emitApiStatusChange() {
|
function emitApiStatusChange() {
|
||||||
for (const listener of listeners) {
|
for (const listener of listeners) {
|
||||||
listener();
|
listener();
|
||||||
@@ -49,39 +61,66 @@ function setApiStatusState(updater: (current: ApiStatusState) => ApiStatusState)
|
|||||||
apiStatusState = updater(apiStatusState);
|
apiStatusState = updater(apiStatusState);
|
||||||
emitApiStatusChange();
|
emitApiStatusChange();
|
||||||
}
|
}
|
||||||
function statusFromUnifiedValue(value: string | undefined): ApiCheckStatus {
|
async function checkSourceStatus(source: ApiSource): Promise<ApiCheckStatus> {
|
||||||
return value === "up" ? "online" : "offline";
|
|
||||||
}
|
|
||||||
async function fetchUnifiedStatuses(forceRefresh: boolean): Promise<Pick<ApiStatusState, "statuses">> {
|
|
||||||
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");
|
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";
|
return isOnline ? "online" : "offline";
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
return "offline";
|
return "offline";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
function statusFromNextValue(value: string | undefined): ApiCheckStatus {
|
||||||
|
return value === "up" ? "online" : "offline";
|
||||||
|
}
|
||||||
|
function anyNextVariantUp(values: Array<string | undefined>): ApiCheckStatus {
|
||||||
|
return values.some((value) => value === "up") ? "online" : "offline";
|
||||||
|
}
|
||||||
|
function delay(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
async function fetchSpotiFLACNextStatusesOnce(): Promise<Record<string, ApiCheckStatus>> {
|
||||||
|
const response = await withTimeout(fetch(SPOTIFLAC_NEXT_STATUS_URL, {
|
||||||
|
method: "GET",
|
||||||
|
cache: "no-store",
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
},
|
||||||
|
}), CHECK_TIMEOUT_MS, "SpotiFLAC Next status check timed out after 10 seconds");
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`SpotiFLAC Next status returned ${response.status}`);
|
||||||
|
}
|
||||||
|
const payload = (await response.json()) as SpotiFLACNextStatusResponse;
|
||||||
|
return {
|
||||||
|
tidal: statusFromNextValue(payload.tidal),
|
||||||
|
qobuz: anyNextVariantUp([payload.qobuz_a, payload.qobuz_b, payload.qobuz_c]),
|
||||||
|
deezer: anyNextVariantUp([payload.deezer_a, payload.deezer_b]),
|
||||||
|
amazon: anyNextVariantUp([payload.amazon_a, payload.amazon_b, payload.amazon_c]),
|
||||||
|
apple: statusFromNextValue(payload.apple),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
async function checkSpotiFLACNextStatuses(): Promise<Record<string, ApiCheckStatus>> {
|
||||||
|
let lastError: unknown = null;
|
||||||
|
for (let attempt = 1; attempt <= SPOTIFLAC_NEXT_MAX_ATTEMPTS; attempt++) {
|
||||||
|
try {
|
||||||
|
return await fetchSpotiFLACNextStatusesOnce();
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
if (attempt < SPOTIFLAC_NEXT_MAX_ATTEMPTS) {
|
||||||
|
await delay(SPOTIFLAC_NEXT_RETRY_DELAY_MS * attempt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastError instanceof Error ? lastError : new Error("SpotiFLAC Next status check failed");
|
||||||
|
}
|
||||||
export function getApiStatusState(): ApiStatusState {
|
export function getApiStatusState(): ApiStatusState {
|
||||||
return apiStatusState;
|
return apiStatusState;
|
||||||
}
|
}
|
||||||
@@ -91,70 +130,98 @@ export function subscribeApiStatus(listener: () => void): () => void {
|
|||||||
listeners.delete(listener);
|
listeners.delete(listener);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
export function hasApiStatusResults(): boolean {
|
function hasSpotiFLACNextResults(): boolean {
|
||||||
return API_SOURCES.some((source) => {
|
return SPOTIFLAC_NEXT_SOURCES.some((source) => {
|
||||||
const status = apiStatusState.statuses[source.id];
|
const status = apiStatusState.nextStatuses[source.id];
|
||||||
return status === "online" || status === "offline";
|
return status === "online" || status === "offline";
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
export function ensureApiStatusCheckStarted(): void {
|
export async function checkSpotiFLACNextStatusesOnly(): Promise<void> {
|
||||||
if (!activeCheckAll && !hasApiStatusResults()) {
|
if (activeCheckNextOnly) {
|
||||||
void checkAllApiStatuses(false);
|
return activeCheckNextOnly;
|
||||||
}
|
}
|
||||||
}
|
activeCheckNextOnly = (async () => {
|
||||||
export async function checkAllApiStatuses(forceRefresh: boolean = false): Promise<void> {
|
const checkingNextStatuses = Object.fromEntries(SPOTIFLAC_NEXT_SOURCES.map((source) => [source.id, "checking" as ApiCheckStatus]));
|
||||||
if (activeCheckAll) {
|
|
||||||
return activeCheckAll;
|
|
||||||
}
|
|
||||||
activeCheckAll = (async () => {
|
|
||||||
const checkingStatuses = Object.fromEntries(API_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([
|
setApiStatusState((current) => ({
|
||||||
withTimeout(fetchUnifiedStatuses(forceRefresh), CHECK_TIMEOUT_MS, "Unified SpotiFLAC status check timed out after 10 seconds"),
|
|
||||||
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 },
|
||||||
};
|
}));
|
||||||
});
|
const nextStatuses = await checkSpotiFLACNextStatuses();
|
||||||
|
setApiStatusState((current) => ({
|
||||||
|
...current,
|
||||||
|
nextStatuses: {
|
||||||
|
...current.nextStatuses,
|
||||||
|
...nextStatuses,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
setApiStatusState((current) => ({
|
||||||
|
...current,
|
||||||
|
nextStatuses: getSafeNextStatusesFallback(current.nextStatuses),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
activeCheckNextOnly = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return activeCheckNextOnly;
|
||||||
|
}
|
||||||
|
export function ensureSpotiFLACNextStatusCheckStarted(): void {
|
||||||
|
if (!activeCheckNextOnly && !hasSpotiFLACNextResults()) {
|
||||||
|
void checkSpotiFLACNextStatusesOnly();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ export async function fetchSpotifyMetadata(url: string, batch: boolean = true, d
|
|||||||
}
|
}
|
||||||
export async function downloadTrack(request: DownloadRequest): Promise<DownloadResponse> {
|
export async function downloadTrack(request: DownloadRequest): Promise<DownloadResponse> {
|
||||||
const req = new main.DownloadRequest(request);
|
const req = new main.DownloadRequest(request);
|
||||||
|
if (request.tidal_variant !== undefined) {
|
||||||
|
(req as any).tidal_variant = request.tidal_variant;
|
||||||
|
}
|
||||||
if (request.use_single_genre !== undefined) {
|
if (request.use_single_genre !== undefined) {
|
||||||
(req as any).use_single_genre = request.use_single_genre;
|
(req as any).use_single_genre = request.use_single_genre;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export interface Settings {
|
|||||||
embedLyrics: boolean;
|
embedLyrics: boolean;
|
||||||
embedMaxQualityCover: boolean;
|
embedMaxQualityCover: boolean;
|
||||||
operatingSystem: "Windows" | "linux/MacOS";
|
operatingSystem: "Windows" | "linux/MacOS";
|
||||||
|
tidalVariant: "tidal" | "alt";
|
||||||
tidalQuality: "LOSSLESS" | "HI_RES_LOSSLESS";
|
tidalQuality: "LOSSLESS" | "HI_RES_LOSSLESS";
|
||||||
qobuzQuality: "6" | "7" | "27";
|
qobuzQuality: "6" | "7" | "27";
|
||||||
amazonQuality: "original";
|
amazonQuality: "original";
|
||||||
@@ -110,6 +111,7 @@ export const DEFAULT_SETTINGS: Settings = {
|
|||||||
embedLyrics: false,
|
embedLyrics: false,
|
||||||
embedMaxQualityCover: false,
|
embedMaxQualityCover: false,
|
||||||
operatingSystem: detectOS(),
|
operatingSystem: detectOS(),
|
||||||
|
tidalVariant: "tidal",
|
||||||
tidalQuality: "LOSSLESS",
|
tidalQuality: "LOSSLESS",
|
||||||
qobuzQuality: "6",
|
qobuzQuality: "6",
|
||||||
amazonQuality: "original",
|
amazonQuality: "original",
|
||||||
@@ -215,6 +217,9 @@ function getSettingsFromLocalStorage(): Settings {
|
|||||||
if (!('tidalQuality' in parsed)) {
|
if (!('tidalQuality' in parsed)) {
|
||||||
parsed.tidalQuality = "LOSSLESS";
|
parsed.tidalQuality = "LOSSLESS";
|
||||||
}
|
}
|
||||||
|
if (!('tidalVariant' in parsed)) {
|
||||||
|
parsed.tidalVariant = "tidal";
|
||||||
|
}
|
||||||
if (!('qobuzQuality' in parsed)) {
|
if (!('qobuzQuality' in parsed)) {
|
||||||
parsed.qobuzQuality = "6";
|
parsed.qobuzQuality = "6";
|
||||||
}
|
}
|
||||||
@@ -306,6 +311,9 @@ export async function loadSettings(): Promise<Settings> {
|
|||||||
if (!('tidalQuality' in parsed)) {
|
if (!('tidalQuality' in parsed)) {
|
||||||
parsed.tidalQuality = "LOSSLESS";
|
parsed.tidalQuality = "LOSSLESS";
|
||||||
}
|
}
|
||||||
|
if (!('tidalVariant' in parsed)) {
|
||||||
|
parsed.tidalVariant = "tidal";
|
||||||
|
}
|
||||||
if (!('qobuzQuality' in parsed)) {
|
if (!('qobuzQuality' in parsed)) {
|
||||||
parsed.qobuzQuality = "6";
|
parsed.qobuzQuality = "6";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ export interface DownloadRequest {
|
|||||||
release_date?: string;
|
release_date?: string;
|
||||||
cover_url?: string;
|
cover_url?: string;
|
||||||
tidal_api_url?: string;
|
tidal_api_url?: string;
|
||||||
|
tidal_variant?: "tidal" | "alt";
|
||||||
output_dir?: string;
|
output_dir?: string;
|
||||||
audio_format?: string;
|
audio_format?: string;
|
||||||
folder_name?: string;
|
folder_name?: string;
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@
|
|||||||
},
|
},
|
||||||
"info": {
|
"info": {
|
||||||
"productName": "SpotiFLAC",
|
"productName": "SpotiFLAC",
|
||||||
"productVersion": "7.1.4",
|
"productVersion": "7.1.5",
|
||||||
"copyright": "© 2026 afkarxyz"
|
"copyright": "© 2026 afkarxyz"
|
||||||
},
|
},
|
||||||
"wailsjsdir": "./frontend",
|
"wailsjsdir": "./frontend",
|
||||||
|
|||||||
Reference in New Issue
Block a user