Compare commits

..

77 Commits

Author SHA1 Message Date
afkarxyz 2f78f7e7c7 .cleanup 2026-04-19 23:14:12 +07:00
afkarxyz 7c52c2d9b4 .tidal alt 2026-04-19 23:12:06 +07:00
afkarxyz a24ca370eb .refine status check 2026-04-19 22:35:03 +07:00
afkarxyz 3af9327a3d .tidal gist url 2026-04-19 22:15:49 +07:00
afkarxyz a3e780587b .manual check 2026-04-19 21:54:56 +07:00
afkarxyz 043f3f07f3 .refine global scrollbar 2026-04-19 21:48:25 +07:00
afkarxyz bcea7a00bd .remove spotidownloader 2026-04-19 21:41:56 +07:00
afkarxyz e74808fb07 .update url 2026-04-18 07:59:13 +07:00
afkarxyz 17a75ea278 .arm64 linux 2026-04-18 07:52:20 +07:00
afkarxyz d6907641ed .revert build 2026-04-18 07:40:34 +07:00
afkarxyz e9b6b02db1 .readme 2026-04-14 07:36:00 +07:00
afkarxyz a9c52e7b6d .cleanup 2026-04-14 07:28:39 +07:00
afkarxyz ce1e6cc65a .build rename 2026-04-14 07:25:16 +07:00
afkarxyz f75081780e .unified status check 2026-04-14 07:14:18 +07:00
afkarxyz c0c1348c3f .skip off musicbrainz 2026-04-14 06:38:02 +07:00
afkarxyz f123caf5b0 .status icons update 2026-04-14 06:35:18 +07:00
afkarxyz 4c5bba73ce .rate limit musicbrainz 2026-04-14 06:06:12 +07:00
afkarxyz 1858fd6f12 .update ffmpeg + linux arm build 2026-04-14 05:49:23 +07:00
afkarxyz 42d25abe0c .rename 2026-04-13 23:30:43 +07:00
afkarxyz 927aad30e7 .move website below network info 2026-04-13 23:01:14 +07:00
afkarxyz 7320cfb6ca .failed fetch pop up 2026-04-13 23:00:10 +07:00
afkarxyz d85d3174e9 .refine ip info 2026-04-13 22:57:34 +07:00
afkarxyz eda188d4b0 .ip detection 2026-04-13 22:56:57 +07:00
afkarxyz 7997f7e264 .remove spotfetch api 2026-04-13 22:50:45 +07:00
afkarxyz e23fa2a48e .unified totp 2026-04-13 22:43:35 +07:00
afkarxyz 5a3f819cef .upc metadata 2026-04-13 22:39:58 +07:00
afkarxyz 66e3f0e572 .improved recent fetches 2026-04-13 22:18:08 +07:00
afkarxyz 2684bc54bd .improve fetch track list info 2026-04-13 22:07:34 +07:00
afkarxyz db8f82aa17 .redownlaod with suffix, isrc variable 2026-04-13 21:53:47 +07:00
afkarxyz 7792a69d33 .add composer and fix multiple value tag 2026-04-13 21:35:55 +07:00
afkarxyz e79622751d .clickable variables 2026-04-13 21:17:07 +07:00
afkarxyz 1b00badd93 .playlist owner folder name 2026-04-13 20:56:24 +07:00
afkarxyz 24d640443a .refine check availibility 2026-04-13 20:43:37 +07:00
afkarxyz 967feb93e1 .fix qobuz api 2026-04-13 20:29:19 +07:00
afkarxyz 475596d934 .readme 2026-04-02 18:38:49 +07:00
afkarxyz 0d42bc3877 .time ago 2026-04-02 18:34:48 +07:00
afkarxyz 7f12b76fd9 .cleanup 2026-04-02 17:41:25 +07:00
afkarxyz 99f5e4e8b3 .reorder layout 2026-04-02 16:28:21 +07:00
afkarxyz 2d2ceac569 .dropdown region songlink 2026-04-02 16:22:14 +07:00
afkarxyz 5fa9da8e23 .refine about page 2026-04-02 16:10:28 +07:00
afkarxyz 0237895603 .refine about page 2026-04-02 15:46:35 +07:00
afkarxyz fc5bda3b26 .url metadata 2026-04-02 15:30:55 +07:00
afkarxyz af72ca0d01 .fix qobuz check 2026-04-02 15:22:23 +07:00
afkarxyz 42278aa1f3 .songlink default 2026-04-02 14:53:46 +07:00
afkarxyz 1128b0245f .deezer url log 2026-04-02 14:48:52 +07:00
afkarxyz 460405a437 .isrc finder fallback 2026-04-02 12:11:56 +07:00
afkarxyz 4b3bf1cf48 .cleanup 2026-04-02 11:45:42 +07:00
afkarxyz 41eda2d230 .batch audio quality analyzer 2026-04-02 11:30:00 +07:00
afkarxyz 78caf6cc61 .simple open folder 2026-04-02 10:36:40 +07:00
afkarxyz 9314b8ec99 .fix audio analyzer alac 2026-04-02 10:31:56 +07:00
afkarxyz cfcb890469 .link resolver 2026-04-02 10:14:49 +07:00
afkarxyz e74ac07afc .rename 2026-04-02 09:34:41 +07:00
afkarxyz 0475529535 .spotfetch isrc 2026-04-02 09:33:12 +07:00
afkarxyz 264b474903 .refine check status 2026-04-02 08:55:24 +07:00
afkarxyz 6066278fe6 .icon 2026-04-02 08:46:34 +07:00
afkarxyz cf36d28444 .separate songlink 2026-04-02 08:36:42 +07:00
afkarxyz 7ce66b4732 .priority api 2026-04-02 08:29:37 +07:00
afkarxyz b96fc8d96c .isrc db 2026-04-02 08:23:58 +07:00
afkarxyz 6de2bae67b .isrc finder 2026-04-02 08:17:35 +07:00
afkarxyz 3e04868746 .update 2026-04-02 08:00:56 +07:00
afkarxyz e3f8f7be0a .cleanup 2026-03-25 20:53:26 +07:00
afkarxyz 5ebd28982b .final 2026-03-25 20:44:31 +07:00
afkarxyz f8ef1180f6 .improve audio quality analyzer 2026-03-25 20:10:05 +07:00
afkarxyz 386c541658 .reorder audio tools 2026-03-25 19:56:23 +07:00
afkarxyz d60a068cab .x 2026-03-25 19:47:40 +07:00
afkarxyz 78adc15be3 .tools refine hover 2026-03-25 19:34:54 +07:00
afkarxyz 724520f51f .refine progressbar audio quality analyzer 2026-03-25 19:25:26 +07:00
afkarxyz c342c3f9ee .remake audio quality analyzer 2026-03-25 18:52:27 +07:00
afkarxyz 8919b9a77a .unicode issue converter 2026-03-25 18:04:22 +07:00
afkarxyz 528bf65771 .refine audio quality analyzer 2026-03-25 17:39:43 +07:00
afkarxyz 0e6b6f9d39 .audio resampler 2026-03-25 17:24:38 +07:00
afkarxyz 45885e1856 .rename 2026-03-25 16:24:24 +07:00
afkarxyz b31e1fe565 .fix spotify rate limit issue 2026-03-25 16:17:44 +07:00
afkarxyz 4e7fc468cd .history page refined 2026-03-25 15:13:36 +07:00
afkarxyz d8722c58dc .refine artist fetch all tracks 2026-03-25 13:43:14 +07:00
afkarxyz dd67b54ea9 .refine issue 2026-03-25 12:06:54 +07:00
afkarxyz cbca6c799f .improve about page 2026-03-25 12:02:14 +07:00
30 changed files with 1785 additions and 834 deletions
+60 -52
View File
@@ -81,13 +81,13 @@ jobs:
- name: Prepare artifacts
run: |
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
uses: actions/upload-artifact@v4
with:
name: windows-bundle
path: dist/spotiflac-windows.zip
name: windows-portable
path: dist/SpotiFLAC.exe
retention-days: 7
build-macos:
@@ -147,33 +147,56 @@ jobs:
- name: Build application
run: wails build -platform darwin/universal
- name: Create macOS bundle
- name: Create DMG
run: |
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
uses: actions/upload-artifact@v4
with:
name: macos-bundle
path: dist/spotiflac-macos-bundle.zip
name: macos-portable
path: dist/SpotiFLAC.dmg
retention-days: 7
build-linux:
name: Build Linux (${{ matrix.arch }})
name: Build Linux (${{ matrix.display_name }})
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
goarch: amd64
- display_name: amd64
runner: ubuntu-24.04
wails_platform: linux/amd64
artifact_name: linux-portable
output_name: SpotiFLAC.AppImage
appimage_arch: x86_64
- arch: arm64
goarch: arm64
appimagetool_arch: x86_64
pkgconfig_dir: /usr/lib/x86_64-linux-gnu/pkgconfig
- display_name: arm64
runner: ubuntu-24.04-arm
wails_platform: linux/arm64
artifact_name: linux-portable-arm
output_name: SpotiFLAC-ARM.AppImage
appimage_arch: aarch64
appimagetool_arch: aarch64
pkgconfig_dir: /usr/lib/aarch64-linux-gnu/pkgconfig
steps:
- name: Checkout code
uses: actions/checkout@v4
@@ -219,15 +242,10 @@ jobs:
- name: Install dependencies
run: |
sudo apt-get update
PACKAGES="libgtk-3-dev libwebkit2gtk-4.1-dev libfuse2 imagemagick"
if [ "${{ matrix.goarch }}" = "amd64" ]; then
PACKAGES="$PACKAGES upx-ucl"
fi
sudo apt-get install -y $PACKAGES
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libfuse2 imagemagick upx-ucl
# Create symlink for webkit2gtk-4.0 -> webkit2gtk-4.1 (Ubuntu 24.04 compatibility)
MULTIARCH="$(dpkg-architecture -qDEB_HOST_MULTIARCH)"
sudo ln -sf "/usr/lib/${MULTIARCH}/pkgconfig/webkit2gtk-4.1.pc" "/usr/lib/${MULTIARCH}/pkgconfig/webkit2gtk-4.0.pc"
sudo ln -sf "${{ matrix.pkgconfig_dir }}/webkit2gtk-4.1.pc" "${{ matrix.pkgconfig_dir }}/webkit2gtk-4.0.pc"
- name: Install Wails CLI
run: go install github.com/wailsapp/wails/v2/cmd/wails@latest
@@ -239,10 +257,9 @@ jobs:
pnpm run generate-icon
- name: Build application
run: wails build -platform linux/${{ matrix.goarch }}
run: wails build -platform ${{ matrix.wails_platform }}
- name: Compress with UPX
if: matrix.goarch == 'amd64'
run: |
upx --best --lzma build/bin/SpotiFLAC
@@ -251,13 +268,13 @@ jobs:
uses: actions/cache@v4
with:
path: appimagetool
key: appimagetool-${{ matrix.appimage_arch }}-v1
key: appimagetool-${{ matrix.appimagetool_arch }}-v2
- name: Download appimagetool
if: steps.cache-appimagetool.outputs.cache-hit != 'true'
run: |
wget --timeout=30 --tries=5 --retry-connrefused --waitretry=5 -O appimagetool https://github.com/AppImage/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/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/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.appimagetool_arch }}.AppImage"
- name: Make appimagetool executable
run: chmod +x appimagetool
@@ -312,18 +329,13 @@ jobs:
# Create AppImage
mkdir -p dist
if [ "${{ matrix.goarch }}" = "arm64" ]; then
RELEASE_ARCH="arm64v8"
else
RELEASE_ARCH="amd64"
fi
ARCH=${{ matrix.appimage_arch }} ./appimagetool --no-appstream AppDir "dist/SpotiFLAC-${RELEASE_ARCH}.AppImage"
ARCH=${{ matrix.appimage_arch }} ./appimagetool --no-appstream AppDir "dist/${{ matrix.output_name }}"
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: linux-appimage-${{ matrix.arch }}
path: dist/*.AppImage
name: ${{ matrix.artifact_name }}
path: dist/${{ matrix.output_name }}
retention-days: 7
create-release:
@@ -351,13 +363,6 @@ jobs:
- name: Display structure of downloaded files
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
uses: softprops/action-gh-release@v2
with:
@@ -369,20 +374,16 @@ jobs:
## Downloads
- `spotiflac-windows.zip` - amd64
- `spotiflac-macos-bundle.zip` - amd64 + arm64
- `spotiflac-linux-bundle.tar.gz` - amd64 + arm64v8
- `SpotiFLAC.exe` - Windows
- `SpotiFLAC.dmg` - macOS
- `SpotiFLAC.AppImage` - Linux AMD64
- `SpotiFLAC-ARM.AppImage` - Linux ARM64
<details>
<summary><b>Linux Requirements</b></summary>
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:**
```bash
sudo apt install libwebkit2gtk-4.1-0
@@ -400,14 +401,21 @@ jobs:
After installing the dependency, make the AppImage executable:
```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>
files: |
artifacts/windows-bundle/*.zip
artifacts/macos-bundle/*.zip
release/spotiflac-linux-bundle.tar.gz
artifacts/windows-portable/SpotiFLAC.exe
artifacts/macos-portable/SpotiFLAC.dmg
artifacts/linux-portable/SpotiFLAC.AppImage
artifacts/linux-portable-arm/SpotiFLAC-ARM.AppImage
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-4
View File
@@ -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.
### [SpotiDownloader](https://github.com/spotbye/SpotiDownloader)
Get Spotify tracks, albums, playlists and discography in MP3 and FLAC.
### [SpotubeDL.com](https://spotubedl.com)
Download Spotify Tracks, Albums, Playlists & Discography as MP3/OGG/Opus.
+173 -126
View File
@@ -34,14 +34,6 @@ type CurrentIPInfo struct {
}
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 {
return &App{}
@@ -152,60 +144,6 @@ func previewResponseBody(body []byte, maxLen int) string {
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) {
type ipwhoisResponse struct {
Success bool `json:"success"`
@@ -313,10 +251,6 @@ func (a *App) GetCurrentIPInfo() (string, error) {
return string(payload), nil
}
func (a *App) FetchUnifiedAPIStatus(forceRefresh bool) (string, error) {
return fetchUnifiedStatusPayload(forceRefresh, unifiedStatusAPIURL)
}
func (a *App) getFirstArtist(artistString string) string {
if artistString == "" {
return ""
@@ -342,6 +276,11 @@ func (a *App) startup(ctx context.Context) {
if err := backend.InitProviderPriorityDB(); err != nil {
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) {
@@ -368,6 +307,7 @@ type DownloadRequest struct {
ReleaseDate string `json:"release_date,omitempty"`
CoverURL string `json:"cover_url,omitempty"`
TidalAPIURL string `json:"tidal_api_url,omitempty"`
TidalVariant string `json:"tidal_variant,omitempty"`
OutputDir string `json:"output_dir,omitempty"`
AudioFormat string `json:"audio_format,omitempty"`
FilenameFormat string `json:"filename_format,omitempty"`
@@ -722,7 +662,11 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
}
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("")
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)
@@ -850,6 +794,11 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
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) {
time.Sleep(2 * time.Second)
@@ -895,7 +844,7 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
}
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{
@@ -1042,70 +991,28 @@ func (a *App) ExportFailedDownloads() (string, error) {
func (a *App) CheckAPIStatus(apiType string, apiURL string) bool {
isOnline, err := runWithTimeout(checkOperationTimeout, func() (bool, error) {
var checkURL string
if apiType == "tidal" {
checkURL = fmt.Sprintf("%s/track/?id=441821360&quality=HI_RES_LOSSLESS", 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"`) {
switch apiType {
case "tidal":
if checkGroupedAPIStatus("tidal", buildTidalStatusCheckURLs(apiURL)) {
return true, nil
}
if (apiType == "qobuz" || apiType == "qbz") && statusCode == 200 && containsStreamingURL(body) {
if strings.TrimSpace(apiURL) == "" {
if _, refreshErr := backend.RefreshTidalAPIList(true); refreshErr == nil && checkGroupedAPIStatus("tidal", buildTidalStatusCheckURLs("")) {
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
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 apiType == "musicbrainz" {
@@ -1122,6 +1029,146 @@ func (a *App) CheckAPIStatus(apiType string, apiURL string) bool {
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() {
panic("quit")
+17 -5
View File
@@ -56,12 +56,11 @@ func (a *AmazonDownloader) DownloadFromAfkarXYZ(amazonURL, outputDir, quality st
return "", fmt.Errorf("failed to extract ASIN from URL: %s", amazonURL)
}
apiURL := fmt.Sprintf("https://amzn.afkarxyz.qzz.io/api/track/%s", asin)
req, err := http.NewRequest("GET", apiURL, nil)
apiURL := fmt.Sprintf("%s/api/track/%s", amazonMusicAPIBaseURL, asin)
req, err := NewRequestWithDefaultHeaders(http.MethodGet, apiURL, nil)
if err != nil {
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)
resp, err := a.client.Do(req)
@@ -98,8 +97,10 @@ func (a *AmazonDownloader) DownloadFromAfkarXYZ(amazonURL, outputDir, quality st
}
defer out.Close()
dlReq, _ := http.NewRequest("GET", 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")
dlReq, err := NewRequestWithDefaultHeaders(http.MethodGet, downloadURL, nil)
if err != nil {
return "", err
}
dlResp, err := a.client.Do(dlReq)
if err != nil {
@@ -287,6 +288,16 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename
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)
originalFileBase := strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
@@ -406,6 +417,7 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename
Separator: metadataSeparator,
Description: "https://github.com/spotbye/SpotiFLAC",
ISRC: isrc,
UPC: upc,
Genre: mbMeta.Genre,
}
+20
View File
@@ -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
}
+1 -1
View File
@@ -180,7 +180,7 @@ func queryMusicBrainzRecordings(client *http.Client, query string) (*MusicBrainz
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")
var lastErr error
+17
View File
@@ -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
View File
@@ -139,7 +139,7 @@ func (q *QobuzDownloader) searchByISRC(isrc string) (*QobuzTrack, error) {
}
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)
@@ -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) {
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 {
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)
standardAPIs := prioritizeProviders("qobuz", []string{
"https://dab.yeet.su/api/stream?trackId=",
"https://dabmusic.xyz/api/stream?trackId=",
"https://qbz.afkarxyz.qzz.io/api/track/",
})
standardAPIs := prioritizeProviders("qobuz", GetQobuzStreamAPIBaseURLs())
downloadFunc := func(qual string) (string, error) {
type Provider struct {
@@ -272,7 +273,12 @@ func (q *QobuzDownloader) DownloadFile(url, filepath string) error {
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 {
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")
}
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 {
return fmt.Errorf("failed to download cover: %w", err)
}
+1 -1
View File
@@ -21,7 +21,7 @@ const (
qobuzAPIBaseURL = "https://www.qobuz.com/api.json/0.2"
qobuzDefaultAPIAppID = "712109809"
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"
qobuzCredentialsCacheTTL = 24 * time.Hour
qobuzCredentialsProbeTrackISRC = "USUM71703861"
+103 -343
View File
@@ -9,7 +9,6 @@ import (
"net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
@@ -49,17 +48,9 @@ type TidalBTSManifest struct {
}
func NewTidalDownloader(apiURL string) *TidalDownloader {
apiURL = strings.TrimRight(strings.TrimSpace(apiURL), "/")
if apiURL == "" {
downloader := &TidalDownloader{
client: &http.Client{
Timeout: 5 * time.Second,
},
timeout: 5 * time.Second,
maxRetries: 3,
apiURL: "",
}
apis, err := downloader.GetAvailableAPIs()
apis, err := GetRotatedTidalAPIList()
if err == nil && len(apis) > 0 {
apiURL = apis[0]
}
@@ -76,16 +67,12 @@ func NewTidalDownloader(apiURL string) *TidalDownloader {
}
func (t *TidalDownloader) GetAvailableAPIs() ([]string, error) {
apis := []string{
"https://hifi-one.spotisaver.net",
"https://hifi-two.spotisaver.net",
"https://eu-central.monochrome.tf",
"https://us-west.monochrome.tf",
"https://api.monochrome.tf",
"https://monochrome-api.samidy.com",
"https://tidal.kinoplus.online",
apis, err := GetRotatedTidalAPIList()
if err == nil && len(apis) > 0 {
return apis, nil
}
return prioritizeProviders("tidal", apis), nil
return nil, err
}
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)
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 {
fmt.Printf("✗ failed to create request: %v\n", 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)
if err != nil {
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)
}
req, err := http.NewRequest("GET", url, nil)
req, err := NewRequestWithDefaultHeaders(http.MethodGet, url, nil)
if err != nil {
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)
if err != nil {
@@ -241,11 +224,10 @@ func (t *TidalDownloader) DownloadFromManifest(manifestB64, outputPath string) e
}
doRequest := func(url string) (*http.Response, error) {
req, err := http.NewRequest("GET", url, nil)
req, err := NewRequestWithDefaultHeaders(http.MethodGet, url, nil)
if err != nil {
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)
}
@@ -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) {
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)
trackID, err := t.GetTrackIDFromURL(tidalURL)
@@ -434,25 +410,10 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo
return "", fmt.Errorf("no track ID found")
}
artistName := spotifyArtistName
trackTitle := spotifyTrackName
albumTitle := spotifyAlbumName
artistNameForFile := sanitizeFilename(artistName)
albumArtistForFile := sanitizeFilename(spotifyAlbumArtist)
if useFirstArtistOnly {
artistNameForFile = sanitizeFilename(GetFirstArtist(artistName))
albumArtistForFile = sanitizeFilename(GetFirstArtist(spotifyAlbumArtist))
outputFilename, alreadyExists, err := buildTidalOutputPath(outputDir, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyTrackNumber, spotifyDiscNumber, isrcOverride, useFirstArtistOnly)
if err != nil {
return "", err
}
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 {
fmt.Printf("File already exists: %s (%.2f MB)\n", outputFilename, float64(mustFileSize(outputFilename))/(1024*1024))
return "EXISTS:" + outputFilename, nil
@@ -460,119 +421,29 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo
downloadURL, err := t.GetDownloadURL(trackID, quality)
if err != nil {
if quality == "HI_RES" && allowFallback {
if isTidalHiResQuality(quality) && allowFallback {
fmt.Println("⚠ HI_RES unavailable/failed, falling back to LOSSLESS...")
downloadURL, err = t.GetDownloadURL(trackID, "LOSSLESS")
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 {
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)
if err := t.DownloadFile(downloadURL, outputFilename); err != nil {
return "", err
cleanupTidalDownloadArtifacts(outputFilename)
return outputFilename, err
}
isrc := strings.TrimSpace(isrcOverride)
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")
if t.apiURL != "" {
if err := RememberTidalAPIUsage(t.apiURL); err != nil {
fmt.Printf("Warning: failed to persist last used Tidal API: %v\n", err)
}
}
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")
}
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")
@@ -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) {
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)
trackID, err := t.GetTrackIDFromURL(tidalURL)
@@ -602,146 +462,24 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality
return "", fmt.Errorf("no track ID found")
}
artistName := spotifyArtistName
trackTitle := spotifyTrackName
albumTitle := spotifyAlbumName
artistNameForFile := sanitizeFilename(artistName)
albumArtistForFile := sanitizeFilename(spotifyAlbumArtist)
if useFirstArtistOnly {
artistNameForFile = sanitizeFilename(GetFirstArtist(artistName))
albumArtistForFile = sanitizeFilename(GetFirstArtist(spotifyAlbumArtist))
outputFilename, alreadyExists, err := buildTidalOutputPath(outputDir, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyTrackNumber, spotifyDiscNumber, isrcOverride, useFirstArtistOnly)
if err != nil {
return "", err
}
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 {
fmt.Printf("File already exists: %s (%.2f MB)\n", outputFilename, float64(mustFileSize(outputFilename))/(1024*1024))
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)
downloader := NewTidalDownloader(successAPI)
if err := downloader.DownloadFile(downloadURL, outputFilename); err != nil {
return "", err
successAPI, err := t.downloadWithRotatingAPIs(trackID, outputFilename, quality, allowFallback)
if err != nil {
cleanupTidalDownloadArtifacts(outputFilename)
return outputFilename, err
}
fmt.Printf("✓ Downloaded using API: %s\n", successAPI)
isrc := strings.TrimSpace(isrcOverride)
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")
}
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")
@@ -752,7 +490,7 @@ func (t *TidalDownloader) Download(spotifyTrackID, outputDir, quality, filenameF
tidalURL, err := t.GetTidalURLFromSpotify(spotifyTrackID)
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)
@@ -920,79 +658,101 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
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 {
return "", "", fmt.Errorf("no APIs available")
return "", fmt.Errorf("no tidal apis available")
}
orderedAPIs := prioritizeProviders("tidal", apis)
fmt.Printf("Trying %d prioritized APIs...\n", len(orderedAPIs))
var lastErr error
errors := make([]string, 0, len(apis))
var lastError error
var errors []string
for _, apiURL := range apis {
fmt.Printf("Trying Tidal API: %s\n", apiURL)
for _, apiURL := range orderedAPIs {
fmt.Printf("Trying API: %s\n", apiURL)
client := &http.Client{
Timeout: 15 * time.Second,
}
url := fmt.Sprintf("%s/track/?id=%d&quality=%s", apiURL, trackID, quality)
resp, err := client.Get(url)
downloader := NewTidalDownloader(apiURL)
downloadURL, err := downloader.GetDownloadURL(trackID, quality)
if err != nil {
lastError = err
recordProviderFailure("tidal", apiURL)
lastErr = err
errors = append(errors, fmt.Sprintf("%s: %v", apiURL, err))
continue
}
if resp.StatusCode != 200 {
resp.Body.Close()
lastError = fmt.Errorf("HTTP %d", resp.StatusCode)
recordProviderFailure("tidal", apiURL)
errors = append(errors, fmt.Sprintf("%s: %v", apiURL, lastError))
if err := downloader.DownloadFile(downloadURL, outputFilename); err != nil {
lastErr = err
cleanupTidalDownloadArtifacts(outputFilename)
errors = append(errors, fmt.Sprintf("%s: %v", apiURL, err))
continue
}
body, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
lastError = err
recordProviderFailure("tidal", apiURL)
errors = append(errors, fmt.Sprintf("%s: read body failed", apiURL))
continue
if err := RememberTidalAPIUsage(apiURL); err != nil {
fmt.Printf("Warning: failed to persist last used Tidal API: %v\n", err)
}
var v2Response TidalAPIResponseV2
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
return apiURL, nil
}
var v1Responses []TidalAPIResponse
if err := json.Unmarshal(body, &v1Responses); err == nil {
for _, item := range v1Responses {
if item.OriginalTrackURL != "" {
fmt.Printf("✓ Success with: %s\n", apiURL)
recordProviderSuccess("tidal", apiURL)
return apiURL, item.OriginalTrackURL, nil
}
if !refreshed {
if _, refreshErr := RefreshTidalAPIList(true); refreshErr != nil {
errors = append(errors, fmt.Sprintf("gist refresh failed: %v", refreshErr))
} else {
fmt.Println("All cached Tidal APIs failed, refreshed gist list and retrying...")
return t.tryDownloadAcrossTidalAPIs(trackID, outputFilename, quality, true)
}
}
lastError = fmt.Errorf("no download URL or manifest in response")
recordProviderFailure("tidal", apiURL)
errors = append(errors, fmt.Sprintf("%s: %v", apiURL, lastError))
if lastErr == nil {
lastErr = fmt.Errorf("all tidal apis failed")
}
fmt.Println("All APIs failed:")
for _, e := range errors {
fmt.Printf(" ✗ %s\n", e)
fmt.Println("All Tidal APIs failed:")
for _, item := range errors {
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 {
+238
View File
@@ -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
}
+296
View File
@@ -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
View File
@@ -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 { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
import { Search, X, ArrowUp } from "lucide-react";
@@ -32,7 +32,7 @@ import { useMetadata } from "@/hooks/useMetadata";
import { useLyrics } from "@/hooks/useLyrics";
import { useCover } from "@/hooks/useCover";
import { useAvailability } from "@/hooks/useAvailability";
import { ensureApiStatusCheckStarted } from "@/lib/api-status";
import { ensureSpotiFLACNextStatusCheckStarted } from "@/lib/api-status";
import { useDownloadQueueDialog } from "@/hooks/useDownloadQueueDialog";
import { useDownloadProgress } from "@/hooks/useDownloadProgress";
import { buildPlaylistFolderName } from "@/lib/playlist";
@@ -125,6 +125,7 @@ function parseStoredHistory(value: string | null): HistoryItem[] {
}
function App() {
const [currentPage, setCurrentPage] = useState<PageType>("main");
const contentScrollRef = useRef<HTMLDivElement | null>(null);
const [spotifyUrl, setSpotifyUrl] = useState("");
const [selectedTracks, setSelectedTracks] = useState<string[]>([]);
const [searchQuery, setSearchQuery] = useState("");
@@ -197,20 +198,33 @@ function App() {
};
mediaQuery.addEventListener("change", handleChange);
checkForUpdates();
ensureApiStatusCheckStarted();
ensureSpotiFLACNextStatusCheckStarted();
void loadHistory();
const handleScroll = () => {
setShowScrollTop(window.scrollY > 300);
};
window.addEventListener("scroll", handleScroll);
return () => {
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(() => {
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(() => {
setSelectedTracks([]);
setSearchQuery("");
@@ -584,16 +598,18 @@ function App() {
}
};
return (<TooltipProvider>
<div className="min-h-screen bg-background flex flex-col">
<div className="h-screen overflow-hidden bg-background">
<TitleBar />
<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">
{renderPage()}
</div>
</div>
</div>
<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

+33 -85
View File
@@ -8,7 +8,6 @@ import ChatGPTTTSIcon from "@/assets/chatgpt-tts.webp";
import XIcon from "@/assets/x.webp";
import XProIcon from "@/assets/x-pro.webp";
import SpotubeDLIcon from "@/assets/icons/spotubedl.svg";
import SpotiDownloaderIcon from "@/assets/icons/spotidownloader.svg";
import XBatchDLIcon from "@/assets/icons/xbatchdl.svg";
import SpotiFLACNextIcon from "@/assets/icons/next.svg";
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: 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() {
const [activeTab, setActiveTab] = useState<"projects" | "support">("projects");
const [repoStats, setRepoStats] = useState<Record<string, any>>({});
@@ -44,8 +48,7 @@ export function AboutPage() {
}
}
const repos = [
{ name: "SpotiDownloader", owner: "afkarxyz" },
{ name: "SpotiFLAC-Next", owner: "spotiverse" },
{ name: "SpotiFLAC-Next", owner: "spotbye" },
{ name: "Twitter-X-Media-Batch-Downloader", owner: "afkarxyz" },
];
const stats: Record<string, any> = {};
@@ -176,7 +179,7 @@ export function AboutPage() {
const getRepoDescription = (repoName: string): string => {
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">
<h2 className="text-2xl font-bold tracking-tight">About</h2>
</div>
@@ -195,17 +198,17 @@ export function AboutPage() {
<div className="flex-1 min-h-0">
{activeTab === "projects" && (<div className="p-1 pr-2">
<div className="grid gap-2 grid-cols-4">
<Card className={`gap-2 ${projectCardClass}`} onClick={() => openExternal("https://github.com/spotbye/SpotiFLAC-Next")}>
<CardHeader>
{activeTab === "projects" && (<div className="pr-1.5">
<div className="grid gap-2 grid-cols-3">
<Card className={projectCardClass} onClick={() => openExternal("https://github.com/spotbye/SpotiFLAC-Next")}>
<CardHeader className={projectCardHeaderClass}>
<div className="flex justify-between items-start mb-2">
<img src={SpotiFLACNextIcon} className="h-6 w-6 shrink-0" alt="SpotiFLAC Next"/>
<div className="flex items-center gap-2">
{repoStats["SpotiFLAC-Next"]?.latestReleaseAt && (<span className="text-[10px] text-muted-foreground whitespace-nowrap">
<div className="ml-3 flex flex-wrap items-center justify-end gap-2">
{repoStats["SpotiFLAC-Next"]?.latestReleaseAt && (<span className={releaseMetaClass}>
{formatReleaseTimeAgo(repoStats["SpotiFLAC-Next"].latestReleaseAt)}
</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}
</span>)}
</div>
@@ -213,11 +216,11 @@ export function AboutPage() {
<CardTitle className="leading-tight">
SpotiFLAC Next
</CardTitle>
<CardDescription>
<CardDescription className={projectBodyClass}>
{getRepoDescription("SpotiFLAC-Next")}
</CardDescription>
</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.map((lang: string) => (<span key={lang} className="px-2 py-0.5 rounded-full font-medium" style={{
backgroundColor: getLangColor(lang) + "20",
@@ -245,76 +248,21 @@ export function AboutPage() {
<Info className="h-3.5 w-3.5"/>
Note
</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.
</p>
</div>
</CardContent>)}
</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")}>
<CardHeader>
<CardHeader className={projectCardHeaderClass}>
<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"/>
<div className="flex items-center gap-2">
{repoStats["Twitter-X-Media-Batch-Downloader"]?.latestReleaseAt && (<span className="text-[10px] text-muted-foreground whitespace-nowrap">
<div className="ml-3 flex flex-wrap items-center justify-end gap-2">
{repoStats["Twitter-X-Media-Batch-Downloader"]?.latestReleaseAt && (<span className={releaseMetaClass}>
{formatReleaseTimeAgo(repoStats["Twitter-X-Media-Batch-Downloader"].latestReleaseAt)}
</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}
</span>)}
</div>
@@ -322,11 +270,11 @@ export function AboutPage() {
<CardTitle className="leading-tight">
Twitter/X Media Batch Downloader
</CardTitle>
<CardDescription>
<CardDescription className={projectBodyClass}>
{getRepoDescription("Twitter-X-Media-Batch-Downloader")}
</CardDescription>
</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">
{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",
@@ -364,14 +312,14 @@ export function AboutPage() {
</div>
</CardContent>)}
</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/")}>
<CardHeader>
<CardTitle>Browser Extensions & Scripts</CardTitle>
<CardDescription className="flex flex-col gap-2 pt-2">
{browserExtensionItems.map((item) => (<div key={item.alt} className="flex items-center gap-2">
<CardHeader className={projectCardHeaderClass}>
<CardTitle className="leading-tight">Browser Extensions & Scripts</CardTitle>
<CardDescription className="flex flex-col gap-2.5 pt-1.5">
{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}/>
<span className="text-[11px] leading-tight text-muted-foreground">
<span className={`${projectBodyClass} text-muted-foreground`}>
{item.label}
</span>
</div>))}
@@ -379,12 +327,12 @@ export function AboutPage() {
</CardHeader>
</Card>
<Card className={`${projectCardClass} flex-1`} onClick={() => openExternal("https://spotubedl.com/")}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<CardHeader className={projectCardHeaderClass}>
<CardTitle className="flex items-center gap-2 leading-tight">
<img src={SpotubeDLIcon} className="h-5 w-5" alt="SpotubeDL"/>{" "}
SpotubeDL.com
</CardTitle>
<CardDescription>
<CardDescription className={projectBodyClass}>
Download Spotify Tracks, Albums, Playlists & Discography as MP3/OGG/Opus.
</CardDescription>
</CardHeader>
+63 -18
View File
@@ -1,34 +1,79 @@
import { Button } from "@/components/ui/button";
import { RefreshCw, CheckCircle2, XCircle, Loader2 } from "lucide-react";
import { TidalIcon, QobuzIcon, AmazonIcon, LrclibIcon, MusicBrainzIcon } from "./PlatformIcons";
import { SearchCheck, CheckCircle2, XCircle, Loader2 } from "lucide-react";
import { TidalIcon, QobuzIcon, AmazonIcon, MusicBrainzIcon, AppleMusicIcon, DeezerIcon } from "./PlatformIcons";
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() {
const { sources, statuses, isCheckingAll, refreshAll } = useApiStatus();
const { sources, statuses, nextStatuses, checkingSources, checkOne } = useApiStatus();
return (<div className="space-y-6">
<div className="flex items-center justify-end">
<Button variant="outline" onClick={() => void refreshAll()} disabled={isCheckingAll} className="gap-2">
<RefreshCw className={`h-4 w-4 ${isCheckingAll ? "animate-spin" : ""}`}/>
Refresh All
</Button>
</div>
<div className="space-y-4">
<h3 className="text-sm font-semibold tracking-tight">SpotiFLAC Services</h3>
<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) => {
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">
{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>
</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 className="flex items-center">{renderStatusIcon(status)}</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 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>);
}
@@ -1,4 +1,6 @@
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 musicBrainzDarkIcon from "../assets/icons/musicbrainz_d.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) {
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) {
return <PlatformIcon src={lrclibIcon} alt="LRCLIB" className={className}/>;
}
+21 -2
View File
@@ -102,6 +102,9 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
const handleTidalQualityChange = async (value: "LOSSLESS" | "HI_RES_LOSSLESS") => {
setTempSettings((prev) => ({ ...prev, tidalQuality: value }));
};
const handleTidalVariantChange = (value: "tidal" | "alt") => {
setTempSettings((prev) => ({ ...prev, tidalVariant: value }));
};
const handleQobuzQualityChange = (value: "6" | "7" | "27") => {
setTempSettings((prev) => ({ ...prev, qobuzQuality: value }));
};
@@ -424,7 +427,9 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
</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">
<SelectValue />
</SelectTrigger>
@@ -434,7 +439,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
24-bit/48kHz
</SelectItem>
</SelectContent>
</Select>)}
</Select>))}
{tempSettings.downloader === "qobuz" && (<Select value={tempSettings.qobuzQuality} onValueChange={handleQobuzQualityChange}>
<SelectTrigger className="h-9 w-fit">
@@ -452,7 +457,21 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
</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.tidalVariant !== "alt" &&
tempSettings.tidalQuality === "HI_RES_LOSSLESS") ||
(tempSettings.downloader === "qobuz" &&
tempSettings.qobuzQuality === "27") ||
+2 -3
View File
@@ -1,9 +1,8 @@
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() {
const [state, setState] = useState(getApiStatusState);
useEffect(() => {
ensureApiStatusCheckStarted();
return subscribeApiStatus(() => {
setState(getApiStatusState());
});
@@ -11,6 +10,6 @@ export function useApiStatus() {
return {
...state,
sources: API_SOURCES,
refreshAll: () => checkAllApiStatuses(true),
checkOne: (sourceId: string) => checkApiStatus(sourceId),
};
}
+51 -24
View File
@@ -52,6 +52,24 @@ async function resolveTemplateISRC(settings: {
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) {
const [downloadProgress, setDownloadProgress] = useState<number>(0);
const [isDownloading, setIsDownloading] = useState(false);
@@ -170,8 +188,11 @@ export function useDownload(region: string) {
itemID = await AddToDownloadQueue(id, trackName || "", displayArtist || "", albumName || "");
}
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;
if (spotifyId) {
if (spotifyId && shouldFetchStreamingURLs(order, settings)) {
try {
const { GetStreamingURLs } = await import("../../wailsjs/go/main/App");
const urlsJson = await GetStreamingURLs(spotifyId, region);
@@ -182,16 +203,15 @@ export function useDownload(region: string) {
}
}
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" };
const fallbackErrors: string[] = [];
const tidalQuality = getTidalAudioFormat(settings, "auto");
const is24Bit = (settings.autoQuality || "24") === "24";
const tidalQuality = is24Bit ? "HI_RES_LOSSLESS" : "LOSSLESS";
const qobuzQuality = is24Bit ? "27" : "6";
for (const s of order) {
if (s === "tidal" && streamingURLs?.tidal_url) {
if (s === "tidal" && ((tidalVariant === "alt" && spotifyId) || streamingURLs?.tidal_url)) {
try {
logger.debug(`trying tidal for: ${trackName} - ${artistName}`);
logger.debug(`trying ${tidalLabel} for: ${trackName} - ${artistName}`);
const response = await downloadTrack({
service: "tidal",
query,
@@ -209,7 +229,8 @@ export function useDownload(region: string) {
spotify_id: spotifyId,
embed_lyrics: settings.embedLyrics,
embed_max_quality_cover: settings.embedMaxQualityCover,
service_url: streamingURLs.tidal_url,
service_url: tidalVariant === "alt" ? undefined : streamingURLs?.tidal_url,
tidal_variant: tidalVariant,
duration: durationSeconds,
item_id: itemID,
audio_format: tidalQuality,
@@ -225,17 +246,17 @@ export function useDownload(region: string) {
embed_genre: settings.embedGenre,
});
if (response.success) {
logger.success(`tidal: ${trackName} - ${artistName}`);
logger.success(`${tidalLabel}: ${trackName} - ${artistName}`);
return response;
}
const errMsg = response.error || response.message || "Failed";
fallbackErrors.push(`[Tidal] ${errMsg}`);
fallbackErrors.push(`[${tidalLabel}] ${errMsg}`);
lastResponse = response;
logger.warning(`tidal failed, trying next...`);
logger.warning(`${tidalLabel} failed, trying next...`);
}
catch (err) {
logger.error(`tidal error: ${err}`);
fallbackErrors.push(`[Tidal] ${String(err)}`);
logger.error(`${tidalLabel} error: ${err}`);
fallbackErrors.push(`[${tidalLabel}] ${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;
let audioFormat: string | undefined;
if (service === "tidal") {
audioFormat = settings.tidalQuality || "LOSSLESS";
audioFormat = getTidalAudioFormat(settings, "single");
}
else if (service === "qobuz") {
audioFormat = settings.qobuzQuality || "6";
@@ -373,6 +394,7 @@ export function useDownload(region: string) {
duration: durationSecondsForFallback,
item_id: itemID,
audio_format: audioFormat,
tidal_variant: service === "tidal" ? getTidalVariant(settings) : undefined,
spotify_track_number: spotifyTrackNumber,
spotify_disc_number: spotifyDiscNumber,
spotify_total_tracks: spotifyTotalTracks,
@@ -380,6 +402,7 @@ export function useDownload(region: string) {
isrc: resolvedTemplateISRC || undefined,
copyright: copyright,
publisher: publisher,
use_first_artist_only: settings.useFirstArtistOnly,
use_single_genre: settings.useSingleGenre,
embed_genre: settings.embedGenre,
});
@@ -451,8 +474,11 @@ export function useDownload(region: string) {
}
}
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;
if (spotifyId) {
if (spotifyId && shouldFetchStreamingURLs(order, settings)) {
try {
const { GetStreamingURLs } = await import("../../wailsjs/go/main/App");
const urlsJson = await GetStreamingURLs(spotifyId, region);
@@ -463,16 +489,15 @@ export function useDownload(region: string) {
}
}
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" };
const fallbackErrors: string[] = [];
const tidalQuality = getTidalAudioFormat(settings, "auto");
const is24Bit = (settings.autoQuality || "24") === "24";
const tidalQuality = is24Bit ? "HI_RES_LOSSLESS" : "LOSSLESS";
const qobuzQuality = is24Bit ? "27" : "6";
for (const s of order) {
if (s === "tidal" && streamingURLs?.tidal_url) {
if (s === "tidal" && ((tidalVariant === "alt" && spotifyId) || streamingURLs?.tidal_url)) {
try {
logger.debug(`trying tidal for: ${trackName} - ${artistName}`);
logger.debug(`trying ${tidalLabel} for: ${trackName} - ${artistName}`);
const response = await downloadTrack({
service: "tidal",
query,
@@ -490,7 +515,8 @@ export function useDownload(region: string) {
spotify_id: spotifyId,
embed_lyrics: settings.embedLyrics,
embed_max_quality_cover: settings.embedMaxQualityCover,
service_url: streamingURLs.tidal_url,
service_url: tidalVariant === "alt" ? undefined : streamingURLs?.tidal_url,
tidal_variant: tidalVariant,
duration: durationSeconds,
item_id: itemID,
audio_format: tidalQuality,
@@ -506,17 +532,17 @@ export function useDownload(region: string) {
embed_genre: settings.embedGenre,
});
if (response.success) {
logger.success(`tidal: ${trackName} - ${artistName}`);
logger.success(`${tidalLabel}: ${trackName} - ${artistName}`);
return response;
}
const errMsg = response.error || response.message || "Failed";
fallbackErrors.push(`[Tidal] ${errMsg}`);
fallbackErrors.push(`[${tidalLabel}] ${errMsg}`);
lastResponse = response;
logger.warning(`tidal failed, trying next...`);
logger.warning(`${tidalLabel} failed, trying next...`);
}
catch (err) {
logger.error(`tidal error: ${err}`);
fallbackErrors.push(`[Tidal] ${String(err)}`);
logger.error(`${tidalLabel} error: ${err}`);
fallbackErrors.push(`[${tidalLabel}] ${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;
let audioFormat: string | undefined;
if (service === "tidal") {
audioFormat = settings.tidalQuality || "LOSSLESS";
audioFormat = getTidalAudioFormat(settings, "single");
}
else if (service === "qobuz") {
audioFormat = settings.qobuzQuality || "6";
@@ -653,6 +679,7 @@ export function useDownload(region: string) {
duration: durationSecondsForFallback,
item_id: itemID,
audio_format: audioFormat,
tidal_variant: service === "tidal" ? getTidalVariant(settings) : undefined,
spotify_track_number: spotifyTrackNumber,
spotify_disc_number: spotifyDiscNumber,
spotify_total_tracks: spotifyTotalTracks,
+7
View File
@@ -73,6 +73,13 @@
}
@layer base {
html,
body,
#root {
height: 100%;
overflow: hidden;
}
* {
@apply border-border outline-ring/50;
}
+171 -104
View File
@@ -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";
export type ApiCheckStatus = "checking" | "online" | "offline" | "idle";
export interface ApiSource {
@@ -7,39 +7,51 @@ export interface ApiSource {
name: string;
url: string;
}
export const API_SOURCES: ApiSource[] = [
{ id: "tidal1", type: "tidal", name: "Tidal A", url: "https://hifi-one.spotisaver.net" },
{ id: "tidal2", type: "tidal", name: "Tidal B", url: "https://hifi-two.spotisaver.net" },
{ 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" },
{ 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 = {
interface SpotiFLACNextSource {
id: string;
name: string;
}
type SpotiFLACNextStatusResponse = {
tidal?: string;
qobuz_a?: string;
qobuz_b?: string;
qobuz_c?: string;
amazon?: string;
lrclib?: string;
deezer_a?: 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() {
for (const listener of listeners) {
listener();
@@ -49,39 +61,66 @@ function setApiStatusState(updater: (current: ApiStatusState) => ApiStatusState)
apiStatusState = updater(apiStatusState);
emitApiStatusChange();
}
function statusFromUnifiedValue(value: string | undefined): 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> {
async function checkSourceStatus(source: ApiSource): Promise<ApiCheckStatus> {
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";
}
catch {
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 {
return apiStatusState;
}
@@ -91,70 +130,98 @@ export function subscribeApiStatus(listener: () => void): () => void {
listeners.delete(listener);
};
}
export function hasApiStatusResults(): boolean {
return API_SOURCES.some((source) => {
const status = apiStatusState.statuses[source.id];
function hasSpotiFLACNextResults(): boolean {
return SPOTIFLAC_NEXT_SOURCES.some((source) => {
const status = apiStatusState.nextStatuses[source.id];
return status === "online" || status === "offline";
});
}
export function ensureApiStatusCheckStarted(): void {
if (!activeCheckAll && !hasApiStatusResults()) {
void checkAllApiStatuses(false);
export async function checkSpotiFLACNextStatusesOnly(): Promise<void> {
if (activeCheckNextOnly) {
return activeCheckNextOnly;
}
}
export async function checkAllApiStatuses(forceRefresh: boolean = false): Promise<void> {
if (activeCheckAll) {
return activeCheckAll;
}
activeCheckAll = (async () => {
const checkingStatuses = Object.fromEntries(API_SOURCES.map((source) => [source.id, "checking" as ApiCheckStatus]));
activeCheckNextOnly = (async () => {
const checkingNextStatuses = Object.fromEntries(SPOTIFLAC_NEXT_SOURCES.map((source) => [source.id, "checking" as ApiCheckStatus]));
setApiStatusState((current) => ({
...current,
isCheckingAll: true,
statuses: {
...current.statuses,
...checkingStatuses,
nextStatuses: {
...current.nextStatuses,
...checkingNextStatuses,
},
}));
try {
const [unifiedResult, musicBrainzStatus] = await Promise.allSettled([
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 {
setApiStatusState((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 {
setApiStatusState((current) => ({
...current,
isCheckingAll: false,
checkingSources: {
...current.checkingSources,
[sourceId]: false,
},
}));
activeCheckAll = null;
activeSourceChecks.delete(sourceId);
}
})();
return activeCheckAll;
activeSourceChecks.set(sourceId, task);
return task;
}
+3
View File
@@ -13,6 +13,9 @@ export async function fetchSpotifyMetadata(url: string, batch: boolean = true, d
}
export async function downloadTrack(request: DownloadRequest): Promise<DownloadResponse> {
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) {
(req as any).use_single_genre = request.use_single_genre;
}
+8
View File
@@ -22,6 +22,7 @@ export interface Settings {
embedLyrics: boolean;
embedMaxQualityCover: boolean;
operatingSystem: "Windows" | "linux/MacOS";
tidalVariant: "tidal" | "alt";
tidalQuality: "LOSSLESS" | "HI_RES_LOSSLESS";
qobuzQuality: "6" | "7" | "27";
amazonQuality: "original";
@@ -110,6 +111,7 @@ export const DEFAULT_SETTINGS: Settings = {
embedLyrics: false,
embedMaxQualityCover: false,
operatingSystem: detectOS(),
tidalVariant: "tidal",
tidalQuality: "LOSSLESS",
qobuzQuality: "6",
amazonQuality: "original",
@@ -215,6 +217,9 @@ function getSettingsFromLocalStorage(): Settings {
if (!('tidalQuality' in parsed)) {
parsed.tidalQuality = "LOSSLESS";
}
if (!('tidalVariant' in parsed)) {
parsed.tidalVariant = "tidal";
}
if (!('qobuzQuality' in parsed)) {
parsed.qobuzQuality = "6";
}
@@ -306,6 +311,9 @@ export async function loadSettings(): Promise<Settings> {
if (!('tidalQuality' in parsed)) {
parsed.tidalQuality = "LOSSLESS";
}
if (!('tidalVariant' in parsed)) {
parsed.tidalVariant = "tidal";
}
if (!('qobuzQuality' in parsed)) {
parsed.qobuzQuality = "6";
}
+1
View File
@@ -120,6 +120,7 @@ export interface DownloadRequest {
release_date?: string;
cover_url?: string;
tidal_api_url?: string;
tidal_variant?: "tidal" | "alt";
output_dir?: string;
audio_format?: string;
folder_name?: string;
+142
View File
@@ -0,0 +1,142 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
import {backend} from '../models';
import {main} from '../models';
export function AddFetchHistory(arg1:backend.FetchHistoryItem):Promise<void>;
export function AddToDownloadQueue(arg1:string,arg2:string,arg3:string,arg4:string):Promise<string>;
export function CancelAllQueuedItems():Promise<void>;
export function CheckAPIStatus(arg1:string,arg2:string):Promise<boolean>;
export function CheckFFmpegInstalled():Promise<boolean>;
export function CheckFilesExistence(arg1:string,arg2:string,arg3:Array<main.CheckFileExistenceRequest>):Promise<Array<main.CheckFileExistenceResult>>;
export function CheckTrackAvailability(arg1:string):Promise<string>;
export function ClearAllDownloads():Promise<void>;
export function ClearCompletedDownloads():Promise<void>;
export function ClearDownloadHistory():Promise<void>;
export function ClearFetchHistory():Promise<void>;
export function ClearFetchHistoryByType(arg1:string):Promise<void>;
export function ConvertAudio(arg1:main.ConvertAudioRequest):Promise<Array<backend.ConvertAudioResult>>;
export function CreateM3U8File(arg1:string,arg2:string,arg3:Array<string>):Promise<void>;
export function DecodeAudioForAnalysis(arg1:string):Promise<backend.AnalysisDecodeResponse>;
export function DeleteDownloadHistoryItem(arg1:string):Promise<void>;
export function DeleteFetchHistoryItem(arg1:string):Promise<void>;
export function DownloadAvatar(arg1:main.AvatarDownloadRequest):Promise<backend.AvatarDownloadResponse>;
export function DownloadCover(arg1:main.CoverDownloadRequest):Promise<backend.CoverDownloadResponse>;
export function DownloadFFmpeg():Promise<main.DownloadFFmpegResponse>;
export function DownloadGalleryImage(arg1:main.GalleryImageDownloadRequest):Promise<backend.GalleryImageDownloadResponse>;
export function DownloadHeader(arg1:main.HeaderDownloadRequest):Promise<backend.HeaderDownloadResponse>;
export function DownloadLyrics(arg1:main.LyricsDownloadRequest):Promise<backend.LyricsDownloadResponse>;
export function DownloadTrack(arg1:main.DownloadRequest):Promise<main.DownloadResponse>;
export function ExportFailedDownloads():Promise<string>;
export function GetBrewPath():Promise<string>;
export function GetConfigPath():Promise<string>;
export function GetCurrentIPInfo():Promise<string>;
export function GetDefaults():Promise<Record<string, string>>;
export function GetDownloadHistory():Promise<Array<backend.HistoryItem>>;
export function GetDownloadProgress():Promise<backend.ProgressInfo>;
export function GetDownloadQueue():Promise<backend.DownloadQueueInfo>;
export function GetFetchHistory():Promise<Array<backend.FetchHistoryItem>>;
export function GetFileSizes(arg1:Array<string>):Promise<Record<string, number>>;
export function GetFlacInfoBatch(arg1:Array<string>):Promise<Array<backend.FlacInfo>>;
export function GetPreviewURL(arg1:string):Promise<string>;
export function GetRecentFetches():Promise<string>;
export function GetSpotifyMetadata(arg1:main.SpotifyMetadataRequest):Promise<string>;
export function GetStreamingURLs(arg1:string,arg2:string):Promise<string>;
export function GetTrackISRC(arg1:string):Promise<string>;
export function InstallFFmpegWithBrew():Promise<main.InstallFFmpegWithBrewResponse>;
export function IsBrewFFmpegInstalled():Promise<boolean>;
export function IsFFmpegInstalled():Promise<boolean>;
export function IsFFprobeInstalled():Promise<boolean>;
export function ListAudioFilesInDir(arg1:string):Promise<Array<backend.FileInfo>>;
export function ListDirectoryFiles(arg1:string):Promise<Array<backend.FileInfo>>;
export function LoadSettings():Promise<Record<string, any>>;
export function MarkDownloadItemFailed(arg1:string,arg2:string):Promise<void>;
export function OpenConfigFolder():Promise<void>;
export function OpenFolder(arg1:string):Promise<void>;
export function PreviewRenameFiles(arg1:Array<string>,arg2:string):Promise<Array<backend.RenamePreview>>;
export function Quit():Promise<void>;
export function ReadFileAsBase64(arg1:string):Promise<string>;
export function ReadFileMetadata(arg1:string):Promise<backend.AudioMetadata>;
export function ReadImageAsBase64(arg1:string):Promise<string>;
export function ReadTextFile(arg1:string):Promise<string>;
export function RenameFileTo(arg1:string,arg2:string):Promise<void>;
export function RenameFilesByMetadata(arg1:Array<string>,arg2:string):Promise<Array<backend.RenameResult>>;
export function ResampleAudio(arg1:main.ResampleAudioRequest):Promise<Array<backend.ResampleResult>>;
export function SaveRecentFetches(arg1:string):Promise<void>;
export function SaveSettings(arg1:Record<string, any>):Promise<void>;
export function SaveSpectrumImage(arg1:string,arg2:string):Promise<string>;
export function SearchSpotify(arg1:main.SpotifySearchRequest):Promise<backend.SearchResponse>;
export function SearchSpotifyByType(arg1:main.SpotifySearchByTypeRequest):Promise<Array<backend.SearchResult>>;
export function SelectAudioFiles():Promise<Array<string>>;
export function SelectFile():Promise<string>;
export function SelectFolder(arg1:string):Promise<string>;
export function SelectImageVideo():Promise<Array<string>>;
export function SkipDownloadItem(arg1:string,arg2:string):Promise<void>;
+279
View File
@@ -0,0 +1,279 @@
// @ts-check
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
export function AddFetchHistory(arg1) {
return window['go']['main']['App']['AddFetchHistory'](arg1);
}
export function AddToDownloadQueue(arg1, arg2, arg3, arg4) {
return window['go']['main']['App']['AddToDownloadQueue'](arg1, arg2, arg3, arg4);
}
export function CancelAllQueuedItems() {
return window['go']['main']['App']['CancelAllQueuedItems']();
}
export function CheckAPIStatus(arg1, arg2) {
return window['go']['main']['App']['CheckAPIStatus'](arg1, arg2);
}
export function CheckFFmpegInstalled() {
return window['go']['main']['App']['CheckFFmpegInstalled']();
}
export function CheckFilesExistence(arg1, arg2, arg3) {
return window['go']['main']['App']['CheckFilesExistence'](arg1, arg2, arg3);
}
export function CheckTrackAvailability(arg1) {
return window['go']['main']['App']['CheckTrackAvailability'](arg1);
}
export function ClearAllDownloads() {
return window['go']['main']['App']['ClearAllDownloads']();
}
export function ClearCompletedDownloads() {
return window['go']['main']['App']['ClearCompletedDownloads']();
}
export function ClearDownloadHistory() {
return window['go']['main']['App']['ClearDownloadHistory']();
}
export function ClearFetchHistory() {
return window['go']['main']['App']['ClearFetchHistory']();
}
export function ClearFetchHistoryByType(arg1) {
return window['go']['main']['App']['ClearFetchHistoryByType'](arg1);
}
export function ConvertAudio(arg1) {
return window['go']['main']['App']['ConvertAudio'](arg1);
}
export function CreateM3U8File(arg1, arg2, arg3) {
return window['go']['main']['App']['CreateM3U8File'](arg1, arg2, arg3);
}
export function DecodeAudioForAnalysis(arg1) {
return window['go']['main']['App']['DecodeAudioForAnalysis'](arg1);
}
export function DeleteDownloadHistoryItem(arg1) {
return window['go']['main']['App']['DeleteDownloadHistoryItem'](arg1);
}
export function DeleteFetchHistoryItem(arg1) {
return window['go']['main']['App']['DeleteFetchHistoryItem'](arg1);
}
export function DownloadAvatar(arg1) {
return window['go']['main']['App']['DownloadAvatar'](arg1);
}
export function DownloadCover(arg1) {
return window['go']['main']['App']['DownloadCover'](arg1);
}
export function DownloadFFmpeg() {
return window['go']['main']['App']['DownloadFFmpeg']();
}
export function DownloadGalleryImage(arg1) {
return window['go']['main']['App']['DownloadGalleryImage'](arg1);
}
export function DownloadHeader(arg1) {
return window['go']['main']['App']['DownloadHeader'](arg1);
}
export function DownloadLyrics(arg1) {
return window['go']['main']['App']['DownloadLyrics'](arg1);
}
export function DownloadTrack(arg1) {
return window['go']['main']['App']['DownloadTrack'](arg1);
}
export function ExportFailedDownloads() {
return window['go']['main']['App']['ExportFailedDownloads']();
}
export function GetBrewPath() {
return window['go']['main']['App']['GetBrewPath']();
}
export function GetConfigPath() {
return window['go']['main']['App']['GetConfigPath']();
}
export function GetCurrentIPInfo() {
return window['go']['main']['App']['GetCurrentIPInfo']();
}
export function GetDefaults() {
return window['go']['main']['App']['GetDefaults']();
}
export function GetDownloadHistory() {
return window['go']['main']['App']['GetDownloadHistory']();
}
export function GetDownloadProgress() {
return window['go']['main']['App']['GetDownloadProgress']();
}
export function GetDownloadQueue() {
return window['go']['main']['App']['GetDownloadQueue']();
}
export function GetFetchHistory() {
return window['go']['main']['App']['GetFetchHistory']();
}
export function GetFileSizes(arg1) {
return window['go']['main']['App']['GetFileSizes'](arg1);
}
export function GetFlacInfoBatch(arg1) {
return window['go']['main']['App']['GetFlacInfoBatch'](arg1);
}
export function GetPreviewURL(arg1) {
return window['go']['main']['App']['GetPreviewURL'](arg1);
}
export function GetRecentFetches() {
return window['go']['main']['App']['GetRecentFetches']();
}
export function GetSpotifyMetadata(arg1) {
return window['go']['main']['App']['GetSpotifyMetadata'](arg1);
}
export function GetStreamingURLs(arg1, arg2) {
return window['go']['main']['App']['GetStreamingURLs'](arg1, arg2);
}
export function GetTrackISRC(arg1) {
return window['go']['main']['App']['GetTrackISRC'](arg1);
}
export function InstallFFmpegWithBrew() {
return window['go']['main']['App']['InstallFFmpegWithBrew']();
}
export function IsBrewFFmpegInstalled() {
return window['go']['main']['App']['IsBrewFFmpegInstalled']();
}
export function IsFFmpegInstalled() {
return window['go']['main']['App']['IsFFmpegInstalled']();
}
export function IsFFprobeInstalled() {
return window['go']['main']['App']['IsFFprobeInstalled']();
}
export function ListAudioFilesInDir(arg1) {
return window['go']['main']['App']['ListAudioFilesInDir'](arg1);
}
export function ListDirectoryFiles(arg1) {
return window['go']['main']['App']['ListDirectoryFiles'](arg1);
}
export function LoadSettings() {
return window['go']['main']['App']['LoadSettings']();
}
export function MarkDownloadItemFailed(arg1, arg2) {
return window['go']['main']['App']['MarkDownloadItemFailed'](arg1, arg2);
}
export function OpenConfigFolder() {
return window['go']['main']['App']['OpenConfigFolder']();
}
export function OpenFolder(arg1) {
return window['go']['main']['App']['OpenFolder'](arg1);
}
export function PreviewRenameFiles(arg1, arg2) {
return window['go']['main']['App']['PreviewRenameFiles'](arg1, arg2);
}
export function Quit() {
return window['go']['main']['App']['Quit']();
}
export function ReadFileAsBase64(arg1) {
return window['go']['main']['App']['ReadFileAsBase64'](arg1);
}
export function ReadFileMetadata(arg1) {
return window['go']['main']['App']['ReadFileMetadata'](arg1);
}
export function ReadImageAsBase64(arg1) {
return window['go']['main']['App']['ReadImageAsBase64'](arg1);
}
export function ReadTextFile(arg1) {
return window['go']['main']['App']['ReadTextFile'](arg1);
}
export function RenameFileTo(arg1, arg2) {
return window['go']['main']['App']['RenameFileTo'](arg1, arg2);
}
export function RenameFilesByMetadata(arg1, arg2) {
return window['go']['main']['App']['RenameFilesByMetadata'](arg1, arg2);
}
export function ResampleAudio(arg1) {
return window['go']['main']['App']['ResampleAudio'](arg1);
}
export function SaveRecentFetches(arg1) {
return window['go']['main']['App']['SaveRecentFetches'](arg1);
}
export function SaveSettings(arg1) {
return window['go']['main']['App']['SaveSettings'](arg1);
}
export function SaveSpectrumImage(arg1, arg2) {
return window['go']['main']['App']['SaveSpectrumImage'](arg1, arg2);
}
export function SearchSpotify(arg1) {
return window['go']['main']['App']['SearchSpotify'](arg1);
}
export function SearchSpotifyByType(arg1) {
return window['go']['main']['App']['SearchSpotifyByType'](arg1);
}
export function SelectAudioFiles() {
return window['go']['main']['App']['SelectAudioFiles']();
}
export function SelectFile() {
return window['go']['main']['App']['SelectFile']();
}
export function SelectFolder(arg1) {
return window['go']['main']['App']['SelectFolder'](arg1);
}
export function SelectImageVideo() {
return window['go']['main']['App']['SelectImageVideo']();
}
export function SkipDownloadItem(arg1, arg2) {
return window['go']['main']['App']['SkipDownloadItem'](arg1, arg2);
}
+1 -1
View File
@@ -12,7 +12,7 @@
},
"info": {
"productName": "SpotiFLAC",
"productVersion": "7.1.4",
"productVersion": "7.1.5",
"copyright": "© 2026 afkarxyz"
},
"wailsjsdir": "./frontend",