v7.1.3
This commit is contained in:
@@ -55,7 +55,7 @@ body:
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: SpotiDownloader Version
|
||||
label: SpotiFLAC Version
|
||||
placeholder: e.g. v7.1.0
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -18,7 +18,7 @@ Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account
|
||||
|
||||
### [SpotiFLAC Next](https://github.com/afkarxyz/SpotiFLAC-Next)
|
||||
|
||||
Get Spotify tracks in true FLAC from Tidal, Qobuz, Amazon Music & Deezer — no account required.
|
||||
Get Spotify tracks in true Lossless from Tidal, Qobuz, Amazon Music, Deezer & Apple Music — no account required.
|
||||
|
||||
### [SpotiDownloader](https://github.com/afkarxyz/SpotiDownloader)
|
||||
|
||||
@@ -112,7 +112,7 @@ The software is provided "as is", without warranty of any kind. The author assum
|
||||
|
||||
## API Credits
|
||||
|
||||
[MusicBrainz](https://musicbrainz.org) · [LRCLIB](https://lrclib.net) · [Song.link](https://song.link) · [hifi-api](https://github.com/binimum/hifi-api) · [dabmusic.xyz](https://dabmusic.xyz)
|
||||
[MusicBrainz](https://musicbrainz.org) · [LRCLIB](https://lrclib.net) · [Songlink/Odesli](https://song.link) · [Songstats](https://songstats.com) · [hifi-api](https://github.com/binimum/hifi-api) · [dabmusic.xyz](https://dabmusic.xyz)
|
||||
|
||||
> [!TIP]
|
||||
>
|
||||
|
||||
@@ -4,8 +4,10 @@ import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
|
||||
"path/filepath"
|
||||
@@ -23,10 +25,73 @@ type App struct {
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
const checkOperationTimeout = 10 * time.Second
|
||||
|
||||
func NewApp() *App {
|
||||
return &App{}
|
||||
}
|
||||
|
||||
type timedResult[T any] struct {
|
||||
value T
|
||||
err error
|
||||
}
|
||||
|
||||
func runWithTimeout[T any](timeout time.Duration, fn func() (T, error)) (T, error) {
|
||||
resultCh := make(chan timedResult[T], 1)
|
||||
|
||||
go func() {
|
||||
value, err := fn()
|
||||
resultCh <- timedResult[T]{value: value, err: err}
|
||||
}()
|
||||
|
||||
select {
|
||||
case result := <-resultCh:
|
||||
return result.value, result.err
|
||||
case <-time.After(timeout):
|
||||
var zero T
|
||||
return zero, fmt.Errorf("operation timed out after %s", timeout)
|
||||
}
|
||||
}
|
||||
|
||||
func containsStreamingURL(body []byte) bool {
|
||||
trimmedBody := strings.TrimSpace(string(body))
|
||||
if trimmedBody == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
var directResp struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &directResp); err == nil && isStreamingURL(directResp.URL) {
|
||||
return true
|
||||
}
|
||||
|
||||
var nestedResp struct {
|
||||
Data struct {
|
||||
URL string `json:"url"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &nestedResp); err == nil && isStreamingURL(nestedResp.Data.URL) {
|
||||
return true
|
||||
}
|
||||
|
||||
return isStreamingURL(trimmedBody)
|
||||
}
|
||||
|
||||
func isStreamingURL(raw string) bool {
|
||||
candidate := strings.TrimSpace(raw)
|
||||
if candidate == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(candidate)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return (parsed.Scheme == "http" || parsed.Scheme == "https") && parsed.Host != ""
|
||||
}
|
||||
|
||||
func (a *App) getFirstArtist(artistString string) string {
|
||||
if artistString == "" {
|
||||
return ""
|
||||
@@ -46,10 +111,18 @@ func (a *App) startup(ctx context.Context) {
|
||||
if err := backend.InitHistoryDB("SpotiFLAC"); err != nil {
|
||||
fmt.Printf("Failed to init history DB: %v\n", err)
|
||||
}
|
||||
if err := backend.InitISRCCacheDB(); err != nil {
|
||||
fmt.Printf("Failed to init ISRC cache DB: %v\n", err)
|
||||
}
|
||||
if err := backend.InitProviderPriorityDB(); err != nil {
|
||||
fmt.Printf("Failed to init provider priority DB: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) shutdown(ctx context.Context) {
|
||||
backend.CloseHistoryDB()
|
||||
backend.CloseISRCCacheDB()
|
||||
backend.CloseProviderPriorityDB()
|
||||
}
|
||||
|
||||
type SpotifyMetadataRequest struct {
|
||||
@@ -408,7 +481,10 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
||||
if req.Service == "qobuz" {
|
||||
go func() {
|
||||
client := backend.NewSongLinkClient()
|
||||
isrc, _ := client.GetISRCDirect(req.SpotifyID)
|
||||
isrc, err := client.GetISRCDirect(req.SpotifyID)
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: failed to resolve ISRC for Qobuz: %v\n", err)
|
||||
}
|
||||
isrcChan <- isrc
|
||||
}()
|
||||
} else {
|
||||
@@ -455,7 +531,7 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
||||
if quality == "" {
|
||||
quality = "6"
|
||||
}
|
||||
filename, err = downloader.DownloadTrackWithISRC(isrc, req.SpotifyID, req.OutputDir, quality, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre)
|
||||
filename, err = downloader.DownloadTrackWithISRC(isrc, req.OutputDir, quality, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre)
|
||||
|
||||
default:
|
||||
return DownloadResponse{
|
||||
@@ -500,7 +576,7 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
||||
Success: false,
|
||||
Error: errorMessage,
|
||||
ItemID: itemID,
|
||||
}, fmt.Errorf(errorMessage)
|
||||
}, errors.New(errorMessage)
|
||||
}
|
||||
if !validated {
|
||||
fmt.Printf("[DownloadValidation] Skipped duration validation for %s (expected=%ds)\n", filename, req.Duration)
|
||||
@@ -626,12 +702,8 @@ func (a *App) OpenFolder(path string) error {
|
||||
}
|
||||
|
||||
func (a *App) OpenConfigFolder() error {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
configDir, err := backend.EnsureAppDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get home directory: %v", err)
|
||||
}
|
||||
configDir := filepath.Join(homeDir, ".spotiflac")
|
||||
if err := os.MkdirAll(configDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create config directory: %v", err)
|
||||
}
|
||||
return backend.OpenFolderInExplorer(configDir)
|
||||
@@ -750,11 +822,12 @@ 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&format_id=27", apiURL)
|
||||
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" {
|
||||
@@ -763,10 +836,10 @@ func (a *App) CheckAPIStatus(apiType string, apiURL string) bool {
|
||||
checkURL = apiURL
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
req, err := http.NewRequest("GET", checkURL, nil)
|
||||
if err != nil {
|
||||
return false
|
||||
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")
|
||||
|
||||
@@ -775,26 +848,41 @@ func (a *App) CheckAPIStatus(apiType string, apiURL string) bool {
|
||||
resp, err := client.Do(req)
|
||||
if err == nil {
|
||||
statusCode := resp.StatusCode
|
||||
if apiType == "amazon" && statusCode == 200 {
|
||||
body, readErr := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if readErr == nil && strings.Contains(string(body), `"amazonMusic":"up"`) {
|
||||
return true
|
||||
if readErr != nil {
|
||||
if i < maxRetries-1 {
|
||||
time.Sleep(1 * time.Second)
|
||||
}
|
||||
} else {
|
||||
resp.Body.Close()
|
||||
if statusCode == 200 {
|
||||
return true
|
||||
continue
|
||||
}
|
||||
|
||||
if apiType == "amazon" && statusCode == 200 && strings.Contains(string(body), `"amazonMusic":"up"`) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
if (apiType == "qobuz" || apiType == "qbz") && statusCode == 200 && containsStreamingURL(body) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
if apiType != "amazon" && apiType != "qobuz" && apiType != "qbz" && statusCode == 200 {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
if i < maxRetries-1 {
|
||||
time.Sleep(1 * time.Second)
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Printf("CheckAPIStatus timeout/error for %s (%s): %v\n", apiType, apiURL, err)
|
||||
return false
|
||||
}
|
||||
|
||||
return isOnline
|
||||
}
|
||||
|
||||
func (a *App) Quit() {
|
||||
|
||||
panic("quit")
|
||||
@@ -1078,6 +1166,7 @@ func (a *App) CheckTrackAvailability(spotifyTrackID string) (string, error) {
|
||||
return "", fmt.Errorf("spotify track ID is required")
|
||||
}
|
||||
|
||||
return runWithTimeout(checkOperationTimeout, func() (string, error) {
|
||||
client := backend.NewSongLinkClient()
|
||||
availability, err := client.CheckTrackAvailability(spotifyTrackID)
|
||||
if err != nil {
|
||||
@@ -1090,6 +1179,7 @@ func (a *App) CheckTrackAvailability(spotifyTrackID string) (string, error) {
|
||||
}
|
||||
|
||||
return string(jsonData), nil
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) IsFFmpegInstalled() (bool, error) {
|
||||
@@ -1257,6 +1347,14 @@ func (a *App) ReadFileAsBase64(filePath string) (string, error) {
|
||||
return base64.StdEncoding.EncodeToString(content), nil
|
||||
}
|
||||
|
||||
func (a *App) DecodeAudioForAnalysis(filePath string) (*backend.AnalysisDecodeResponse, error) {
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("file path is required")
|
||||
}
|
||||
|
||||
return backend.DecodeAudioForAnalysis(filePath)
|
||||
}
|
||||
|
||||
func (a *App) RenameFileTo(oldPath, newName string) error {
|
||||
dir := filepath.Dir(oldPath)
|
||||
ext := filepath.Ext(oldPath)
|
||||
|
||||
@@ -387,6 +387,7 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename
|
||||
DiscNumber: spotifyDiscNumber,
|
||||
TotalDiscs: spotifyTotalDiscs,
|
||||
URL: spotifyURL,
|
||||
Comment: spotifyURL,
|
||||
Copyright: spotifyCopyright,
|
||||
Publisher: spotifyPublisher,
|
||||
Description: "https://github.com/afkarxyz/SpotiFLAC",
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -24,6 +26,16 @@ type AnalysisResult struct {
|
||||
RMSLevel float64 `json:"rms_level"`
|
||||
}
|
||||
|
||||
type AnalysisDecodeResponse struct {
|
||||
PCMBase64 string `json:"pcm_base64"`
|
||||
SampleRate uint32 `json:"sample_rate"`
|
||||
Channels uint8 `json:"channels"`
|
||||
BitsPerSample uint8 `json:"bits_per_sample"`
|
||||
Duration float64 `json:"duration"`
|
||||
BitrateKbps int `json:"bitrate_kbps,omitempty"`
|
||||
BitDepth string `json:"bit_depth,omitempty"`
|
||||
}
|
||||
|
||||
func GetTrackMetadata(filepath string) (*AnalysisResult, error) {
|
||||
if !fileExists(filepath) {
|
||||
return nil, fmt.Errorf("file does not exist: %s", filepath)
|
||||
@@ -113,3 +125,90 @@ func GetMetadataWithFFprobe(filePath string) (*AnalysisResult, error) {
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func DecodeAudioForAnalysis(filePath string) (*AnalysisDecodeResponse, error) {
|
||||
metadata, err := GetTrackMetadata(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pcmBase64, err := extractAnalysisPCMBase64(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp := &AnalysisDecodeResponse{
|
||||
PCMBase64: pcmBase64,
|
||||
SampleRate: metadata.SampleRate,
|
||||
Channels: metadata.Channels,
|
||||
BitsPerSample: metadata.BitsPerSample,
|
||||
Duration: metadata.Duration,
|
||||
BitDepth: metadata.BitDepth,
|
||||
}
|
||||
|
||||
if metadata.Bitrate > 0 {
|
||||
resp.BitrateKbps = metadata.Bitrate / 1000
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func extractAnalysisPCMBase64(filePath string) (string, error) {
|
||||
ffmpegPath, err := GetFFmpegPath()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
argSets := [][]string{
|
||||
{
|
||||
"-v", "error",
|
||||
"-i", filePath,
|
||||
"-vn",
|
||||
"-map", "0:a:0",
|
||||
"-af", "pan=mono|c0=c0",
|
||||
"-f", "s16le",
|
||||
"-acodec", "pcm_s16le",
|
||||
"pipe:1",
|
||||
},
|
||||
{
|
||||
"-v", "error",
|
||||
"-i", filePath,
|
||||
"-vn",
|
||||
"-map", "0:a:0",
|
||||
"-ac", "1",
|
||||
"-f", "s16le",
|
||||
"-acodec", "pcm_s16le",
|
||||
"pipe:1",
|
||||
},
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
|
||||
for _, args := range argSets {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
cmd := exec.Command(ffmpegPath, args...)
|
||||
setHideWindow(cmd)
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
lastErr = fmt.Errorf("ffmpeg analysis decode failed: %w - %s", err, strings.TrimSpace(stderr.String()))
|
||||
continue
|
||||
}
|
||||
|
||||
if stdout.Len() == 0 {
|
||||
lastErr = fmt.Errorf("ffmpeg analysis decode returned empty PCM output")
|
||||
continue
|
||||
}
|
||||
|
||||
return base64.StdEncoding.EncodeToString(stdout.Bytes()), nil
|
||||
}
|
||||
|
||||
if lastErr != nil {
|
||||
return "", lastErr
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("ffmpeg analysis decode failed")
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func GetDefaultMusicPath() string {
|
||||
@@ -15,3 +17,87 @@ func GetDefaultMusicPath() string {
|
||||
|
||||
return filepath.Join(homeDir, "Music")
|
||||
}
|
||||
|
||||
func GetConfigPath() (string, error) {
|
||||
dir, err := EnsureAppDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return filepath.Join(dir, "config.json"), nil
|
||||
}
|
||||
|
||||
func LoadConfigSettings() (map[string]interface{}, error) {
|
||||
configPath, err := GetConfigPath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var settings map[string]interface{}
|
||||
if err := json.Unmarshal(data, &settings); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
func GetSpotFetchAPISettings() (bool, string) {
|
||||
settings, err := LoadConfigSettings()
|
||||
if err != nil || settings == nil {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
useAPI, _ := settings["useSpotFetchAPI"].(bool)
|
||||
if !useAPI {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
apiURL, _ := settings["spotFetchAPIUrl"].(string)
|
||||
if apiURL == "" {
|
||||
apiURL = "https://sp.afkarxyz.qzz.io/api"
|
||||
}
|
||||
|
||||
return true, apiURL
|
||||
}
|
||||
|
||||
func GetLinkResolverSetting() string {
|
||||
settings, err := LoadConfigSettings()
|
||||
if err != nil || settings == nil {
|
||||
return linkResolverProviderDeezerSongLink
|
||||
}
|
||||
|
||||
resolver, _ := settings["linkResolver"].(string)
|
||||
switch strings.TrimSpace(strings.ToLower(resolver)) {
|
||||
case "songlink", linkResolverProviderDeezerSongLink:
|
||||
return linkResolverProviderDeezerSongLink
|
||||
case "songstats":
|
||||
return linkResolverProviderSongstats
|
||||
case "":
|
||||
return linkResolverProviderDeezerSongLink
|
||||
default:
|
||||
return linkResolverProviderDeezerSongLink
|
||||
}
|
||||
}
|
||||
|
||||
func GetLinkResolverAllowFallback() bool {
|
||||
settings, err := LoadConfigSettings()
|
||||
if err != nil || settings == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
allowFallback, ok := settings["allowResolverFallback"].(bool)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
|
||||
return allowFallback
|
||||
}
|
||||
|
||||
+18
-1
@@ -58,7 +58,7 @@ func ValidateExecutable(path string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetFFmpegDir() (string, error) {
|
||||
func GetAppDir() (string, error) {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get home directory: %w", err)
|
||||
@@ -66,6 +66,23 @@ func GetFFmpegDir() (string, error) {
|
||||
return filepath.Join(homeDir, ".spotiflac"), nil
|
||||
}
|
||||
|
||||
func EnsureAppDir() (string, error) {
|
||||
appDir, err := GetAppDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(appDir, 0o755); err != nil {
|
||||
return "", fmt.Errorf("failed to create app directory: %w", err)
|
||||
}
|
||||
|
||||
return appDir, nil
|
||||
}
|
||||
|
||||
func GetFFmpegDir() (string, error) {
|
||||
return EnsureAppDir()
|
||||
}
|
||||
|
||||
func GetFFmpegPath() (string, error) {
|
||||
ffmpegDir, err := GetFFmpegDir()
|
||||
if err != nil {
|
||||
|
||||
@@ -11,8 +11,8 @@ func SelectMultipleFiles(ctx context.Context) ([]string, error) {
|
||||
Title: "Select Audio Files",
|
||||
Filters: []runtime.FileFilter{
|
||||
{
|
||||
DisplayName: "Audio Files (*.mp3, *.m4a, *.flac)",
|
||||
Pattern: "*.mp3;*.m4a;*.flac",
|
||||
DisplayName: "Audio Files (*.mp3, *.m4a, *.flac, *.aac)",
|
||||
Pattern: "*.mp3;*.m4a;*.flac;*.aac",
|
||||
},
|
||||
{
|
||||
DisplayName: "MP3 Files (*.mp3)",
|
||||
@@ -26,6 +26,10 @@ func SelectMultipleFiles(ctx context.Context) ([]string, error) {
|
||||
DisplayName: "FLAC Files (*.flac)",
|
||||
Pattern: "*.flac",
|
||||
},
|
||||
{
|
||||
DisplayName: "AAC Files (*.aac)",
|
||||
Pattern: "*.aac",
|
||||
},
|
||||
{
|
||||
DisplayName: "All Files (*.*)",
|
||||
Pattern: "*.*",
|
||||
|
||||
@@ -94,7 +94,7 @@ func ListAudioFiles(dirPath string) ([]FileInfo, error) {
|
||||
}
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
if ext == ".flac" || ext == ".mp3" || ext == ".m4a" {
|
||||
if ext == ".flac" || ext == ".mp3" || ext == ".m4a" || ext == ".aac" {
|
||||
result = append(result, FileInfo{
|
||||
Name: info.Name(),
|
||||
Path: path,
|
||||
|
||||
+2
-12
@@ -1,9 +1,7 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
@@ -139,18 +137,11 @@ func NormalizePath(folderPath string) string {
|
||||
}
|
||||
|
||||
func GetSeparator() string {
|
||||
dir, err := GetFFmpegDir()
|
||||
if err != nil {
|
||||
return "; "
|
||||
}
|
||||
configPath := filepath.Join(dir, "config.json")
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
settings, err := LoadConfigSettings()
|
||||
if err != nil || settings == nil {
|
||||
return "; "
|
||||
}
|
||||
|
||||
var settings map[string]interface{}
|
||||
if err := json.Unmarshal(data, &settings); err == nil {
|
||||
if sep, ok := settings["separator"].(string); ok {
|
||||
if sep == "comma" {
|
||||
return ", "
|
||||
@@ -159,7 +150,6 @@ func GetSeparator() string {
|
||||
return "; "
|
||||
}
|
||||
}
|
||||
}
|
||||
return "; "
|
||||
}
|
||||
|
||||
|
||||
+1
-5
@@ -3,7 +3,6 @@ package backend
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"time"
|
||||
@@ -35,13 +34,10 @@ const (
|
||||
|
||||
func InitHistoryDB(appName string) error {
|
||||
|
||||
appDir, err := GetFFmpegDir()
|
||||
appDir, err := EnsureAppDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := os.Stat(appDir); os.IsNotExist(err) {
|
||||
os.MkdirAll(appDir, 0755)
|
||||
}
|
||||
dbPath := filepath.Join(appDir, "history.db")
|
||||
|
||||
db, err := bolt.Open(dbPath, 0600, &bolt.Options{Timeout: 1 * time.Second})
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
bolt "go.etcd.io/bbolt"
|
||||
)
|
||||
|
||||
const (
|
||||
isrcCacheDBFile = "isrc_cache.db"
|
||||
isrcCacheBucket = "SpotifyTrackISRC"
|
||||
)
|
||||
|
||||
type isrcCacheEntry struct {
|
||||
TrackID string `json:"track_id"`
|
||||
ISRC string `json:"isrc"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
var (
|
||||
isrcCacheDB *bolt.DB
|
||||
isrcCacheDBMu sync.Mutex
|
||||
)
|
||||
|
||||
func InitISRCCacheDB() error {
|
||||
isrcCacheDBMu.Lock()
|
||||
defer isrcCacheDBMu.Unlock()
|
||||
|
||||
if isrcCacheDB != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
appDir, err := EnsureAppDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dbPath := filepath.Join(appDir, isrcCacheDBFile)
|
||||
db, err := bolt.Open(dbPath, 0o600, &bolt.Options{Timeout: 1 * time.Second})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := db.Update(func(tx *bolt.Tx) error {
|
||||
_, err := tx.CreateBucketIfNotExists([]byte(isrcCacheBucket))
|
||||
return err
|
||||
}); err != nil {
|
||||
db.Close()
|
||||
return err
|
||||
}
|
||||
|
||||
isrcCacheDB = db
|
||||
return nil
|
||||
}
|
||||
|
||||
func CloseISRCCacheDB() {
|
||||
isrcCacheDBMu.Lock()
|
||||
defer isrcCacheDBMu.Unlock()
|
||||
|
||||
if isrcCacheDB != nil {
|
||||
_ = isrcCacheDB.Close()
|
||||
isrcCacheDB = nil
|
||||
}
|
||||
}
|
||||
|
||||
func GetCachedISRC(trackID string) (string, error) {
|
||||
normalizedTrackID := strings.TrimSpace(trackID)
|
||||
if normalizedTrackID == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
if err := InitISRCCacheDB(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var cachedISRC string
|
||||
err := isrcCacheDB.View(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte(isrcCacheBucket))
|
||||
if bucket == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
value := bucket.Get([]byte(normalizedTrackID))
|
||||
if len(value) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var entry isrcCacheEntry
|
||||
if err := json.Unmarshal(value, &entry); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cachedISRC = strings.ToUpper(strings.TrimSpace(entry.ISRC))
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return cachedISRC, nil
|
||||
}
|
||||
|
||||
func PutCachedISRC(trackID string, isrc string) error {
|
||||
normalizedTrackID := strings.TrimSpace(trackID)
|
||||
normalizedISRC := strings.ToUpper(strings.TrimSpace(isrc))
|
||||
if normalizedTrackID == "" || normalizedISRC == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := InitISRCCacheDB(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
entry := isrcCacheEntry{
|
||||
TrackID: normalizedTrackID,
|
||||
ISRC: normalizedISRC,
|
||||
UpdatedAt: time.Now().Unix(),
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(entry)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encode ISRC cache entry: %w", err)
|
||||
}
|
||||
|
||||
return isrcCacheDB.Update(func(tx *bolt.Tx) error {
|
||||
bucket, err := tx.CreateBucketIfNotExists([]byte(isrcCacheBucket))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return bucket.Put([]byte(normalizedTrackID), payload)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,572 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha1"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
spotifyServerTimeURL = "https://open.spotify.com/api/server-time"
|
||||
spotifySessionTokenURL = "https://open.spotify.com/api/token"
|
||||
spotifyTOTPSecretsURL = "https://git.gay/thereallo/totp-secrets/raw/branch/main/secrets/secretDict.json"
|
||||
spotifyGIDMetadataURL = "https://spclient.wg.spotify.com/metadata/4/%s/%s?market=from_token"
|
||||
spotifyTOTPPeriod = 30
|
||||
spotifyTOTPDigits = 6
|
||||
spotifyBase62Alphabet = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
spotifyTokenCacheFile = ".isrc-finder-token.json"
|
||||
spotifySecretsCacheFile = "spotify-secret-dict-cache.json"
|
||||
spotifySecretsCacheTTL = 24 * time.Hour
|
||||
)
|
||||
|
||||
var spotifyAnonymousTokenMu sync.Mutex
|
||||
|
||||
type spotifyAnonymousToken struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
AccessTokenExpirationTimestampMs int64 `json:"accessTokenExpirationTimestampMs"`
|
||||
}
|
||||
|
||||
type spotifyServerTimeResponse struct {
|
||||
ServerTime int64 `json:"serverTime"`
|
||||
}
|
||||
|
||||
type spotifySecretsCache struct {
|
||||
FetchedAtUnix int64 `json:"fetched_at_unix"`
|
||||
Secrets map[string][]int `json:"secrets"`
|
||||
}
|
||||
|
||||
type spotifyTrackRawData struct {
|
||||
ExternalID []struct {
|
||||
Type string `json:"type"`
|
||||
ID string `json:"id"`
|
||||
} `json:"external_id"`
|
||||
}
|
||||
|
||||
type spotFetchISRCResponse struct {
|
||||
Input string `json:"input"`
|
||||
TrackID string `json:"track_id"`
|
||||
GID string `json:"gid"`
|
||||
CanonicalURI string `json:"canonical_uri"`
|
||||
Name string `json:"name"`
|
||||
Artists []string `json:"artists"`
|
||||
AlbumName string `json:"album_name"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
Label string `json:"label"`
|
||||
ISRC string `json:"isrc"`
|
||||
}
|
||||
|
||||
func (s *SongLinkClient) lookupSpotifyISRC(spotifyTrackID string) (string, error) {
|
||||
normalizedTrackID, err := extractSpotifyTrackID(spotifyTrackID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
cachedISRC, err := GetCachedISRC(normalizedTrackID)
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: failed to read ISRC cache: %v\n", err)
|
||||
} else if cachedISRC != "" {
|
||||
fmt.Printf("Found ISRC in cache: %s\n", cachedISRC)
|
||||
return cachedISRC, nil
|
||||
}
|
||||
|
||||
useSpotFetchAPI, spotFetchAPIURL := GetSpotFetchAPISettings()
|
||||
if useSpotFetchAPI {
|
||||
isrc, resolvedTrackID, err := s.lookupSpotifyISRCViaSpotFetchAPI(normalizedTrackID, spotFetchAPIURL)
|
||||
if err == nil && isrc != "" {
|
||||
fmt.Printf("Found ISRC via SpotFetch API: %s\n", isrc)
|
||||
cacheResolvedSpotifyTrackISRC(normalizedTrackID, resolvedTrackID, isrc)
|
||||
return isrc, nil
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: SpotFetch ISRC lookup failed, falling back to Spotify metadata: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
payload, metadataErr := fetchSpotifyTrackRawData(s.client, normalizedTrackID)
|
||||
if metadataErr == nil {
|
||||
isrc, extractErr := extractSpotifyTrackISRC(payload)
|
||||
if extractErr == nil {
|
||||
fmt.Printf("Found ISRC via Spotify metadata: %s\n", isrc)
|
||||
cacheResolvedSpotifyTrackISRC(normalizedTrackID, "", isrc)
|
||||
return isrc, nil
|
||||
}
|
||||
metadataErr = extractErr
|
||||
}
|
||||
|
||||
if metadataErr != nil {
|
||||
fmt.Printf("Warning: Spotify metadata ISRC lookup failed, falling back to Soundplate: %v\n", metadataErr)
|
||||
}
|
||||
|
||||
isrc, resolvedTrackID, soundplateErr := s.lookupSpotifyISRCViaSoundplate(normalizedTrackID)
|
||||
if soundplateErr == nil && isrc != "" {
|
||||
fmt.Printf("Found ISRC via Soundplate: %s\n", isrc)
|
||||
cacheResolvedSpotifyTrackISRC(normalizedTrackID, resolvedTrackID, isrc)
|
||||
return isrc, nil
|
||||
}
|
||||
|
||||
if metadataErr != nil && soundplateErr != nil {
|
||||
return "", fmt.Errorf("spotify metadata lookup failed: %v | soundplate lookup failed: %w", metadataErr, soundplateErr)
|
||||
}
|
||||
if soundplateErr != nil {
|
||||
return "", soundplateErr
|
||||
}
|
||||
return "", metadataErr
|
||||
}
|
||||
|
||||
func cacheResolvedSpotifyTrackISRC(trackID string, resolvedTrackID string, isrc string) {
|
||||
if err := PutCachedISRC(trackID, isrc); err != nil {
|
||||
fmt.Printf("Warning: failed to write ISRC cache: %v\n", err)
|
||||
}
|
||||
if resolvedTrackID != "" && resolvedTrackID != trackID {
|
||||
if err := PutCachedISRC(resolvedTrackID, isrc); err != nil {
|
||||
fmt.Printf("Warning: failed to write ISRC cache for resolved track ID: %v\n", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SongLinkClient) lookupSpotifyISRCViaSpotFetchAPI(spotifyTrackID string, apiBaseURL string) (string, string, error) {
|
||||
normalizedTrackID := strings.TrimSpace(spotifyTrackID)
|
||||
baseURL := strings.TrimRight(strings.TrimSpace(apiBaseURL), "/")
|
||||
if normalizedTrackID == "" {
|
||||
return "", "", fmt.Errorf("spotify track ID is required")
|
||||
}
|
||||
if baseURL == "" {
|
||||
return "", "", fmt.Errorf("spotfetch api url is required")
|
||||
}
|
||||
|
||||
requestURL := fmt.Sprintf("%s/isrc/%s", baseURL, url.PathEscape(normalizedTrackID))
|
||||
req, err := http.NewRequest(http.MethodGet, requestURL, nil)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to create SpotFetch ISRC request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", songLinkUserAgent)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("SpotFetch ISRC request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyPreview, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
|
||||
return "", "", fmt.Errorf("SpotFetch ISRC returned status %d (%s)", resp.StatusCode, strings.TrimSpace(string(bodyPreview)))
|
||||
}
|
||||
|
||||
var payload spotFetchISRCResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||
return "", "", fmt.Errorf("failed to decode SpotFetch ISRC response: %w", err)
|
||||
}
|
||||
|
||||
isrc := firstISRCMatch(payload.ISRC)
|
||||
if isrc == "" {
|
||||
return "", "", fmt.Errorf("ISRC missing in SpotFetch response")
|
||||
}
|
||||
|
||||
return isrc, strings.TrimSpace(payload.TrackID), nil
|
||||
}
|
||||
|
||||
func requestSpotifyBytes(client *http.Client, targetURL string, headers map[string]string) ([]byte, error) {
|
||||
req, err := http.NewRequest(http.MethodGet, targetURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for key, value := range headers {
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
details := strings.TrimSpace(string(body))
|
||||
if details == "" {
|
||||
details = resp.Status
|
||||
}
|
||||
return nil, fmt.Errorf("request failed: %s", details)
|
||||
}
|
||||
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func requestSpotifyJSON(client *http.Client, targetURL string, headers map[string]string, target interface{}) error {
|
||||
body, err := requestSpotifyBytes(client, targetURL, headers)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, target); err != nil {
|
||||
return fmt.Errorf("failed to parse JSON response: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadSpotifyCachedToken() (*spotifyAnonymousToken, error) {
|
||||
cachePath, err := spotifyTokenCachePath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
body, err := os.ReadFile(cachePath)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to read token cache: %w", err)
|
||||
}
|
||||
|
||||
var token spotifyAnonymousToken
|
||||
if err := json.Unmarshal(body, &token); err != nil {
|
||||
return nil, fmt.Errorf("failed to read token cache: %w", err)
|
||||
}
|
||||
|
||||
return &token, nil
|
||||
}
|
||||
|
||||
func saveSpotifyCachedToken(token *spotifyAnonymousToken) error {
|
||||
cachePath, err := spotifyTokenCachePath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(cachePath), 0o755); err != nil {
|
||||
return fmt.Errorf("failed to create token cache directory: %w", err)
|
||||
}
|
||||
|
||||
body, err := json.MarshalIndent(token, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.WriteFile(cachePath, body, 0o644); err != nil {
|
||||
return fmt.Errorf("failed to write token cache: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadSpotifyCachedSecrets() (*spotifySecretsCache, error) {
|
||||
cachePath, err := spotifySecretsCachePath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
body, err := os.ReadFile(cachePath)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to read secrets cache: %w", err)
|
||||
}
|
||||
|
||||
var cache spotifySecretsCache
|
||||
if err := json.Unmarshal(body, &cache); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse secrets cache: %w", err)
|
||||
}
|
||||
|
||||
return &cache, nil
|
||||
}
|
||||
|
||||
func saveSpotifyCachedSecrets(cache *spotifySecretsCache) error {
|
||||
cachePath, err := spotifySecretsCachePath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(cachePath), 0o755); err != nil {
|
||||
return fmt.Errorf("failed to create secrets cache directory: %w", err)
|
||||
}
|
||||
|
||||
body, err := json.MarshalIndent(cache, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.WriteFile(cachePath, body, 0o644); err != nil {
|
||||
return fmt.Errorf("failed to write secrets cache: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func spotifyTokenCachePath() (string, error) {
|
||||
appDir, err := EnsureAppDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return filepath.Join(appDir, spotifyTokenCacheFile), nil
|
||||
}
|
||||
|
||||
func spotifySecretsCachePath() (string, error) {
|
||||
appDir, err := EnsureAppDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return filepath.Join(appDir, spotifySecretsCacheFile), nil
|
||||
}
|
||||
|
||||
func spotifyTokenIsValid(token *spotifyAnonymousToken) bool {
|
||||
if token == nil || token.AccessToken == "" || token.AccessTokenExpirationTimestampMs == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
return time.Now().UnixMilli() < token.AccessTokenExpirationTimestampMs-30_000
|
||||
}
|
||||
|
||||
func spotifySecretsCacheIsValid(cache *spotifySecretsCache) bool {
|
||||
if cache == nil || cache.FetchedAtUnix == 0 || len(cache.Secrets) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
return time.Since(time.Unix(cache.FetchedAtUnix, 0)) < spotifySecretsCacheTTL
|
||||
}
|
||||
|
||||
func deriveSpotifyTOTPSecret(ciphertext []int) []byte {
|
||||
var builder strings.Builder
|
||||
|
||||
for index, value := range ciphertext {
|
||||
builder.WriteString(strconv.Itoa(value ^ ((index % 33) + 9)))
|
||||
}
|
||||
|
||||
return []byte(builder.String())
|
||||
}
|
||||
|
||||
func generateSpotifyTOTP(secret []byte, timestampMs int64) string {
|
||||
counter := timestampMs / 1000 / spotifyTOTPPeriod
|
||||
counterBytes := make([]byte, 8)
|
||||
binary.BigEndian.PutUint64(counterBytes, uint64(counter))
|
||||
|
||||
mac := hmac.New(sha1.New, secret)
|
||||
mac.Write(counterBytes)
|
||||
digest := mac.Sum(nil)
|
||||
|
||||
offset := digest[len(digest)-1] & 0x0f
|
||||
binaryCode := (int(digest[offset])&0x7f)<<24 |
|
||||
(int(digest[offset+1])&0xff)<<16 |
|
||||
(int(digest[offset+2])&0xff)<<8 |
|
||||
(int(digest[offset+3]) & 0xff)
|
||||
|
||||
modulo := 1
|
||||
for i := 0; i < spotifyTOTPDigits; i++ {
|
||||
modulo *= 10
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%0*d", spotifyTOTPDigits, binaryCode%modulo)
|
||||
}
|
||||
|
||||
func requestSpotifyAnonymousAccessToken(client *http.Client) (string, error) {
|
||||
spotifyAnonymousTokenMu.Lock()
|
||||
defer spotifyAnonymousTokenMu.Unlock()
|
||||
|
||||
cachedToken, err := loadSpotifyCachedToken()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if spotifyTokenIsValid(cachedToken) {
|
||||
return cachedToken.AccessToken, nil
|
||||
}
|
||||
|
||||
var serverTime spotifyServerTimeResponse
|
||||
if err := requestSpotifyJSON(client, spotifyServerTimeURL, nil, &serverTime); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var secrets map[string][]int
|
||||
cachedSecrets, err := loadSpotifyCachedSecrets()
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: failed to read Spotify secrets cache: %v\n", err)
|
||||
}
|
||||
|
||||
if spotifySecretsCacheIsValid(cachedSecrets) {
|
||||
secrets = cachedSecrets.Secrets
|
||||
} else {
|
||||
if err := requestSpotifyJSON(client, spotifyTOTPSecretsURL, nil, &secrets); err != nil {
|
||||
if cachedSecrets != nil && len(cachedSecrets.Secrets) > 0 {
|
||||
fmt.Printf("Warning: failed to refresh Spotify secrets cache, using stale cache: %v\n", err)
|
||||
secrets = cachedSecrets.Secrets
|
||||
} else {
|
||||
return "", err
|
||||
}
|
||||
} else {
|
||||
cache := &spotifySecretsCache{
|
||||
FetchedAtUnix: time.Now().Unix(),
|
||||
Secrets: secrets,
|
||||
}
|
||||
if err := saveSpotifyCachedSecrets(cache); err != nil {
|
||||
fmt.Printf("Warning: failed to write Spotify secrets cache: %v\n", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
version, err := latestSpotifySecretVersion(secrets)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
secret := deriveSpotifyTOTPSecret(secrets[version])
|
||||
generatedTOTP := generateSpotifyTOTP(secret, serverTime.ServerTime*1000)
|
||||
|
||||
query := url.Values{
|
||||
"reason": {"init"},
|
||||
"productType": {"web-player"},
|
||||
"totp": {generatedTOTP},
|
||||
"totpServer": {generatedTOTP},
|
||||
"totpVer": {version},
|
||||
}
|
||||
|
||||
var token spotifyAnonymousToken
|
||||
if err := requestSpotifyJSON(client, spotifySessionTokenURL+"?"+query.Encode(), nil, &token); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err := saveSpotifyCachedToken(&token); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return token.AccessToken, nil
|
||||
}
|
||||
|
||||
func latestSpotifySecretVersion(secrets map[string][]int) (string, error) {
|
||||
var (
|
||||
bestVersion string
|
||||
bestNumber int
|
||||
)
|
||||
|
||||
for version := range secrets {
|
||||
number, err := strconv.Atoi(version)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid secret version %q: %w", version, err)
|
||||
}
|
||||
if bestVersion == "" || number > bestNumber {
|
||||
bestVersion = version
|
||||
bestNumber = number
|
||||
}
|
||||
}
|
||||
|
||||
if bestVersion == "" {
|
||||
return "", errors.New("no TOTP secret versions available")
|
||||
}
|
||||
|
||||
return bestVersion, nil
|
||||
}
|
||||
|
||||
func extractSpotifyTrackID(value string) (string, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return "", errors.New("track input is required")
|
||||
}
|
||||
|
||||
if strings.HasPrefix(value, "spotify:track:") {
|
||||
return value[strings.LastIndex(value, ":")+1:], nil
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(value)
|
||||
if err == nil && (parsed.Scheme == "http" || parsed.Scheme == "https") {
|
||||
parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
|
||||
if len(parts) >= 2 && parts[0] == "track" {
|
||||
return parts[1], nil
|
||||
}
|
||||
return "", errors.New("expected URL like https://open.spotify.com/track/<id>")
|
||||
}
|
||||
|
||||
if len(value) == 22 {
|
||||
return value, nil
|
||||
}
|
||||
|
||||
return "", errors.New("track must be a Spotify track ID, URL, or URI")
|
||||
}
|
||||
|
||||
func spotifyTrackIDToGID(trackID string) (string, error) {
|
||||
if trackID == "" {
|
||||
return "", errors.New("track ID is empty")
|
||||
}
|
||||
|
||||
value := big.NewInt(0)
|
||||
base := big.NewInt(62)
|
||||
|
||||
for _, char := range trackID {
|
||||
index := strings.IndexRune(spotifyBase62Alphabet, char)
|
||||
if index < 0 {
|
||||
return "", fmt.Errorf("invalid base62 character: %q", string(char))
|
||||
}
|
||||
|
||||
value.Mul(value, base)
|
||||
value.Add(value, big.NewInt(int64(index)))
|
||||
}
|
||||
|
||||
hexValue := value.Text(16)
|
||||
if len(hexValue) < 32 {
|
||||
hexValue = strings.Repeat("0", 32-len(hexValue)) + hexValue
|
||||
}
|
||||
|
||||
return hexValue, nil
|
||||
}
|
||||
|
||||
func fetchSpotifyTrackRawData(client *http.Client, trackID string) ([]byte, error) {
|
||||
accessToken, err := requestSpotifyAnonymousAccessToken(client)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
gid, err := spotifyTrackIDToGID(trackID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return requestSpotifyBytes(
|
||||
client,
|
||||
fmt.Sprintf(spotifyGIDMetadataURL, "track", gid),
|
||||
map[string]string{
|
||||
"authorization": "Bearer " + accessToken,
|
||||
"accept": "application/json",
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func extractSpotifyTrackISRC(payload []byte) (string, error) {
|
||||
var track spotifyTrackRawData
|
||||
if err := json.Unmarshal(payload, &track); err != nil {
|
||||
return "", fmt.Errorf("failed to decode Spotify track metadata: %w", err)
|
||||
}
|
||||
|
||||
for _, externalID := range track.ExternalID {
|
||||
if strings.EqualFold(strings.TrimSpace(externalID.Type), "isrc") {
|
||||
if isrc := firstISRCMatch(externalID.ID); isrc != "" {
|
||||
return isrc, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if fallbackISRC := firstISRCMatch(string(payload)); fallbackISRC != "" {
|
||||
return fallbackISRC, nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("ISRC not found in Spotify track metadata")
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type resolvedTrackLinks struct {
|
||||
TidalURL string
|
||||
AmazonURL string
|
||||
DeezerURL string
|
||||
ISRC string
|
||||
}
|
||||
|
||||
const (
|
||||
linkResolverProviderSongstats = "songstats"
|
||||
linkResolverProviderDeezerSongLink = "deezer-songlink"
|
||||
)
|
||||
|
||||
func (s *SongLinkClient) resolveSpotifyTrackLinks(spotifyTrackID string, region string) (*resolvedTrackLinks, error) {
|
||||
links := &resolvedTrackLinks{}
|
||||
var attempts []string
|
||||
|
||||
isrc, err := s.lookupSpotifyISRC(spotifyTrackID)
|
||||
if err != nil {
|
||||
attempts = append(attempts, fmt.Sprintf("spotify isrc: %v", err))
|
||||
} else {
|
||||
links.ISRC = isrc
|
||||
}
|
||||
|
||||
if links.ISRC != "" {
|
||||
resolvers := orderedLinkResolvers()
|
||||
|
||||
for _, resolver := range resolvers {
|
||||
switch resolver {
|
||||
case linkResolverProviderSongstats:
|
||||
addedData, songstatsErr := s.resolveLinksViaSongstats(links)
|
||||
if songstatsErr != nil {
|
||||
attempts = append(attempts, fmt.Sprintf("songstats: %v", songstatsErr))
|
||||
} else if addedData {
|
||||
fmt.Println("Using Songstats as configured link resolver")
|
||||
}
|
||||
case linkResolverProviderDeezerSongLink:
|
||||
addedData, deezerSongLinkErr := s.resolveLinksViaDeezerSongLink(links, region)
|
||||
if deezerSongLinkErr != nil {
|
||||
attempts = append(attempts, fmt.Sprintf("deezer-songlink: %v", deezerSongLinkErr))
|
||||
} else if addedData {
|
||||
fmt.Println("Using Songlink as configured link resolver")
|
||||
}
|
||||
}
|
||||
|
||||
if links.TidalURL != "" && links.AmazonURL != "" {
|
||||
return links, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if hasAnySongLinkData(links) {
|
||||
return links, nil
|
||||
}
|
||||
|
||||
if len(attempts) == 0 {
|
||||
attempts = append(attempts, "no streaming URLs found")
|
||||
}
|
||||
|
||||
return links, errors.New(strings.Join(attempts, " | "))
|
||||
}
|
||||
|
||||
func orderedLinkResolvers() []string {
|
||||
preferred := GetLinkResolverSetting()
|
||||
if !GetLinkResolverAllowFallback() {
|
||||
if preferred == linkResolverProviderDeezerSongLink {
|
||||
return []string{linkResolverProviderDeezerSongLink}
|
||||
}
|
||||
return []string{linkResolverProviderSongstats}
|
||||
}
|
||||
|
||||
if preferred == linkResolverProviderDeezerSongLink {
|
||||
return []string{
|
||||
linkResolverProviderDeezerSongLink,
|
||||
linkResolverProviderSongstats,
|
||||
}
|
||||
}
|
||||
|
||||
return []string{
|
||||
linkResolverProviderSongstats,
|
||||
linkResolverProviderDeezerSongLink,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SongLinkClient) resolveLinksViaSongstats(links *resolvedTrackLinks) (bool, error) {
|
||||
if links == nil || links.ISRC == "" {
|
||||
return false, fmt.Errorf("ISRC is required for Songstats resolver")
|
||||
}
|
||||
|
||||
before := *links
|
||||
|
||||
fmt.Printf("Fetching Songstats links for ISRC %s\n", links.ISRC)
|
||||
if err := s.populateLinksFromSongstats(links, links.ISRC); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return *links != before, nil
|
||||
}
|
||||
|
||||
func (s *SongLinkClient) resolveLinksViaDeezerSongLink(links *resolvedTrackLinks, region string) (bool, error) {
|
||||
if links == nil || links.ISRC == "" {
|
||||
return false, fmt.Errorf("ISRC is required for Deezer song.link resolver")
|
||||
}
|
||||
|
||||
before := *links
|
||||
var attempts []string
|
||||
|
||||
if links.DeezerURL == "" {
|
||||
fmt.Printf("Resolving Deezer track from ISRC %s\n", links.ISRC)
|
||||
deezerURL, err := s.lookupDeezerTrackURLByISRC(links.ISRC)
|
||||
if err != nil {
|
||||
attempts = append(attempts, fmt.Sprintf("deezer isrc: %v", err))
|
||||
} else {
|
||||
links.DeezerURL = deezerURL
|
||||
fmt.Printf("Found Deezer URL: %s\n", links.DeezerURL)
|
||||
}
|
||||
}
|
||||
|
||||
if links.DeezerURL != "" {
|
||||
fmt.Println("Resolving streaming URLs from song.link via Deezer URL...")
|
||||
deezerResp, err := s.fetchSongLinkLinksByURL(links.DeezerURL, region)
|
||||
if err != nil {
|
||||
attempts = append(attempts, fmt.Sprintf("song.link deezer: %v", err))
|
||||
} else {
|
||||
mergeSongLinkResponse(links, deezerResp)
|
||||
}
|
||||
|
||||
if links.ISRC == "" {
|
||||
if resolvedISRC, deezerISRCErr := getDeezerISRC(links.DeezerURL); deezerISRCErr == nil {
|
||||
links.ISRC = resolvedISRC
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if *links != before {
|
||||
if len(attempts) == 0 {
|
||||
return true, nil
|
||||
}
|
||||
return true, errors.New(strings.Join(attempts, " | "))
|
||||
}
|
||||
|
||||
if len(attempts) == 0 {
|
||||
attempts = append(attempts, "no links found via deezer-songlink")
|
||||
}
|
||||
|
||||
return false, errors.New(strings.Join(attempts, " | "))
|
||||
}
|
||||
+30
-1
@@ -28,6 +28,7 @@ type Metadata struct {
|
||||
DiscNumber int
|
||||
TotalDiscs int
|
||||
URL string
|
||||
Comment string
|
||||
Copyright string
|
||||
Publisher string
|
||||
Lyrics string
|
||||
@@ -88,6 +89,9 @@ func EmbedMetadata(filepath string, metadata Metadata, coverPath string) error {
|
||||
if metadata.Description != "" {
|
||||
_ = cmt.Add("DESCRIPTION", metadata.Description)
|
||||
}
|
||||
if comment := resolveMetadataComment(metadata); comment != "" {
|
||||
_ = cmt.Add("COMMENT", comment)
|
||||
}
|
||||
|
||||
if metadata.ISRC != "" {
|
||||
_ = cmt.Add("ISRC", metadata.ISRC)
|
||||
@@ -166,6 +170,14 @@ func extractYear(releaseDate string) string {
|
||||
return releaseDate
|
||||
}
|
||||
|
||||
func resolveMetadataComment(metadata Metadata) string {
|
||||
if comment := strings.TrimSpace(metadata.Comment); comment != "" {
|
||||
return comment
|
||||
}
|
||||
|
||||
return strings.TrimSpace(metadata.URL)
|
||||
}
|
||||
|
||||
func EmbedLyricsOnly(filepath string, lyrics string) error {
|
||||
if lyrics == "" {
|
||||
return nil
|
||||
@@ -891,7 +903,11 @@ func ExtractFullMetadataFromFile(filePath string) (Metadata, error) {
|
||||
metadata.Publisher = value
|
||||
case "url":
|
||||
metadata.URL = value
|
||||
case "description", "comment":
|
||||
case "comment", "comments":
|
||||
if metadata.Comment == "" {
|
||||
metadata.Comment = value
|
||||
}
|
||||
case "description":
|
||||
if metadata.Description == "" {
|
||||
metadata.Description = value
|
||||
}
|
||||
@@ -982,6 +998,16 @@ func embedMetadataToMP3(filePath string, metadata Metadata, coverPath string) er
|
||||
tag.AddTextFrame("TSRC", id3v2.EncodingUTF8, metadata.ISRC)
|
||||
}
|
||||
|
||||
if comment := resolveMetadataComment(metadata); comment != "" {
|
||||
tag.DeleteFrames(tag.CommonID("Comments"))
|
||||
tag.AddCommentFrame(id3v2.CommentFrame{
|
||||
Encoding: id3v2.EncodingUTF8,
|
||||
Language: "eng",
|
||||
Description: "",
|
||||
Text: comment,
|
||||
})
|
||||
}
|
||||
|
||||
if coverPath != "" && fileExists(coverPath) {
|
||||
|
||||
tag.DeleteFrames(tag.CommonID("Attached picture"))
|
||||
@@ -1068,6 +1094,9 @@ func embedMetadataToM4A(filePath string, metadata Metadata, coverPath string) er
|
||||
if metadata.ISRC != "" {
|
||||
args = append(args, "-metadata", "isrc="+metadata.ISRC)
|
||||
}
|
||||
if comment := resolveMetadataComment(metadata); comment != "" {
|
||||
args = append(args, "-metadata", "comment="+comment)
|
||||
}
|
||||
|
||||
tmpOutputFile := strings.TrimSuffix(filePath, pathfilepath.Ext(filePath)) + ".tmp" + pathfilepath.Ext(filePath)
|
||||
defer func() {
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
bolt "go.etcd.io/bbolt"
|
||||
)
|
||||
|
||||
const (
|
||||
providerPriorityDBFile = "provider_priority.db"
|
||||
providerPriorityBucket = "ProviderPriority"
|
||||
)
|
||||
|
||||
type providerPriorityEntry struct {
|
||||
Service string `json:"service"`
|
||||
Provider string `json:"provider"`
|
||||
LastOutcome string `json:"last_outcome"`
|
||||
LastAttempt int64 `json:"last_attempt"`
|
||||
LastSuccess int64 `json:"last_success"`
|
||||
LastFailure int64 `json:"last_failure"`
|
||||
SuccessCount int64 `json:"success_count"`
|
||||
FailureCount int64 `json:"failure_count"`
|
||||
}
|
||||
|
||||
var (
|
||||
providerPriorityDB *bolt.DB
|
||||
providerPriorityDBMu sync.Mutex
|
||||
)
|
||||
|
||||
func InitProviderPriorityDB() error {
|
||||
providerPriorityDBMu.Lock()
|
||||
defer providerPriorityDBMu.Unlock()
|
||||
|
||||
if providerPriorityDB != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
appDir, err := EnsureAppDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dbPath := filepath.Join(appDir, providerPriorityDBFile)
|
||||
db, err := bolt.Open(dbPath, 0o600, &bolt.Options{Timeout: 1 * time.Second})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := db.Update(func(tx *bolt.Tx) error {
|
||||
_, err := tx.CreateBucketIfNotExists([]byte(providerPriorityBucket))
|
||||
return err
|
||||
}); err != nil {
|
||||
db.Close()
|
||||
return err
|
||||
}
|
||||
|
||||
providerPriorityDB = db
|
||||
return nil
|
||||
}
|
||||
|
||||
func CloseProviderPriorityDB() {
|
||||
providerPriorityDBMu.Lock()
|
||||
defer providerPriorityDBMu.Unlock()
|
||||
|
||||
if providerPriorityDB != nil {
|
||||
_ = providerPriorityDB.Close()
|
||||
providerPriorityDB = nil
|
||||
}
|
||||
}
|
||||
|
||||
func prioritizeProviders(service string, providers []string) []string {
|
||||
ordered := append([]string(nil), providers...)
|
||||
if len(ordered) < 2 {
|
||||
return ordered
|
||||
}
|
||||
|
||||
if err := InitProviderPriorityDB(); err != nil {
|
||||
fmt.Printf("Warning: failed to init provider priority DB: %v\n", err)
|
||||
return ordered
|
||||
}
|
||||
|
||||
serviceKey := strings.TrimSpace(strings.ToLower(service))
|
||||
entries := make(map[string]providerPriorityEntry, len(ordered))
|
||||
|
||||
if err := providerPriorityDB.View(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte(providerPriorityBucket))
|
||||
if bucket == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, provider := range ordered {
|
||||
if raw := bucket.Get([]byte(providerPriorityKey(serviceKey, provider))); len(raw) > 0 {
|
||||
var entry providerPriorityEntry
|
||||
if err := json.Unmarshal(raw, &entry); err != nil {
|
||||
return err
|
||||
}
|
||||
entries[provider] = entry
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
fmt.Printf("Warning: failed to read provider priority DB: %v\n", err)
|
||||
return ordered
|
||||
}
|
||||
|
||||
originalIndex := make(map[string]int, len(ordered))
|
||||
for idx, provider := range ordered {
|
||||
originalIndex[provider] = idx
|
||||
}
|
||||
|
||||
sort.SliceStable(ordered, func(i, j int) bool {
|
||||
left := entries[ordered[i]]
|
||||
right := entries[ordered[j]]
|
||||
|
||||
leftRank := providerOutcomeRank(left.LastOutcome)
|
||||
rightRank := providerOutcomeRank(right.LastOutcome)
|
||||
if leftRank != rightRank {
|
||||
return leftRank > rightRank
|
||||
}
|
||||
|
||||
if left.LastSuccess != right.LastSuccess {
|
||||
return left.LastSuccess > right.LastSuccess
|
||||
}
|
||||
|
||||
if left.LastAttempt != right.LastAttempt {
|
||||
return left.LastAttempt > right.LastAttempt
|
||||
}
|
||||
|
||||
return originalIndex[ordered[i]] < originalIndex[ordered[j]]
|
||||
})
|
||||
|
||||
return ordered
|
||||
}
|
||||
|
||||
func recordProviderSuccess(service string, provider string) {
|
||||
recordProviderOutcome(service, provider, true)
|
||||
}
|
||||
|
||||
func recordProviderFailure(service string, provider string) {
|
||||
recordProviderOutcome(service, provider, false)
|
||||
}
|
||||
|
||||
func recordProviderOutcome(service string, provider string, success bool) {
|
||||
if strings.TrimSpace(service) == "" || strings.TrimSpace(provider) == "" {
|
||||
return
|
||||
}
|
||||
|
||||
if err := InitProviderPriorityDB(); err != nil {
|
||||
fmt.Printf("Warning: failed to init provider priority DB: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
serviceKey := strings.TrimSpace(strings.ToLower(service))
|
||||
providerKey := providerPriorityKey(serviceKey, provider)
|
||||
now := time.Now().Unix()
|
||||
|
||||
if err := providerPriorityDB.Update(func(tx *bolt.Tx) error {
|
||||
bucket, err := tx.CreateBucketIfNotExists([]byte(providerPriorityBucket))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
entry := providerPriorityEntry{
|
||||
Service: serviceKey,
|
||||
Provider: provider,
|
||||
}
|
||||
|
||||
if raw := bucket.Get([]byte(providerKey)); len(raw) > 0 {
|
||||
if err := json.Unmarshal(raw, &entry); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
entry.LastAttempt = now
|
||||
if success {
|
||||
entry.LastOutcome = "success"
|
||||
entry.LastSuccess = now
|
||||
entry.SuccessCount++
|
||||
} else {
|
||||
entry.LastOutcome = "failure"
|
||||
entry.LastFailure = now
|
||||
entry.FailureCount++
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(entry)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return bucket.Put([]byte(providerKey), payload)
|
||||
}); err != nil {
|
||||
fmt.Printf("Warning: failed to update provider priority DB: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
func providerOutcomeRank(outcome string) int {
|
||||
switch strings.TrimSpace(strings.ToLower(outcome)) {
|
||||
case "success":
|
||||
return 2
|
||||
case "":
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func providerPriorityKey(service string, provider string) string {
|
||||
return strings.TrimSpace(strings.ToLower(service)) + "|" + strings.TrimSpace(provider)
|
||||
}
|
||||
+19
-19
@@ -4,7 +4,6 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -171,15 +170,16 @@ 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 := []string{
|
||||
standardAPIs := prioritizeProviders("qobuz", []string{
|
||||
"https://dab.yeet.su/api/stream?trackId=",
|
||||
"https://dabmusic.xyz/api/stream?trackId=",
|
||||
"https://qbz.afkarxyz.qzz.io/api/track/",
|
||||
}
|
||||
})
|
||||
|
||||
downloadFunc := func(qual string) (string, error) {
|
||||
type Provider struct {
|
||||
Name string
|
||||
API string
|
||||
Func func() (string, error)
|
||||
}
|
||||
|
||||
@@ -189,27 +189,26 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string, allowFal
|
||||
currentAPI := api
|
||||
providers = append(providers, Provider{
|
||||
Name: "Standard(" + currentAPI + ")",
|
||||
API: currentAPI,
|
||||
Func: func() (string, error) {
|
||||
return q.DownloadFromStandard(currentAPI, trackID, qual)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
rand.Seed(time.Now().UnixNano())
|
||||
rand.Shuffle(len(providers), func(i, j int) { providers[i], providers[j] = providers[j], providers[i] })
|
||||
|
||||
var lastErr error
|
||||
for _, p := range providers {
|
||||
|
||||
fmt.Printf("Trying Provider: %s (Quality: %s)...\n", p.Name, qual)
|
||||
|
||||
url, err := p.Func()
|
||||
if err == nil {
|
||||
fmt.Printf("✓ Success\n")
|
||||
recordProviderSuccess("qobuz", p.API)
|
||||
return url, nil
|
||||
}
|
||||
|
||||
fmt.Printf("Provider failed: %v\n", err)
|
||||
recordProviderFailure("qobuz", p.API)
|
||||
lastErr = err
|
||||
}
|
||||
return "", lastErr
|
||||
@@ -362,29 +361,29 @@ func buildQobuzFilename(title, artist, album, albumArtist, releaseDate string, t
|
||||
}
|
||||
|
||||
func (q *QobuzDownloader) DownloadTrack(spotifyID, 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, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) {
|
||||
var deezerISRC string
|
||||
var isrc string
|
||||
if spotifyID != "" {
|
||||
songlinkClient := NewSongLinkClient()
|
||||
isrc, err := songlinkClient.GetISRCDirect(spotifyID)
|
||||
linkClient := NewSongLinkClient()
|
||||
resolvedISRC, err := linkClient.GetISRCDirect(spotifyID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get ISRC: %v", err)
|
||||
}
|
||||
deezerISRC = isrc
|
||||
isrc = resolvedISRC
|
||||
} else {
|
||||
return "", fmt.Errorf("spotify ID is required for Qobuz download")
|
||||
}
|
||||
|
||||
return q.DownloadTrackWithISRC(deezerISRC, spotifyID, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyURL, allowFallback, useFirstArtistOnly, useSingleGenre, embedGenre)
|
||||
return q.DownloadTrackWithISRC(isrc, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyURL, allowFallback, useFirstArtistOnly, useSingleGenre, embedGenre)
|
||||
}
|
||||
|
||||
func (q *QobuzDownloader) DownloadTrackWithISRC(deezerISRC, spotifyID, 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, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) {
|
||||
fmt.Printf("Fetching track info for ISRC: %s\n", deezerISRC)
|
||||
func (q *QobuzDownloader) DownloadTrackWithISRC(isrc, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) {
|
||||
fmt.Printf("Fetching track info for ISRC: %s\n", isrc)
|
||||
|
||||
metaChan := make(chan Metadata, 1)
|
||||
if embedGenre && deezerISRC != "" {
|
||||
if embedGenre && isrc != "" {
|
||||
go func() {
|
||||
fmt.Println("Fetching MusicBrainz metadata...")
|
||||
if fetchedMeta, err := FetchMusicBrainzMetadata(deezerISRC, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useSingleGenre, embedGenre); err == nil {
|
||||
if fetchedMeta, err := FetchMusicBrainzMetadata(isrc, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useSingleGenre, embedGenre); err == nil {
|
||||
fmt.Println("✓ MusicBrainz metadata fetched")
|
||||
metaChan <- fetchedMeta
|
||||
} else {
|
||||
@@ -402,7 +401,7 @@ func (q *QobuzDownloader) DownloadTrackWithISRC(deezerISRC, spotifyID, outputDir
|
||||
}
|
||||
}
|
||||
|
||||
track, err := q.searchByISRC(deezerISRC)
|
||||
track, err := q.searchByISRC(isrc)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -477,7 +476,7 @@ func (q *QobuzDownloader) DownloadTrackWithISRC(deezerISRC, spotifyID, outputDir
|
||||
}
|
||||
|
||||
var mbMeta Metadata
|
||||
if deezerISRC != "" {
|
||||
if isrc != "" {
|
||||
mbMeta = <-metaChan
|
||||
}
|
||||
|
||||
@@ -499,10 +498,11 @@ func (q *QobuzDownloader) DownloadTrackWithISRC(deezerISRC, spotifyID, outputDir
|
||||
DiscNumber: spotifyDiscNumber,
|
||||
TotalDiscs: spotifyTotalDiscs,
|
||||
URL: spotifyURL,
|
||||
Comment: spotifyURL,
|
||||
Copyright: spotifyCopyright,
|
||||
Publisher: spotifyPublisher,
|
||||
Description: "https://github.com/afkarxyz/SpotiFLAC",
|
||||
ISRC: deezerISRC,
|
||||
ISRC: isrc,
|
||||
Genre: mbMeta.Genre,
|
||||
}
|
||||
|
||||
|
||||
+11
-495
@@ -2,12 +2,9 @@ package backend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
@@ -17,10 +14,7 @@ import (
|
||||
const songLinkUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"
|
||||
|
||||
var (
|
||||
errSongLinkRateLimited = errors.New("song.link rate limited")
|
||||
isrcPattern = regexp.MustCompile(`\b([A-Z]{2}[A-Z0-9]{3}\d{7})\b`)
|
||||
csrfTokenPattern = regexp.MustCompile(`name=["']csrfmiddlewaretoken["'][^>]*value=["']([^"']+)["']`)
|
||||
songstatsScriptPattern = regexp.MustCompile(`(?is)<script[^>]+type=["']application/ld\+json["'][^>]*>(.*?)</script>`)
|
||||
amazonAlbumTrackPath = regexp.MustCompile(`/albums/[A-Z0-9]{10}/(B[0-9A-Z]{9})`)
|
||||
amazonTrackPath = regexp.MustCompile(`/tracks/(B[0-9A-Z]{9})`)
|
||||
)
|
||||
@@ -53,13 +47,6 @@ type songLinkAPIResponse struct {
|
||||
} `json:"linksByPlatform"`
|
||||
}
|
||||
|
||||
type resolvedTrackLinks struct {
|
||||
TidalURL string
|
||||
AmazonURL string
|
||||
DeezerURL string
|
||||
ISRC string
|
||||
}
|
||||
|
||||
func NewSongLinkClient() *SongLinkClient {
|
||||
return &SongLinkClient{
|
||||
client: &http.Client{
|
||||
@@ -113,8 +100,8 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string) (*TrackAv
|
||||
}
|
||||
|
||||
if isrc == "" && availability.DeezerURL != "" {
|
||||
if deezerISRC, deezerErr := getDeezerISRC(availability.DeezerURL); deezerErr == nil {
|
||||
isrc = deezerISRC
|
||||
if resolvedISRC, deezerErr := getDeezerISRC(availability.DeezerURL); deezerErr == nil {
|
||||
isrc = resolvedISRC
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,7 +132,11 @@ func checkQobuzAvailability(isrc string) bool {
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
appID := "798273057"
|
||||
|
||||
searchURL := fmt.Sprintf("https://www.qobuz.com/api.json/0.2/track/search?query=%s&limit=1&app_id=%s", isrc, appID)
|
||||
searchURL := fmt.Sprintf(
|
||||
"https://www.qobuz.com/api.json/0.2/track/search?query=%s&limit=1&app_id=%s",
|
||||
url.QueryEscape(strings.TrimSpace(isrc)),
|
||||
appID,
|
||||
)
|
||||
|
||||
resp, err := client.Get(searchURL)
|
||||
if err != nil {
|
||||
@@ -153,7 +144,7 @@ func checkQobuzAvailability(isrc string) bool {
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -222,7 +213,7 @@ func getDeezerISRC(deezerURL string) (string, error) {
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("Deezer API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
@@ -277,84 +268,13 @@ func (s *SongLinkClient) GetISRCDirect(spotifyID string) (string, error) {
|
||||
return s.lookupSpotifyISRC(spotifyID)
|
||||
}
|
||||
|
||||
func (s *SongLinkClient) resolveSpotifyTrackLinks(spotifyTrackID string, region string) (*resolvedTrackLinks, error) {
|
||||
links := &resolvedTrackLinks{}
|
||||
var attempts []string
|
||||
|
||||
spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID)
|
||||
|
||||
fmt.Println("Getting streaming URLs from song.link...")
|
||||
resp, err := s.fetchSongLinkLinksByURL(spotifyURL, region)
|
||||
if err == nil {
|
||||
mergeSongLinkResponse(links, resp)
|
||||
if links.DeezerURL != "" && links.ISRC == "" {
|
||||
if isrc, deezerErr := getDeezerISRC(links.DeezerURL); deezerErr == nil {
|
||||
links.ISRC = isrc
|
||||
}
|
||||
}
|
||||
if hasAnySongLinkData(links) {
|
||||
return links, nil
|
||||
}
|
||||
attempts = append(attempts, "song.link spotify: no links found")
|
||||
} else {
|
||||
if errors.Is(err, errSongLinkRateLimited) {
|
||||
fmt.Println("song.link rate limited for Spotify URL, switching to fallback 1 (songstats)...")
|
||||
} else {
|
||||
fmt.Printf("song.link primary lookup failed: %v\n", err)
|
||||
}
|
||||
attempts = append(attempts, fmt.Sprintf("song.link spotify: %v", err))
|
||||
}
|
||||
|
||||
isrc, lookupErr := s.lookupSpotifyISRC(spotifyTrackID)
|
||||
if lookupErr != nil {
|
||||
attempts = append(attempts, fmt.Sprintf("isrc lookup: %v", lookupErr))
|
||||
} else {
|
||||
links.ISRC = isrc
|
||||
}
|
||||
|
||||
if links.ISRC != "" {
|
||||
fmt.Printf("Fallback 1: fetching Songstats links for ISRC %s\n", links.ISRC)
|
||||
if songstatsErr := s.populateLinksFromSongstats(links, links.ISRC); songstatsErr != nil {
|
||||
attempts = append(attempts, fmt.Sprintf("songstats: %v", songstatsErr))
|
||||
} else if links.TidalURL != "" && links.AmazonURL != "" {
|
||||
return links, nil
|
||||
}
|
||||
|
||||
fmt.Printf("Fallback 2: resolving Deezer track from ISRC %s\n", links.ISRC)
|
||||
deezerURL, deezerErr := s.lookupDeezerTrackURLByISRC(links.ISRC)
|
||||
if deezerErr != nil {
|
||||
attempts = append(attempts, fmt.Sprintf("deezer isrc: %v", deezerErr))
|
||||
} else {
|
||||
if links.DeezerURL == "" {
|
||||
links.DeezerURL = deezerURL
|
||||
}
|
||||
deezerResp, deezerSongLinkErr := s.fetchSongLinkLinksByURL(deezerURL, region)
|
||||
if deezerSongLinkErr != nil {
|
||||
attempts = append(attempts, fmt.Sprintf("song.link deezer: %v", deezerSongLinkErr))
|
||||
} else {
|
||||
mergeSongLinkResponse(links, deezerResp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if hasAnySongLinkData(links) {
|
||||
return links, nil
|
||||
}
|
||||
|
||||
if len(attempts) == 0 {
|
||||
attempts = append(attempts, "no streaming URLs found")
|
||||
}
|
||||
|
||||
return links, errors.New(strings.Join(attempts, " | "))
|
||||
}
|
||||
|
||||
func (s *SongLinkClient) fetchSongLinkLinksByURL(rawURL string, region string) (*songLinkAPIResponse, error) {
|
||||
apiURL := fmt.Sprintf("https://api.song.link/v1-alpha.1/links?url=%s", url.QueryEscape(rawURL))
|
||||
if region != "" {
|
||||
apiURL += fmt.Sprintf("&userCountry=%s", url.QueryEscape(region))
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", apiURL, nil)
|
||||
req, err := http.NewRequest(http.MethodGet, apiURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
@@ -366,9 +286,6 @@ func (s *SongLinkClient) fetchSongLinkLinksByURL(rawURL string, region string) (
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusTooManyRequests {
|
||||
return nil, errSongLinkRateLimited
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyPreview, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
|
||||
return nil, fmt.Errorf("song.link returned status %d (%s)", resp.StatusCode, strings.TrimSpace(string(bodyPreview)))
|
||||
@@ -394,319 +311,10 @@ func (s *SongLinkClient) fetchSongLinkLinksByURL(rawURL string, region string) (
|
||||
return &parsed, nil
|
||||
}
|
||||
|
||||
func (s *SongLinkClient) lookupSpotifyISRC(spotifyTrackID string) (string, error) {
|
||||
spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID)
|
||||
|
||||
providers := []struct {
|
||||
name string
|
||||
fn func(string) (string, error)
|
||||
}{
|
||||
{name: "isrcfinder", fn: s.lookupISRCViaISRCFinder},
|
||||
{name: "phpstack", fn: lookupISRCViaPHPStack},
|
||||
{name: "findmyisrc", fn: lookupISRCViaFindMyISRC},
|
||||
{name: "mixvibe", fn: lookupISRCViaMixvibe},
|
||||
}
|
||||
|
||||
var errorsList []string
|
||||
for _, provider := range providers {
|
||||
fmt.Printf("Trying ISRC provider: %s\n", provider.name)
|
||||
isrc, err := provider.fn(spotifyURL)
|
||||
if err == nil && isrc != "" {
|
||||
fmt.Printf("Found ISRC via %s: %s\n", provider.name, isrc)
|
||||
return isrc, nil
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
errorsList = append(errorsList, fmt.Sprintf("%s: %v", provider.name, err))
|
||||
} else {
|
||||
errorsList = append(errorsList, fmt.Sprintf("%s: no ISRC found", provider.name))
|
||||
}
|
||||
}
|
||||
|
||||
return "", errors.New(strings.Join(errorsList, " | "))
|
||||
}
|
||||
|
||||
func (s *SongLinkClient) lookupISRCViaISRCFinder(spotifyURL string) (string, error) {
|
||||
jar, err := cookiejar.New(nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create cookie jar: %w", err)
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 20 * time.Second,
|
||||
Jar: jar,
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", "https://www.isrcfinder.com/", nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create GET request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", songLinkUserAgent)
|
||||
req.Header.Set("Referer", "https://www.isrcfinder.com/")
|
||||
req.Header.Set("Origin", "https://www.isrcfinder.com")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to load isrcfinder: %w", err)
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read isrcfinder response: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("isrcfinder returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
token := extractCSRFToken(string(body))
|
||||
if token == "" {
|
||||
if parsedURL, parseErr := url.Parse("https://www.isrcfinder.com/"); parseErr == nil {
|
||||
for _, cookie := range jar.Cookies(parsedURL) {
|
||||
if cookie.Name == "csrftoken" {
|
||||
token = cookie.Value
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if token == "" {
|
||||
return "", fmt.Errorf("csrf token not found")
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("csrfmiddlewaretoken", token)
|
||||
form.Set("URI", spotifyURL)
|
||||
|
||||
postReq, err := http.NewRequest("POST", "https://www.isrcfinder.com/", strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create POST request: %w", err)
|
||||
}
|
||||
postReq.Header.Set("User-Agent", songLinkUserAgent)
|
||||
postReq.Header.Set("Referer", "https://www.isrcfinder.com/")
|
||||
postReq.Header.Set("Origin", "https://www.isrcfinder.com")
|
||||
postReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
postResp, err := client.Do(postReq)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to submit isrcfinder form: %w", err)
|
||||
}
|
||||
postBody, err := io.ReadAll(postResp.Body)
|
||||
postResp.Body.Close()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read isrcfinder POST response: %w", err)
|
||||
}
|
||||
if postResp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("isrcfinder POST returned status %d", postResp.StatusCode)
|
||||
}
|
||||
|
||||
isrc := firstISRCMatch(string(postBody))
|
||||
if isrc == "" {
|
||||
return "", fmt.Errorf("ISRC not found in isrcfinder response")
|
||||
}
|
||||
|
||||
return isrc, nil
|
||||
}
|
||||
|
||||
func lookupISRCViaPHPStack(spotifyURL string) (string, error) {
|
||||
apiURL := fmt.Sprintf(
|
||||
"https://phpstack-822472-6184058.cloudwaysapps.com/api/spotify.php?q=%s",
|
||||
url.QueryEscape(spotifyURL),
|
||||
)
|
||||
|
||||
req, err := http.NewRequest("GET", apiURL, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", songLinkUserAgent)
|
||||
req.Header.Set("Referer", "https://phpstack-822472-6184058.cloudwaysapps.com/?")
|
||||
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("phpstack request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("phpstack returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
ISRC string `json:"isrc"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||
return "", fmt.Errorf("failed to decode phpstack response: %w", err)
|
||||
}
|
||||
if payload.ISRC == "" {
|
||||
return "", fmt.Errorf("ISRC missing in phpstack response")
|
||||
}
|
||||
|
||||
return strings.ToUpper(strings.TrimSpace(payload.ISRC)), nil
|
||||
}
|
||||
|
||||
func lookupISRCViaFindMyISRC(spotifyURL string) (string, error) {
|
||||
payloadBytes, err := json.Marshal(map[string][]string{
|
||||
"uris": []string{spotifyURL},
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to encode payload: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(
|
||||
"POST",
|
||||
"https://lxtzsnh4l3.execute-api.ap-southeast-2.amazonaws.com/prod/find-my-isrc",
|
||||
strings.NewReader(string(payloadBytes)),
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", songLinkUserAgent)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Origin", "https://www.findmyisrc.com")
|
||||
req.Header.Set("Referer", "https://www.findmyisrc.com/")
|
||||
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("findmyisrc request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("findmyisrc returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var payload []struct {
|
||||
Data struct {
|
||||
ISRC string `json:"isrc"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||
return "", fmt.Errorf("failed to decode findmyisrc response: %w", err)
|
||||
}
|
||||
|
||||
for _, item := range payload {
|
||||
if item.Data.ISRC != "" {
|
||||
return strings.ToUpper(strings.TrimSpace(item.Data.ISRC)), nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("ISRC missing in findmyisrc response")
|
||||
}
|
||||
|
||||
func lookupISRCViaMixvibe(spotifyURL string) (string, error) {
|
||||
payloadBytes, err := json.Marshal(map[string]string{
|
||||
"url": spotifyURL,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to encode payload: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(
|
||||
"POST",
|
||||
"https://tools.mixviberecords.com/api/find-isrc",
|
||||
strings.NewReader(string(payloadBytes)),
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", songLinkUserAgent)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Origin", "https://tools.mixviberecords.com")
|
||||
req.Header.Set("Referer", "https://tools.mixviberecords.com/isrc-finder")
|
||||
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("mixvibe request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read mixvibe response: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("mixvibe returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var payload interface{}
|
||||
if err := json.Unmarshal(body, &payload); err == nil {
|
||||
if isrc := findISRCInValue(payload); isrc != "" {
|
||||
return isrc, nil
|
||||
}
|
||||
}
|
||||
|
||||
if isrc := firstISRCMatch(string(body)); isrc != "" {
|
||||
return isrc, nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("ISRC missing in mixvibe response")
|
||||
}
|
||||
|
||||
func (s *SongLinkClient) populateLinksFromSongstats(links *resolvedTrackLinks, isrc string) error {
|
||||
pageURL := fmt.Sprintf("https://songstats.com/%s?ref=ISRCFinder", strings.ToUpper(strings.TrimSpace(isrc)))
|
||||
|
||||
req, err := http.NewRequest("GET", pageURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", songLinkUserAgent)
|
||||
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch Songstats page: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("Songstats returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read Songstats response: %w", err)
|
||||
}
|
||||
|
||||
matches := songstatsScriptPattern.FindAllStringSubmatch(string(body), -1)
|
||||
if len(matches) == 0 {
|
||||
return fmt.Errorf("Songstats JSON-LD not found")
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, match := range matches {
|
||||
if len(match) < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
scriptBody := strings.TrimSpace(html.UnescapeString(match[1]))
|
||||
if scriptBody == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
var payload interface{}
|
||||
if err := json.Unmarshal([]byte(scriptBody), &payload); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
before := *links
|
||||
collectSongstatsLinks(payload, links)
|
||||
if *links != before {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
|
||||
if !found && !hasAnySongLinkData(links) {
|
||||
return fmt.Errorf("no platform links found in Songstats")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SongLinkClient) lookupDeezerTrackURLByISRC(isrc string) (string, error) {
|
||||
apiURL := fmt.Sprintf("https://api.deezer.com/track/isrc:%s", strings.ToUpper(strings.TrimSpace(isrc)))
|
||||
|
||||
req, err := http.NewRequest("GET", apiURL, nil)
|
||||
req, err := http.NewRequest(http.MethodGet, apiURL, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
@@ -762,62 +370,6 @@ func mergeSongLinkResponse(links *resolvedTrackLinks, resp *songLinkAPIResponse)
|
||||
}
|
||||
}
|
||||
|
||||
func collectSongstatsLinks(value interface{}, links *resolvedTrackLinks) {
|
||||
switch typed := value.(type) {
|
||||
case map[string]interface{}:
|
||||
if sameAs, ok := typed["sameAs"]; ok {
|
||||
applySongstatsSameAs(sameAs, links)
|
||||
}
|
||||
for _, nested := range typed {
|
||||
collectSongstatsLinks(nested, links)
|
||||
}
|
||||
case []interface{}:
|
||||
for _, nested := range typed {
|
||||
collectSongstatsLinks(nested, links)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func applySongstatsSameAs(value interface{}, links *resolvedTrackLinks) {
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
assignSongstatsLink(typed, links)
|
||||
case []interface{}:
|
||||
for _, item := range typed {
|
||||
if link, ok := item.(string); ok {
|
||||
assignSongstatsLink(link, links)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assignSongstatsLink(rawLink string, links *resolvedTrackLinks) {
|
||||
link := strings.TrimSpace(rawLink)
|
||||
if link == "" {
|
||||
return
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.Contains(link, "listen.tidal.com/track"):
|
||||
if links.TidalURL == "" {
|
||||
links.TidalURL = link
|
||||
fmt.Println("✓ Tidal URL found via Songstats")
|
||||
}
|
||||
case strings.Contains(link, "music.amazon.com"):
|
||||
if links.AmazonURL == "" {
|
||||
if normalized := normalizeAmazonMusicURL(link); normalized != "" {
|
||||
links.AmazonURL = normalized
|
||||
fmt.Println("✓ Amazon URL found via Songstats")
|
||||
}
|
||||
}
|
||||
case strings.Contains(link, "deezer.com"):
|
||||
if links.DeezerURL == "" {
|
||||
links.DeezerURL = normalizeDeezerTrackURL(link)
|
||||
fmt.Println("✓ Deezer URL found via Songstats")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeAmazonMusicURL(rawURL string) string {
|
||||
amazonURL := strings.TrimSpace(rawURL)
|
||||
if amazonURL == "" {
|
||||
@@ -880,14 +432,6 @@ func hasAnySongLinkData(links *resolvedTrackLinks) bool {
|
||||
return links.TidalURL != "" || links.AmazonURL != "" || links.DeezerURL != ""
|
||||
}
|
||||
|
||||
func extractCSRFToken(body string) string {
|
||||
match := csrfTokenPattern.FindStringSubmatch(body)
|
||||
if len(match) < 2 {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(match[1])
|
||||
}
|
||||
|
||||
func firstISRCMatch(body string) string {
|
||||
match := isrcPattern.FindStringSubmatch(strings.ToUpper(body))
|
||||
if len(match) < 2 {
|
||||
@@ -895,31 +439,3 @@ func firstISRCMatch(body string) string {
|
||||
}
|
||||
return strings.TrimSpace(match[1])
|
||||
}
|
||||
|
||||
func findISRCInValue(value interface{}) string {
|
||||
switch typed := value.(type) {
|
||||
case map[string]interface{}:
|
||||
for key, nested := range typed {
|
||||
if strings.EqualFold(key, "isrc") {
|
||||
if isrc, ok := nested.(string); ok {
|
||||
if normalized := firstISRCMatch(isrc); normalized != "" {
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
}
|
||||
if isrc := findISRCInValue(nested); isrc != "" {
|
||||
return isrc
|
||||
}
|
||||
}
|
||||
case []interface{}:
|
||||
for _, nested := range typed {
|
||||
if isrc := findISRCInValue(nested); isrc != "" {
|
||||
return isrc
|
||||
}
|
||||
}
|
||||
case string:
|
||||
return firstISRCMatch(typed)
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var songstatsScriptPattern = regexp.MustCompile(`(?is)<script[^>]+type=["']application/ld\+json["'][^>]*>(.*?)</script>`)
|
||||
|
||||
func (s *SongLinkClient) populateLinksFromSongstats(links *resolvedTrackLinks, isrc string) error {
|
||||
pageURL := fmt.Sprintf("https://songstats.com/%s?ref=ISRCFinder", strings.ToUpper(strings.TrimSpace(isrc)))
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, pageURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", songLinkUserAgent)
|
||||
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch Songstats page: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("Songstats returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read Songstats response: %w", err)
|
||||
}
|
||||
|
||||
matches := songstatsScriptPattern.FindAllStringSubmatch(string(body), -1)
|
||||
if len(matches) == 0 {
|
||||
return fmt.Errorf("Songstats JSON-LD not found")
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, match := range matches {
|
||||
if len(match) < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
scriptBody := strings.TrimSpace(html.UnescapeString(match[1]))
|
||||
if scriptBody == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
var payload interface{}
|
||||
if err := json.Unmarshal([]byte(scriptBody), &payload); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
before := *links
|
||||
collectSongstatsLinks(payload, links)
|
||||
if *links != before {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
|
||||
if !found && !hasAnySongLinkData(links) {
|
||||
return fmt.Errorf("no platform links found in Songstats")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func collectSongstatsLinks(value interface{}, links *resolvedTrackLinks) {
|
||||
switch typed := value.(type) {
|
||||
case map[string]interface{}:
|
||||
if sameAs, ok := typed["sameAs"]; ok {
|
||||
applySongstatsSameAs(sameAs, links)
|
||||
}
|
||||
for _, nested := range typed {
|
||||
collectSongstatsLinks(nested, links)
|
||||
}
|
||||
case []interface{}:
|
||||
for _, nested := range typed {
|
||||
collectSongstatsLinks(nested, links)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func applySongstatsSameAs(value interface{}, links *resolvedTrackLinks) {
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
assignSongstatsLink(typed, links)
|
||||
case []interface{}:
|
||||
for _, item := range typed {
|
||||
if link, ok := item.(string); ok {
|
||||
assignSongstatsLink(link, links)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assignSongstatsLink(rawLink string, links *resolvedTrackLinks) {
|
||||
link := strings.TrimSpace(rawLink)
|
||||
if link == "" {
|
||||
return
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.Contains(link, "listen.tidal.com/track"):
|
||||
if links.TidalURL == "" {
|
||||
links.TidalURL = link
|
||||
fmt.Println("✓ Tidal URL found via Songstats")
|
||||
}
|
||||
case strings.Contains(link, "music.amazon.com"):
|
||||
if links.AmazonURL == "" {
|
||||
if normalized := normalizeAmazonMusicURL(link); normalized != "" {
|
||||
links.AmazonURL = normalized
|
||||
fmt.Println("✓ Amazon URL found via Songstats")
|
||||
}
|
||||
}
|
||||
case strings.Contains(link, "deezer.com"):
|
||||
if links.DeezerURL == "" {
|
||||
links.DeezerURL = normalizeDeezerTrackURL(link)
|
||||
fmt.Println("✓ Deezer URL found via Songstats")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
soundplateSpotifyAPIURL = "https://phpstack-822472-6184058.cloudwaysapps.com/api/spotify.php"
|
||||
soundplateRefererURL = "https://phpstack-822472-6184058.cloudwaysapps.com/?"
|
||||
soundplateUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"
|
||||
)
|
||||
|
||||
type soundplateSpotifyResponse struct {
|
||||
Name string `json:"name"`
|
||||
Artist string `json:"artist"`
|
||||
Album string `json:"album"`
|
||||
AlbumType string `json:"album_type"`
|
||||
ArtworkURL string `json:"artwork_url"`
|
||||
ISRC string `json:"isrc"`
|
||||
Year string `json:"year"`
|
||||
SpotifyURL string `json:"spotify_url"`
|
||||
}
|
||||
|
||||
func (s *SongLinkClient) lookupSpotifyISRCViaSoundplate(spotifyTrackID string) (string, string, error) {
|
||||
normalizedTrackID, err := extractSpotifyTrackID(spotifyTrackID)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
spotifyTrackURL := fmt.Sprintf("https://open.spotify.com/track/%s", normalizedTrackID)
|
||||
query := url.Values{}
|
||||
query.Set("q", spotifyTrackURL)
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, soundplateSpotifyAPIURL+"?"+query.Encode(), nil)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to create Soundplate ISRC request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", soundplateUserAgent)
|
||||
req.Header.Set("Accept", "*/*")
|
||||
req.Header.Set("Referer", soundplateRefererURL)
|
||||
req.Header.Set("Accept-Language", "en-US,en;q=0.9,id;q=0.8")
|
||||
req.Header.Set("Sec-CH-UA", "\"Chromium\";v=\"146\", \"Not-A.Brand\";v=\"24\", \"Google Chrome\";v=\"146\"")
|
||||
req.Header.Set("Sec-CH-UA-Mobile", "?0")
|
||||
req.Header.Set("Sec-CH-UA-Platform", "\"Windows\"")
|
||||
req.Header.Set("Sec-Fetch-Dest", "empty")
|
||||
req.Header.Set("Sec-Fetch-Mode", "cors")
|
||||
req.Header.Set("Sec-Fetch-Site", "same-origin")
|
||||
req.Header.Set("Priority", "u=1, i")
|
||||
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("Soundplate ISRC request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to read Soundplate ISRC response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyPreview := strings.TrimSpace(string(body))
|
||||
if len(bodyPreview) > 256 {
|
||||
bodyPreview = bodyPreview[:256]
|
||||
}
|
||||
return "", "", fmt.Errorf("Soundplate ISRC returned status %d (%s)", resp.StatusCode, bodyPreview)
|
||||
}
|
||||
|
||||
var payload soundplateSpotifyResponse
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
return "", "", fmt.Errorf("failed to decode Soundplate ISRC response: %w", err)
|
||||
}
|
||||
|
||||
isrc := firstISRCMatch(payload.ISRC)
|
||||
if isrc == "" {
|
||||
isrc = firstISRCMatch(string(body))
|
||||
}
|
||||
if isrc == "" {
|
||||
return "", "", fmt.Errorf("ISRC missing in Soundplate response")
|
||||
}
|
||||
|
||||
resolvedTrackID := ""
|
||||
if payload.SpotifyURL != "" {
|
||||
if trackID, err := extractSpotifyTrackID(payload.SpotifyURL); err == nil {
|
||||
resolvedTrackID = trackID
|
||||
}
|
||||
}
|
||||
|
||||
return isrc, resolvedTrackID, nil
|
||||
}
|
||||
+12
-7
@@ -6,7 +6,6 @@ import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -86,7 +85,7 @@ func (t *TidalDownloader) GetAvailableAPIs() ([]string, error) {
|
||||
"https://monochrome-api.samidy.com",
|
||||
"https://tidal.kinoplus.online",
|
||||
}
|
||||
return apis, nil
|
||||
return prioritizeProviders("tidal", apis), nil
|
||||
}
|
||||
|
||||
func (t *TidalDownloader) GetTidalURLFromSpotify(spotifyTrackID string) (string, error) {
|
||||
@@ -552,6 +551,7 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo
|
||||
DiscNumber: spotifyDiscNumber,
|
||||
TotalDiscs: spotifyTotalDiscs,
|
||||
URL: spotifyURL,
|
||||
Comment: spotifyURL,
|
||||
Copyright: spotifyCopyright,
|
||||
Publisher: spotifyPublisher,
|
||||
Description: "https://github.com/afkarxyz/SpotiFLAC",
|
||||
@@ -711,6 +711,7 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality
|
||||
DiscNumber: spotifyDiscNumber,
|
||||
TotalDiscs: spotifyTotalDiscs,
|
||||
URL: spotifyURL,
|
||||
Comment: spotifyURL,
|
||||
Copyright: spotifyCopyright,
|
||||
Publisher: spotifyPublisher,
|
||||
Description: "https://github.com/afkarxyz/SpotiFLAC",
|
||||
@@ -906,15 +907,13 @@ func getDownloadURLRotated(apis []string, trackID int64, quality string) (string
|
||||
return "", "", fmt.Errorf("no APIs available")
|
||||
}
|
||||
|
||||
rand.Seed(time.Now().UnixNano())
|
||||
rand.Shuffle(len(apis), func(i, j int) { apis[i], apis[j] = apis[j], apis[i] })
|
||||
|
||||
fmt.Printf("Rotating through %d APIs...\n", len(apis))
|
||||
orderedAPIs := prioritizeProviders("tidal", apis)
|
||||
fmt.Printf("Trying %d prioritized APIs...\n", len(orderedAPIs))
|
||||
|
||||
var lastError error
|
||||
var errors []string
|
||||
|
||||
for _, apiURL := range apis {
|
||||
for _, apiURL := range orderedAPIs {
|
||||
fmt.Printf("Trying API: %s\n", apiURL)
|
||||
|
||||
client := &http.Client{
|
||||
@@ -925,6 +924,7 @@ func getDownloadURLRotated(apis []string, trackID int64, quality string) (string
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
lastError = err
|
||||
recordProviderFailure("tidal", apiURL)
|
||||
errors = append(errors, fmt.Sprintf("%s: %v", apiURL, err))
|
||||
continue
|
||||
}
|
||||
@@ -932,6 +932,7 @@ func getDownloadURLRotated(apis []string, trackID int64, quality string) (string
|
||||
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))
|
||||
continue
|
||||
}
|
||||
@@ -940,6 +941,7 @@ func getDownloadURLRotated(apis []string, trackID int64, quality string) (string
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
lastError = err
|
||||
recordProviderFailure("tidal", apiURL)
|
||||
errors = append(errors, fmt.Sprintf("%s: read body failed", apiURL))
|
||||
continue
|
||||
}
|
||||
@@ -947,6 +949,7 @@ func getDownloadURLRotated(apis []string, trackID int64, quality string) (string
|
||||
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
|
||||
}
|
||||
|
||||
@@ -955,12 +958,14 @@ func getDownloadURLRotated(apis []string, trackID int64, quality string) (string
|
||||
for _, item := range v1Responses {
|
||||
if item.OriginalTrackURL != "" {
|
||||
fmt.Printf("✓ Success with: %s\n", apiURL)
|
||||
recordProviderSuccess("tidal", apiURL)
|
||||
return apiURL, item.OriginalTrackURL, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lastError = fmt.Errorf("no download URL or manifest in response")
|
||||
recordProviderFailure("tidal", apiURL)
|
||||
errors = append(errors, fmt.Sprintf("%s: %v", apiURL, lastError))
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +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 { useDownloadQueueDialog } from "@/hooks/useDownloadQueueDialog";
|
||||
import { useDownloadProgress } from "@/hooks/useDownloadProgress";
|
||||
const HISTORY_KEY = "spotiflac_fetch_history";
|
||||
@@ -179,6 +180,7 @@ function App() {
|
||||
};
|
||||
mediaQuery.addEventListener("change", handleChange);
|
||||
checkForUpdates();
|
||||
ensureApiStatusCheckStarted();
|
||||
loadHistory();
|
||||
const handleScroll = () => {
|
||||
setShowScrollTop(window.scrollY > 300);
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 345 B |
@@ -15,13 +15,20 @@ import KofiLogo from "@/assets/ko-fi.gif";
|
||||
import KofiSvg from "@/assets/kofi_symbol.svg";
|
||||
import UsdtBarcode from "@/assets/usdt.jpg";
|
||||
import { langColors } from "@/assets/github-lang-colors";
|
||||
const browserExtensionItems = [
|
||||
{ icon: AudioTTSProIcon, label: "AudioTTS Pro", alt: "AudioTTS Pro" },
|
||||
{ icon: ChatGPTTTSIcon, label: "ChatGPT TTS", alt: "ChatGPT TTS" },
|
||||
{ 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";
|
||||
export function AboutPage() {
|
||||
const [activeTab, setActiveTab] = useState<"projects" | "support">("projects");
|
||||
const [repoStats, setRepoStats] = useState<Record<string, any>>({});
|
||||
const [copiedUsdt, setCopiedUsdt] = useState(false);
|
||||
useEffect(() => {
|
||||
const fetchRepoStats = async () => {
|
||||
const CACHE_KEY = "github_repo_stats_v3";
|
||||
const CACHE_KEY = "github_repo_stats_v4";
|
||||
const CACHE_DURATION = 1000 * 60 * 60;
|
||||
const cached = localStorage.getItem(CACHE_KEY);
|
||||
if (cached) {
|
||||
@@ -63,8 +70,10 @@ export function AboutPage() {
|
||||
let totalDownloads = 0;
|
||||
let latestDownloads = 0;
|
||||
let latestVersion = "";
|
||||
let latestReleaseAt = "";
|
||||
if (releases.length > 0) {
|
||||
latestVersion = releases[0].tag_name || "";
|
||||
latestReleaseAt = releases[0].published_at || releases[0].created_at || "";
|
||||
latestDownloads =
|
||||
releases[0].assets?.reduce((sum: number, asset: any) => sum + (asset.download_count || 0), 0) || 0;
|
||||
totalDownloads = releases.reduce((sum: number, release: any) => {
|
||||
@@ -84,6 +93,7 @@ export function AboutPage() {
|
||||
totalDownloads,
|
||||
latestDownloads,
|
||||
latestVersion,
|
||||
latestReleaseAt,
|
||||
languages: topLangs,
|
||||
};
|
||||
}
|
||||
@@ -121,6 +131,39 @@ export function AboutPage() {
|
||||
const diffYears = Math.floor(diffMonths / 12);
|
||||
return `${diffYears}y`;
|
||||
};
|
||||
const formatReleaseTimeAgo = (dateString: string): string => {
|
||||
if (!dateString) {
|
||||
return "";
|
||||
}
|
||||
const now = Date.now();
|
||||
const releasedAt = new Date(dateString).getTime();
|
||||
if (Number.isNaN(releasedAt)) {
|
||||
return "";
|
||||
}
|
||||
const diffMs = Math.max(0, now - releasedAt);
|
||||
const totalMinutes = Math.floor(diffMs / (1000 * 60));
|
||||
const totalHours = Math.floor(totalMinutes / 60);
|
||||
const totalDays = Math.floor(totalHours / 24);
|
||||
const totalMonths = Math.floor(totalDays / 30);
|
||||
const totalYears = Math.floor(totalMonths / 12);
|
||||
if (totalYears > 0) {
|
||||
const remainingMonths = totalMonths % 12;
|
||||
return remainingMonths > 0 ? `${totalYears}y ${remainingMonths}m ago` : `${totalYears}y ago`;
|
||||
}
|
||||
if (totalMonths > 0) {
|
||||
const remainingDays = totalDays % 30;
|
||||
return remainingDays > 0 ? `${totalMonths}m ${remainingDays}d ago` : `${totalMonths}m ago`;
|
||||
}
|
||||
if (totalDays > 0) {
|
||||
const remainingHours = totalHours % 24;
|
||||
return remainingHours > 0 ? `${totalDays}d ${remainingHours}h ago` : `${totalDays}d ago`;
|
||||
}
|
||||
if (totalHours > 0) {
|
||||
const remainingMinutes = totalMinutes % 60;
|
||||
return `${totalHours}h ${remainingMinutes}m ago`;
|
||||
}
|
||||
return `${totalMinutes}m ago`;
|
||||
};
|
||||
const formatNumber = (num: number): string => {
|
||||
if (num >= 1000) {
|
||||
return num.toLocaleString();
|
||||
@@ -154,39 +197,73 @@ export function AboutPage() {
|
||||
|
||||
{activeTab === "projects" && (<div className="p-1 pr-2">
|
||||
<div className="grid gap-2 grid-cols-4">
|
||||
<div className="flex flex-col gap-2 h-full">
|
||||
<Card className="hover:bg-muted/50 hover:border-primary/50 transition-colors cursor-pointer flex-1" onClick={() => openExternal("https://exyezed.qzz.io/")}>
|
||||
<Card className={`gap-2 ${projectCardClass}`} onClick={() => openExternal("https://github.com/spotiverse/SpotiFLAC-Next")}>
|
||||
<CardHeader>
|
||||
<CardTitle>Browser Extensions & Scripts</CardTitle>
|
||||
<CardDescription className="flex gap-3 pt-2">
|
||||
<img src={AudioTTSProIcon} className="h-8 w-8 rounded-md shadow-sm" alt="AudioTTS Pro"/>
|
||||
<img src={ChatGPTTTSIcon} className="h-8 w-8 rounded-md shadow-sm" alt="ChatGPT TTS"/>
|
||||
<img src={XIcon} className="h-8 w-8 rounded-md shadow-sm" alt="X"/>
|
||||
<img src={XProIcon} className="h-8 w-8 rounded-md shadow-sm" alt="X Pro"/>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<Card className="hover:bg-muted/50 hover:border-primary/50 transition-colors cursor-pointer flex-1" onClick={() => openExternal("https://spotubedl.com/")}>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<img src={SpotubeDLIcon} className="h-5 w-5" alt="SpotubeDL"/>{" "}
|
||||
SpotubeDL
|
||||
<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">
|
||||
{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>)}
|
||||
</div>
|
||||
</div>
|
||||
<CardTitle className="leading-tight">
|
||||
SpotiFLAC Next
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Download Spotify Tracks, Albums, Playlists as MP3/OGG/Opus
|
||||
with High Quality.
|
||||
{getRepoDescription("SpotiFLAC-Next")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
{repoStats["SpotiFLAC-Next"] && (<CardContent className="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",
|
||||
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["SpotiFLAC-Next"].stars)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<GitFork className="h-3.5 w-3.5"/>{" "}
|
||||
{repoStats["SpotiFLAC-Next"].forks}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3.5 w-3.5"/>{" "}
|
||||
{formatTimeAgo(repoStats["SpotiFLAC-Next"].createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<Card className="hover:bg-muted/50 hover:border-primary/50 transition-colors cursor-pointer" onClick={() => openExternal("https://github.com/afkarxyz/SpotiDownloader")}>
|
||||
<div className="rounded-md border border-sky-500/25 bg-sky-500/8 px-3 py-2">
|
||||
<div className="mb-1 flex items-center gap-1.5 text-xs font-semibold text-sky-700 dark:text-sky-300">
|
||||
<Info className="h-3.5 w-3.5"/>
|
||||
Note
|
||||
</div>
|
||||
<p className="text-xs leading-relaxed 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/afkarxyz/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>
|
||||
@@ -229,64 +306,19 @@ export function AboutPage() {
|
||||
</div>
|
||||
</CardContent>)}
|
||||
</Card>
|
||||
<Card className="gap-2 hover:bg-muted/50 hover:border-primary/50 transition-colors cursor-pointer" onClick={() => openExternal("https://github.com/spotiverse/SpotiFLAC-Next")}>
|
||||
<CardHeader>
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<img src={SpotiFLACNextIcon} className="h-6 w-6 shrink-0" alt="SpotiFLAC Next"/>
|
||||
{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>)}
|
||||
</div>
|
||||
<CardTitle className="leading-tight">
|
||||
SpotiFLAC Next
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{getRepoDescription("SpotiFLAC-Next")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
{repoStats["SpotiFLAC-Next"] && (<CardContent className="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",
|
||||
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["SpotiFLAC-Next"].stars)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<GitFork className="h-3.5 w-3.5"/>{" "}
|
||||
{repoStats["SpotiFLAC-Next"].forks}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3.5 w-3.5"/>{" "}
|
||||
{formatTimeAgo(repoStats["SpotiFLAC-Next"].createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="rounded-md border border-sky-500/25 bg-sky-500/8 px-3 py-2">
|
||||
<div className="mb-1 flex items-center gap-1.5 text-xs font-semibold text-sky-700 dark:text-sky-300">
|
||||
<Info className="h-3.5 w-3.5"/>
|
||||
Note
|
||||
</div>
|
||||
<p className="text-xs leading-relaxed text-sky-700 dark:text-sky-300">
|
||||
SpotiFLAC Next is a separate project created as a thank-you
|
||||
to everyone who has supported SpotiFLAC on Ko-fi.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>)}
|
||||
</Card>
|
||||
<Card className="hover:bg-muted/50 hover:border-primary/50 transition-colors cursor-pointer" onClick={() => openExternal("https://github.com/afkarxyz/Twitter-X-Media-Batch-Downloader")}>
|
||||
<Card className={projectCardClass} onClick={() => openExternal("https://github.com/afkarxyz/Twitter-X-Media-Batch-Downloader")}>
|
||||
<CardHeader>
|
||||
<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">
|
||||
{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>)}
|
||||
</div>
|
||||
</div>
|
||||
<CardTitle className="leading-tight">
|
||||
Twitter/X Media Batch Downloader
|
||||
</CardTitle>
|
||||
@@ -332,6 +364,33 @@ export function AboutPage() {
|
||||
</div>
|
||||
</CardContent>)}
|
||||
</Card>
|
||||
<div className="flex flex-col gap-2 h-full">
|
||||
<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">
|
||||
<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">
|
||||
{item.label}
|
||||
</span>
|
||||
</div>))}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<Card className={`${projectCardClass} flex-1`} onClick={() => openExternal("https://spotubedl.com/")}>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<img src={SpotubeDLIcon} className="h-5 w-5" alt="SpotubeDL"/>{" "}
|
||||
SpotubeDL
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Download Spotify Tracks, Albums, Playlists as MP3/OGG/Opus
|
||||
with High Quality.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>)}
|
||||
|
||||
|
||||
@@ -206,10 +206,16 @@ export function AlbumInfo({ albumInfo, trackList, searchQuery, sortBy, selectedT
|
||||
<p>Download All Separate Covers</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>)}
|
||||
{downloadedTracks.size > 0 && (<Button onClick={onOpenFolder} variant="outline">
|
||||
{downloadedTracks.size > 0 && (<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button onClick={onOpenFolder} variant="outline" size="icon">
|
||||
<FolderOpen className="h-4 w-4"/>
|
||||
Open Folder
|
||||
</Button>)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Open Folder</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>)}
|
||||
</div>
|
||||
{isDownloading && (<DownloadProgress progress={downloadProgress} currentTrack={currentDownloadInfo} onStop={onStopDownload}/>)}
|
||||
</div>
|
||||
|
||||
@@ -1,59 +1,19 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RefreshCw, CheckCircle2, XCircle, Loader2 } from "lucide-react";
|
||||
import { CheckAPIStatus } from "../../wailsjs/go/main/App";
|
||||
import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons";
|
||||
interface ApiSource {
|
||||
id: string;
|
||||
type: string;
|
||||
name: string;
|
||||
url: string;
|
||||
}
|
||||
const 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" },
|
||||
];
|
||||
import { useApiStatus } from "@/hooks/useApiStatus";
|
||||
export function ApiStatusTab() {
|
||||
const [statuses, setStatuses] = useState<Record<string, "checking" | "online" | "offline" | "idle">>({});
|
||||
const [isCheckingAll, setIsCheckingAll] = useState(false);
|
||||
const checkStatus = async (sourceId: string, apiType: string, url: string) => {
|
||||
setStatuses(prev => ({ ...prev, [sourceId]: "checking" }));
|
||||
try {
|
||||
const isOnline = await CheckAPIStatus(apiType, url);
|
||||
setStatuses(prev => ({ ...prev, [sourceId]: isOnline ? "online" : "offline" }));
|
||||
}
|
||||
catch (error) {
|
||||
setStatuses(prev => ({ ...prev, [sourceId]: "offline" }));
|
||||
}
|
||||
};
|
||||
const checkAll = async () => {
|
||||
setIsCheckingAll(true);
|
||||
const promises = SOURCES.map(s => checkStatus(s.id, s.type, s.url));
|
||||
await Promise.allSettled(promises);
|
||||
setIsCheckingAll(false);
|
||||
};
|
||||
useEffect(() => {
|
||||
checkAll();
|
||||
}, []);
|
||||
const { sources, statuses, isCheckingAll, refreshAll } = useApiStatus();
|
||||
return (<div className="space-y-6">
|
||||
<div className="flex items-center justify-end">
|
||||
<Button variant="outline" onClick={checkAll} disabled={isCheckingAll} className="gap-2">
|
||||
<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="grid grid-cols-4 gap-4">
|
||||
{SOURCES.map((source) => {
|
||||
{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">
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
@@ -610,10 +610,16 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
|
||||
<p>Download All Separate Covers</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>)}
|
||||
{downloadedTracks.size > 0 && (<Button onClick={onOpenFolder} size="sm" variant="outline">
|
||||
{downloadedTracks.size > 0 && (<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button onClick={onOpenFolder} size="icon" variant="outline">
|
||||
<FolderOpen className="h-4 w-4"/>
|
||||
Open Folder
|
||||
</Button>)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Open Folder</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>)}
|
||||
</div>
|
||||
</div>
|
||||
{isDownloading && (<DownloadProgress progress={downloadProgress} currentTrack={currentDownloadInfo} onStop={onStopDownload}/>)}
|
||||
|
||||
@@ -1,16 +1,44 @@
|
||||
import { useState, useCallback, useRef, useEffect, type ChangeEvent, type DragEvent, type CSSProperties } from "react";
|
||||
import { useState, useCallback, useRef, useEffect, type ChangeEvent, type CSSProperties, type DragEvent } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Upload, ArrowLeft, Trash2, Download } from "lucide-react";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Upload, ArrowLeft, Trash2, Download, FolderOpen, X, AlertCircle, CheckCircle2, FileMusic, ChevronDown, Play, StopCircle } from "lucide-react";
|
||||
import { AudioAnalysis } from "@/components/AudioAnalysis";
|
||||
import { SpectrumVisualization } from "@/components/SpectrumVisualization";
|
||||
import { SpectrumVisualization, createSpectrogramDataURL, type SpectrumVisualizationHandle } from "@/components/SpectrumVisualization";
|
||||
import { useAudioAnalysis } from "@/hooks/useAudioAnalysis";
|
||||
import type { AnalysisResult } from "@/types/api";
|
||||
import { loadAudioAnalysisPreferences } from "@/lib/audio-analysis-preferences";
|
||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||
import { SelectFile, SaveSpectrumImage } from "../../wailsjs/go/main/App";
|
||||
import { GetFileSizes, ListAudioFilesInDir, SaveSpectrumImage, SelectAudioFiles, SelectFolder } from "../../wailsjs/go/main/App";
|
||||
import { OnFileDrop, OnFileDropOff } from "../../wailsjs/runtime/runtime";
|
||||
interface AudioAnalysisPageProps {
|
||||
onBack?: () => void;
|
||||
}
|
||||
type BatchItemStatus = "pending" | "analyzing" | "success" | "error";
|
||||
type BatchItemSource = "path" | "browser";
|
||||
interface BatchAnalysisItem {
|
||||
id: string;
|
||||
source: BatchItemSource;
|
||||
path: string;
|
||||
name: string;
|
||||
size: number;
|
||||
status: BatchItemStatus;
|
||||
error?: string;
|
||||
result?: AnalysisResult;
|
||||
file?: File;
|
||||
}
|
||||
interface QueueProgressState {
|
||||
completed: number;
|
||||
total: number;
|
||||
fileName: string;
|
||||
}
|
||||
const EMPTY_PROGRESS_STATE: QueueProgressState = {
|
||||
completed: 0,
|
||||
total: 0,
|
||||
fileName: "",
|
||||
};
|
||||
const SUPPORTED_AUDIO_EXTENSIONS = [".flac", ".mp3", ".m4a", ".aac"];
|
||||
const SUPPORTED_AUDIO_ACCEPT = [
|
||||
".flac",
|
||||
@@ -51,98 +79,458 @@ function fileNameFromPath(filePath: string): string {
|
||||
const parts = filePath.split(/[/\\]/);
|
||||
return parts[parts.length - 1] || filePath;
|
||||
}
|
||||
function browserFileId(file: File): string {
|
||||
return `browser:${file.name}:${file.size}:${file.lastModified}`;
|
||||
}
|
||||
function downloadDataURL(dataUrl: string, fileName: string): void {
|
||||
const link = document.createElement("a");
|
||||
link.href = dataUrl;
|
||||
link.download = fileName;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes <= 0) {
|
||||
return "0 B";
|
||||
}
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB"];
|
||||
const index = Math.min(sizes.length - 1, Math.floor(Math.log(bytes) / Math.log(k)));
|
||||
return `${parseFloat((bytes / Math.pow(k, index)).toFixed(1))} ${sizes[index]}`;
|
||||
}
|
||||
function formatDuration(seconds: number): string {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins}:${secs.toString().padStart(2, "0")}`;
|
||||
}
|
||||
function itemMetaLine(item: BatchAnalysisItem): string {
|
||||
if (item.result) {
|
||||
const parts = [
|
||||
item.result.file_type ?? "Audio",
|
||||
`${(item.result.sample_rate / 1000).toFixed(1)} kHz`,
|
||||
formatDuration(item.result.duration),
|
||||
];
|
||||
if (typeof item.result.bitrate_kbps === "number" && item.result.bitrate_kbps > 0) {
|
||||
parts.push(`${item.result.bitrate_kbps} kbps`);
|
||||
}
|
||||
return parts.join(" • ");
|
||||
}
|
||||
switch (item.status) {
|
||||
case "analyzing":
|
||||
return "Analyzing audio quality...";
|
||||
case "error":
|
||||
return item.error || "Analysis failed";
|
||||
case "pending":
|
||||
default:
|
||||
return "Waiting to be analyzed";
|
||||
}
|
||||
}
|
||||
function statusIcon(status: BatchItemStatus) {
|
||||
switch (status) {
|
||||
case "analyzing":
|
||||
return <Spinner className="h-4 w-4 text-primary"/>;
|
||||
case "success":
|
||||
return <CheckCircle2 className="h-4 w-4 text-green-500"/>;
|
||||
case "error":
|
||||
return <AlertCircle className="h-4 w-4 text-destructive"/>;
|
||||
case "pending":
|
||||
default:
|
||||
return <FileMusic className="h-4 w-4 text-muted-foreground"/>;
|
||||
}
|
||||
}
|
||||
export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
|
||||
const { analyzing, analysisProgress, result, analyzeFile, analyzeFilePath, clearResult, selectedFilePath, spectrumLoading, spectrumProgress, reAnalyzeSpectrum, } = useAudioAnalysis();
|
||||
const { analysisProgress, spectrumLoading, spectrumProgress, analyzeFile, analyzeFilePath, cancelAnalysis, loadStoredAnalysis, clearStoredAnalysis, reAnalyzeSpectrum, clearResult, } = useAudioAnalysis();
|
||||
const [items, setItems] = useState<BatchAnalysisItem[]>([]);
|
||||
const [activeItemId, setActiveItemId] = useState<string | null>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const [isExportingSelected, setIsExportingSelected] = useState(false);
|
||||
const [isExportingBatch, setIsExportingBatch] = useState(false);
|
||||
const [isBatchRunning, setIsBatchRunning] = useState(false);
|
||||
const [batchProgress, setBatchProgress] = useState<QueueProgressState>(EMPTY_PROGRESS_STATE);
|
||||
const [exportProgress, setExportProgress] = useState<QueueProgressState>(EMPTY_PROGRESS_STATE);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const spectrumRef = useRef<{
|
||||
getCanvasDataURL: () => string | null;
|
||||
}>(null);
|
||||
const analyzeSelectedPath = useCallback(async (filePath: string) => {
|
||||
if (!isSupportedAudioPath(filePath)) {
|
||||
toast.error("Invalid File Type", {
|
||||
description: `Please select a ${SUPPORTED_AUDIO_LABEL} file for analysis`,
|
||||
});
|
||||
const spectrumRef = useRef<SpectrumVisualizationHandle>(null);
|
||||
const batchRunIdRef = useRef(0);
|
||||
const itemsRef = useRef(items);
|
||||
const activeItemIdRef = useRef<string | null>(activeItemId);
|
||||
useEffect(() => {
|
||||
itemsRef.current = items;
|
||||
}, [items]);
|
||||
useEffect(() => {
|
||||
activeItemIdRef.current = activeItemId;
|
||||
}, [activeItemId]);
|
||||
const setActiveSelection = useCallback((nextId: string | null) => {
|
||||
activeItemIdRef.current = nextId;
|
||||
setActiveItemId(nextId);
|
||||
}, []);
|
||||
const activeItem = items.find((item) => item.id === activeItemId) ?? null;
|
||||
const successItems = items.filter((item) => item.status === "success" && item.result?.spectrum);
|
||||
const pendingItems = items.filter((item) => item.status === "pending");
|
||||
const isSingleMode = items.length === 1;
|
||||
const isBatchMode = items.length > 1;
|
||||
const canResumeBatch = isBatchMode && !isBatchRunning && pendingItems.length > 0;
|
||||
const batchPercent = batchProgress.total > 0
|
||||
? Math.round(Math.max(0, Math.min(100, ((batchProgress.completed + (isBatchRunning ? analysisProgress.percent / 100 : 0)) / batchProgress.total) * 100)))
|
||||
: 0;
|
||||
const exportPercent = exportProgress.total > 0
|
||||
? Math.round(Math.max(0, Math.min(100, (exportProgress.completed / exportProgress.total) * 100)))
|
||||
: 0;
|
||||
useEffect(() => {
|
||||
if (!activeItem?.result) {
|
||||
return;
|
||||
}
|
||||
await analyzeFilePath(filePath);
|
||||
}, [analyzeFilePath]);
|
||||
const analyzeSelectedFile = useCallback(async (file: File) => {
|
||||
if (!isSupportedAudioFile(file)) {
|
||||
toast.error("Invalid File Type", {
|
||||
description: `Please select a ${SUPPORTED_AUDIO_LABEL} file for analysis`,
|
||||
});
|
||||
loadStoredAnalysis(activeItem.id, activeItem.result, activeItem.path);
|
||||
}, [activeItem, loadStoredAnalysis]);
|
||||
const runBatchAnalysis = useCallback(async (entries: BatchAnalysisItem[]) => {
|
||||
if (entries.length === 0) {
|
||||
return;
|
||||
}
|
||||
await analyzeFile(file);
|
||||
}, [analyzeFile]);
|
||||
const handleSelectFile = useCallback(async () => {
|
||||
const runId = batchRunIdRef.current + 1;
|
||||
batchRunIdRef.current = runId;
|
||||
setIsBatchRunning(true);
|
||||
setBatchProgress({
|
||||
completed: 0,
|
||||
total: entries.length,
|
||||
fileName: entries[0]?.name ?? "",
|
||||
});
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
try {
|
||||
const filePath = await SelectFile();
|
||||
if (!filePath) {
|
||||
for (let index = 0; index < entries.length; index++) {
|
||||
if (batchRunIdRef.current !== runId) {
|
||||
return;
|
||||
}
|
||||
await analyzeSelectedPath(filePath);
|
||||
const entry = entries[index];
|
||||
setBatchProgress({
|
||||
completed: index,
|
||||
total: entries.length,
|
||||
fileName: entry.name,
|
||||
});
|
||||
setItems((prev) => prev.map((item) => item.id === entry.id
|
||||
? { ...item, status: "analyzing", error: undefined }
|
||||
: item));
|
||||
const outcome = entry.source === "browser" && entry.file
|
||||
? await analyzeFile(entry.file, {
|
||||
analysisKey: entry.id,
|
||||
displayPath: entry.path,
|
||||
suppressToast: true,
|
||||
})
|
||||
: await analyzeFilePath(entry.path, {
|
||||
analysisKey: entry.id,
|
||||
displayPath: entry.path,
|
||||
suppressToast: true,
|
||||
});
|
||||
if (batchRunIdRef.current !== runId) {
|
||||
return;
|
||||
}
|
||||
if (outcome.cancelled) {
|
||||
return;
|
||||
}
|
||||
if (outcome.result) {
|
||||
const analysisResult = outcome.result;
|
||||
successCount++;
|
||||
setItems((prev) => prev.map((item) => item.id === entry.id
|
||||
? {
|
||||
...item,
|
||||
status: "success",
|
||||
error: undefined,
|
||||
result: analysisResult,
|
||||
size: analysisResult.file_size || item.size,
|
||||
}
|
||||
: item));
|
||||
const hasSelectedSuccess = itemsRef.current.some((item) => item.id === activeItemIdRef.current && item.status === "success" && item.result);
|
||||
if (!hasSelectedSuccess) {
|
||||
setActiveSelection(entry.id);
|
||||
}
|
||||
}
|
||||
else {
|
||||
failCount++;
|
||||
setItems((prev) => prev.map((item) => item.id === entry.id
|
||||
? {
|
||||
...item,
|
||||
status: "error",
|
||||
error: outcome.error || "Analysis failed",
|
||||
}
|
||||
: item));
|
||||
if (!activeItemIdRef.current) {
|
||||
setActiveSelection(entry.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (batchRunIdRef.current === runId) {
|
||||
setBatchProgress({
|
||||
completed: entries.length,
|
||||
total: entries.length,
|
||||
fileName: "",
|
||||
});
|
||||
if (successCount > 0) {
|
||||
toast.success("Batch Analysis Complete", {
|
||||
description: `Successfully analyzed ${successCount} file(s)${failCount > 0 ? `, ${failCount} failed` : ""}`,
|
||||
});
|
||||
}
|
||||
else if (failCount > 0) {
|
||||
toast.error("Batch Analysis Failed", {
|
||||
description: `All ${failCount} file(s) failed to analyze`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
finally {
|
||||
if (batchRunIdRef.current === runId) {
|
||||
setIsBatchRunning(false);
|
||||
}
|
||||
}
|
||||
}, [analyzeFile, analyzeFilePath, setActiveSelection]);
|
||||
const ensureIdleQueue = useCallback(() => {
|
||||
if (!isBatchRunning) {
|
||||
return true;
|
||||
}
|
||||
toast.info("Analysis in progress", {
|
||||
description: "Please wait for the current batch to finish or clear it first.",
|
||||
});
|
||||
return false;
|
||||
}, [isBatchRunning]);
|
||||
const addPathItems = useCallback(async (paths: string[]) => {
|
||||
if (!ensureIdleQueue()) {
|
||||
return;
|
||||
}
|
||||
const uniquePaths = Array.from(new Set(paths.filter(Boolean)));
|
||||
const invalidCount = uniquePaths.filter((path) => !isSupportedAudioPath(path)).length;
|
||||
const validPaths = uniquePaths.filter(isSupportedAudioPath);
|
||||
if (invalidCount > 0) {
|
||||
toast.error("Unsupported format", {
|
||||
description: `Only ${SUPPORTED_AUDIO_LABEL} files can be analyzed.`,
|
||||
});
|
||||
}
|
||||
if (validPaths.length === 0) {
|
||||
return;
|
||||
}
|
||||
const existingIds = new Set(itemsRef.current.map((item) => item.id));
|
||||
const newPaths = validPaths.filter((path) => !existingIds.has(path));
|
||||
if (newPaths.length === 0) {
|
||||
toast.info("No new files added", {
|
||||
description: "All selected files were already in the batch queue.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const fileSizes = await GetFileSizes(newPaths);
|
||||
const newItems = newPaths.map((path) => ({
|
||||
id: path,
|
||||
source: "path" as const,
|
||||
path,
|
||||
name: fileNameFromPath(path),
|
||||
size: fileSizes[path] || 0,
|
||||
status: "pending" as const,
|
||||
}));
|
||||
if (validPaths.length !== newPaths.length) {
|
||||
toast.info("Some files skipped", {
|
||||
description: `${validPaths.length - newPaths.length} file(s) were already queued.`,
|
||||
});
|
||||
}
|
||||
setItems((prev) => [...prev, ...newItems]);
|
||||
if (!activeItemIdRef.current) {
|
||||
setActiveSelection(newItems[0]?.id ?? null);
|
||||
}
|
||||
void runBatchAnalysis(newItems);
|
||||
}, [ensureIdleQueue, runBatchAnalysis, setActiveSelection]);
|
||||
const addBrowserFiles = useCallback(async (files: File[]) => {
|
||||
if (!ensureIdleQueue()) {
|
||||
return;
|
||||
}
|
||||
const validFiles = files.filter(isSupportedAudioFile);
|
||||
const invalidCount = files.length - validFiles.length;
|
||||
if (invalidCount > 0) {
|
||||
toast.error("Unsupported format", {
|
||||
description: `Only ${SUPPORTED_AUDIO_LABEL} files can be analyzed.`,
|
||||
});
|
||||
}
|
||||
if (validFiles.length === 0) {
|
||||
return;
|
||||
}
|
||||
const existingIds = new Set(itemsRef.current.map((item) => item.id));
|
||||
const newItems = validFiles
|
||||
.map((file) => ({
|
||||
id: browserFileId(file),
|
||||
source: "browser" as const,
|
||||
path: file.name,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
status: "pending" as const,
|
||||
file,
|
||||
}))
|
||||
.filter((item) => !existingIds.has(item.id));
|
||||
if (newItems.length === 0) {
|
||||
toast.info("No new files added", {
|
||||
description: "All selected files were already in the batch queue.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (validFiles.length !== newItems.length) {
|
||||
toast.info("Some files skipped", {
|
||||
description: `${validFiles.length - newItems.length} file(s) were already queued.`,
|
||||
});
|
||||
}
|
||||
setItems((prev) => [...prev, ...newItems]);
|
||||
if (!activeItemIdRef.current) {
|
||||
setActiveSelection(newItems[0]?.id ?? null);
|
||||
}
|
||||
void runBatchAnalysis(newItems);
|
||||
}, [ensureIdleQueue, runBatchAnalysis, setActiveSelection]);
|
||||
const handleSelectFiles = useCallback(async () => {
|
||||
if (!ensureIdleQueue()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const selectedPaths = await SelectAudioFiles();
|
||||
if (selectedPaths && selectedPaths.length > 0) {
|
||||
await addPathItems(selectedPaths);
|
||||
}
|
||||
return;
|
||||
}
|
||||
catch {
|
||||
fileInputRef.current?.click();
|
||||
return;
|
||||
}
|
||||
}, [analyzeSelectedPath]);
|
||||
const handleInputChange = useCallback(async (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file)
|
||||
}, [addPathItems, ensureIdleQueue]);
|
||||
const handleSelectFolder = useCallback(async () => {
|
||||
if (!ensureIdleQueue()) {
|
||||
return;
|
||||
await analyzeSelectedFile(file);
|
||||
e.target.value = "";
|
||||
}, [analyzeSelectedFile]);
|
||||
const handleHtmlDrop = useCallback(async (e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
}
|
||||
try {
|
||||
const selectedFolder = await SelectFolder("");
|
||||
if (!selectedFolder) {
|
||||
return;
|
||||
}
|
||||
const folderFiles = await ListAudioFilesInDir(selectedFolder);
|
||||
if (!folderFiles || folderFiles.length === 0) {
|
||||
toast.info("No audio files found", {
|
||||
description: `No ${SUPPORTED_AUDIO_LABEL} files were found in the selected folder.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await addPathItems(folderFiles.map((file) => file.path));
|
||||
}
|
||||
catch (err) {
|
||||
toast.error("Folder Selection Failed", {
|
||||
description: err instanceof Error ? err.message : "Failed to select folder",
|
||||
});
|
||||
}
|
||||
}, [addPathItems, ensureIdleQueue]);
|
||||
const handleInputChange = useCallback(async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(event.target.files ?? []);
|
||||
event.target.value = "";
|
||||
if (files.length === 0) {
|
||||
return;
|
||||
}
|
||||
await addBrowserFiles(files);
|
||||
}, [addBrowserFiles]);
|
||||
const handleHtmlDrop = useCallback(async (event: DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
setIsDragging(false);
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
if (!file)
|
||||
const files = Array.from(event.dataTransfer.files ?? []);
|
||||
if (files.length === 0) {
|
||||
return;
|
||||
await analyzeSelectedFile(file);
|
||||
}, [analyzeSelectedFile]);
|
||||
}
|
||||
await addBrowserFiles(files);
|
||||
}, [addBrowserFiles]);
|
||||
useEffect(() => {
|
||||
OnFileDrop((_x, _y, paths) => {
|
||||
setIsDragging(false);
|
||||
const droppedPath = paths?.[0];
|
||||
if (!droppedPath)
|
||||
if (!paths || paths.length === 0) {
|
||||
return;
|
||||
void analyzeSelectedPath(droppedPath);
|
||||
}
|
||||
void addPathItems(paths);
|
||||
}, true);
|
||||
return () => {
|
||||
OnFileDropOff();
|
||||
};
|
||||
}, [analyzeSelectedPath]);
|
||||
const handleExport = useCallback(async () => {
|
||||
if (!spectrumRef.current)
|
||||
return;
|
||||
const dataUrl = spectrumRef.current.getCanvasDataURL();
|
||||
if (!dataUrl) {
|
||||
toast.error("Export Failed", { description: "Cannot get canvas data" });
|
||||
}, [addPathItems]);
|
||||
const handleSelectItem = useCallback((itemId: string) => {
|
||||
setActiveSelection(itemId);
|
||||
}, [setActiveSelection]);
|
||||
const handleRemoveItem = useCallback((itemId: string) => {
|
||||
if (isBatchRunning || isExportingBatch || isExportingSelected || spectrumLoading) {
|
||||
return;
|
||||
}
|
||||
setIsExporting(true);
|
||||
clearStoredAnalysis(itemId);
|
||||
const nextItems = itemsRef.current.filter((item) => item.id !== itemId);
|
||||
itemsRef.current = nextItems;
|
||||
setItems(nextItems);
|
||||
if (activeItemIdRef.current === itemId) {
|
||||
const nextActive = nextItems.find((item) => item.status === "success" && item.result) ?? nextItems[0] ?? null;
|
||||
setActiveSelection(nextActive?.id ?? null);
|
||||
if (!nextActive) {
|
||||
clearResult();
|
||||
}
|
||||
}
|
||||
}, [clearResult, clearStoredAnalysis, isBatchRunning, isExportingBatch, isExportingSelected, setActiveSelection, spectrumLoading]);
|
||||
const handleClearAll = useCallback(() => {
|
||||
if (isExportingBatch || isExportingSelected) {
|
||||
return;
|
||||
}
|
||||
batchRunIdRef.current += 1;
|
||||
itemsRef.current = [];
|
||||
setItems([]);
|
||||
setActiveSelection(null);
|
||||
clearStoredAnalysis();
|
||||
clearResult();
|
||||
setIsBatchRunning(false);
|
||||
setBatchProgress(EMPTY_PROGRESS_STATE);
|
||||
setExportProgress(EMPTY_PROGRESS_STATE);
|
||||
setIsDragging(false);
|
||||
}, [clearResult, clearStoredAnalysis, isExportingBatch, isExportingSelected, setActiveSelection]);
|
||||
const handleStopBatch = useCallback(() => {
|
||||
if (!isBatchRunning) {
|
||||
return;
|
||||
}
|
||||
batchRunIdRef.current += 1;
|
||||
cancelAnalysis();
|
||||
setIsBatchRunning(false);
|
||||
setBatchProgress(EMPTY_PROGRESS_STATE);
|
||||
setItems((prev) => prev.map((item) => item.status === "analyzing"
|
||||
? {
|
||||
...item,
|
||||
status: "pending",
|
||||
}
|
||||
: item));
|
||||
toast.info("Batch analysis stopped", {
|
||||
description: "Click Analyze to continue the remaining files.",
|
||||
});
|
||||
}, [cancelAnalysis, isBatchRunning]);
|
||||
const handleAnalyzePending = useCallback(() => {
|
||||
if (isBatchRunning || isExportingBatch || isExportingSelected || spectrumLoading) {
|
||||
return;
|
||||
}
|
||||
const nextPendingItems = itemsRef.current.filter((item) => item.status === "pending");
|
||||
if (nextPendingItems.length === 0) {
|
||||
return;
|
||||
}
|
||||
void runBatchAnalysis(nextPendingItems);
|
||||
}, [isBatchRunning, isExportingBatch, isExportingSelected, runBatchAnalysis, spectrumLoading]);
|
||||
const handleExportSelected = useCallback(async () => {
|
||||
if (!activeItem?.result?.spectrum || !spectrumRef.current) {
|
||||
return;
|
||||
}
|
||||
const dataUrl = spectrumRef.current.getCanvasDataURL();
|
||||
if (!dataUrl) {
|
||||
toast.error("Export Failed", {
|
||||
description: "Cannot get canvas data",
|
||||
});
|
||||
return;
|
||||
}
|
||||
setIsExportingSelected(true);
|
||||
try {
|
||||
if (selectedFilePath && isAbsolutePath(selectedFilePath)) {
|
||||
const outPath = await SaveSpectrumImage(selectedFilePath, dataUrl);
|
||||
toast.success("Exported Successfully", {
|
||||
if (activeItem.source === "path" && isAbsolutePath(activeItem.path)) {
|
||||
const outPath = await SaveSpectrumImage(activeItem.path, dataUrl);
|
||||
toast.success("PNG Exported", {
|
||||
description: `Saved to: ${outPath}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const base = selectedFilePath
|
||||
? fileNameFromPath(selectedFilePath).replace(/\.[^/.]+$/, "")
|
||||
: "spectrogram";
|
||||
const a = document.createElement("a");
|
||||
a.href = dataUrl;
|
||||
a.download = `${base}_spectrogram.png`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
toast.success("Exported Successfully", {
|
||||
const baseName = activeItem.name.replace(/\.[^/.]+$/, "") || "spectrogram";
|
||||
downloadDataURL(dataUrl, `${baseName}_spectrogram.png`);
|
||||
toast.success("PNG Exported", {
|
||||
description: "Spectrogram image downloaded",
|
||||
});
|
||||
}
|
||||
@@ -152,42 +540,228 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
|
||||
});
|
||||
}
|
||||
finally {
|
||||
setIsExporting(false);
|
||||
setIsExportingSelected(false);
|
||||
}
|
||||
}, [selectedFilePath]);
|
||||
const handleAnalyzeAnother = () => {
|
||||
clearResult();
|
||||
};
|
||||
const fileName = selectedFilePath ? fileNameFromPath(selectedFilePath) : undefined;
|
||||
return (<div className="space-y-6">
|
||||
<input ref={fileInputRef} type="file" accept={SUPPORTED_AUDIO_ACCEPT} className="hidden" onChange={handleInputChange}/>
|
||||
}, [activeItem]);
|
||||
const handleBatchExport = useCallback(async () => {
|
||||
const exportableItems = itemsRef.current.filter((item) => item.status === "success" && item.result?.spectrum);
|
||||
if (exportableItems.length === 0) {
|
||||
toast.error("Nothing to export", {
|
||||
description: "Analyze at least one file successfully before exporting PNGs.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const preferences = loadAudioAnalysisPreferences();
|
||||
setIsExportingBatch(true);
|
||||
setExportProgress({
|
||||
completed: 0,
|
||||
total: exportableItems.length,
|
||||
fileName: exportableItems[0]?.name ?? "",
|
||||
});
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
try {
|
||||
for (let index = 0; index < exportableItems.length; index++) {
|
||||
const item = exportableItems[index];
|
||||
const result = item.result;
|
||||
if (!result?.spectrum) {
|
||||
failCount++;
|
||||
continue;
|
||||
}
|
||||
setExportProgress({
|
||||
completed: index,
|
||||
total: exportableItems.length,
|
||||
fileName: item.name,
|
||||
});
|
||||
try {
|
||||
const dataUrl = await createSpectrogramDataURL({
|
||||
spectrumData: result.spectrum,
|
||||
sampleRate: result.sample_rate,
|
||||
duration: result.duration,
|
||||
freqScale: preferences.freqScale,
|
||||
colorScheme: preferences.colorScheme,
|
||||
fileName: item.name,
|
||||
});
|
||||
if (item.source === "path" && isAbsolutePath(item.path)) {
|
||||
await SaveSpectrumImage(item.path, dataUrl);
|
||||
}
|
||||
else {
|
||||
const baseName = item.name.replace(/\.[^/.]+$/, "") || "spectrogram";
|
||||
downloadDataURL(dataUrl, `${baseName}_spectrogram.png`);
|
||||
}
|
||||
successCount++;
|
||||
}
|
||||
catch {
|
||||
failCount++;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
setExportProgress({
|
||||
completed: exportableItems.length,
|
||||
total: exportableItems.length,
|
||||
fileName: "",
|
||||
});
|
||||
if (successCount > 0) {
|
||||
toast.success("Batch PNG Export Complete", {
|
||||
description: `Exported ${successCount} spectrogram PNG file(s)${failCount > 0 ? `, ${failCount} failed` : ""}`,
|
||||
});
|
||||
}
|
||||
else {
|
||||
toast.error("Batch PNG Export Failed", {
|
||||
description: "No spectrogram PNG files were exported.",
|
||||
});
|
||||
}
|
||||
}
|
||||
finally {
|
||||
setIsExportingBatch(false);
|
||||
}
|
||||
}, []);
|
||||
const handleReAnalyzeSelectedSpectrum = useCallback(async (fftSize: number, windowFunction: string) => {
|
||||
if (!activeItem?.result) {
|
||||
return;
|
||||
}
|
||||
const nextResult = await reAnalyzeSpectrum(fftSize, windowFunction);
|
||||
if (!nextResult) {
|
||||
return;
|
||||
}
|
||||
setItems((prev) => prev.map((item) => item.id === activeItem.id
|
||||
? {
|
||||
...item,
|
||||
result: nextResult,
|
||||
status: "success",
|
||||
error: undefined,
|
||||
}
|
||||
: item));
|
||||
}, [activeItem, reAnalyzeSpectrum]);
|
||||
const batchDetailContent = !activeItem ? (<Card>
|
||||
<CardContent className="flex min-h-[320px] items-center justify-center px-6 py-10">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Select a file from the batch queue to inspect its analysis result.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>) : activeItem.status !== "success" || !activeItem.result ? (<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">{activeItem.name}</CardTitle>
|
||||
<p className="break-all font-mono text-sm text-muted-foreground">{activeItem.path}</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{activeItem.status === "analyzing" && (<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Spinner />
|
||||
<span className="text-sm text-muted-foreground">Analyzing audio quality...</span>
|
||||
</div>
|
||||
<Progress value={analysisProgress.percent} className="h-2 w-full"/>
|
||||
<p className="text-xs text-muted-foreground">{analysisProgress.message}</p>
|
||||
</div>)}
|
||||
{activeItem.status === "pending" && (<p className="text-sm text-muted-foreground">
|
||||
This file is queued and waiting for batch analysis to start.
|
||||
</p>)}
|
||||
{activeItem.status === "error" && (<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
|
||||
{activeItem.error || "Analysis failed"}
|
||||
</div>)}
|
||||
</CardContent>
|
||||
</Card>) : (<div className="space-y-4">
|
||||
<AudioAnalysis result={activeItem.result} analyzing={false} showAnalyzeButton={false} filePath={activeItem.path}/>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<SpectrumVisualization ref={spectrumRef} sampleRate={activeItem.result.sample_rate} duration={activeItem.result.duration} spectrumData={activeItem.result.spectrum} fileName={activeItem.name} onReAnalyze={handleReAnalyzeSelectedSpectrum} isAnalyzingSpectrum={spectrumLoading} spectrumProgress={spectrumProgress}/>
|
||||
</div>);
|
||||
const singleModeContent = !activeItem ? null : activeItem.status === "success" && activeItem.result ? (<div className="mx-auto w-full max-w-6xl space-y-4">
|
||||
<AudioAnalysis result={activeItem.result} analyzing={false} showAnalyzeButton={false} filePath={activeItem.path}/>
|
||||
|
||||
<SpectrumVisualization ref={spectrumRef} sampleRate={activeItem.result.sample_rate} duration={activeItem.result.duration} spectrumData={activeItem.result.spectrum} fileName={activeItem.name} onReAnalyze={handleReAnalyzeSelectedSpectrum} isAnalyzingSpectrum={spectrumLoading} spectrumProgress={spectrumProgress}/>
|
||||
</div>) : activeItem.status === "analyzing" || activeItem.status === "pending" ? (<div className="flex h-[400px] items-center justify-center">
|
||||
<div className="w-full max-w-md space-y-2">
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<span>{activeItem.status === "pending" ? "Preparing..." : "Processing..."}</span>
|
||||
<span className="tabular-nums">{analysisProgress.percent}%</span>
|
||||
</div>
|
||||
<Progress value={analysisProgress.percent} className="h-2 w-full"/>
|
||||
<p className="text-center text-xs text-muted-foreground">{analysisProgress.message}</p>
|
||||
</div>
|
||||
</div>) : (<div className="flex h-[400px] items-center justify-center">
|
||||
<div className="w-full max-w-md rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
|
||||
{activeItem.error || "Analysis failed"}
|
||||
</div>
|
||||
</div>);
|
||||
const showSingleModeActions = isSingleMode && activeItem?.status === "success" && activeItem.result;
|
||||
return (<div className="space-y-6">
|
||||
<input ref={fileInputRef} type="file" multiple accept={SUPPORTED_AUDIO_ACCEPT} className="hidden" onChange={handleInputChange}/>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-4">
|
||||
{onBack && (<Button variant="ghost" size="icon" onClick={onBack}>
|
||||
<ArrowLeft className="h-5 w-5"/>
|
||||
</Button>)}
|
||||
<h1 className="text-2xl font-bold">Audio Quality Analyzer</h1>
|
||||
</div>
|
||||
{result && (<div className="flex gap-2">
|
||||
<Button onClick={handleExport} variant="outline" size="sm" disabled={isExporting || spectrumLoading}>
|
||||
<Download className="h-4 w-4 mr-1"/>
|
||||
{isExporting ? "Exporting..." : "Export PNG"}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{isBatchMode && isBatchRunning && (<Button onClick={handleStopBatch} variant="destructive" size="sm" disabled={isExportingBatch || isExportingSelected} className="gap-1.5">
|
||||
<StopCircle className="h-4 w-4"/>
|
||||
Stop
|
||||
</Button>)}
|
||||
{canResumeBatch && (<Button onClick={handleAnalyzePending} variant="outline" size="sm" disabled={isExportingBatch || isExportingSelected || spectrumLoading}>
|
||||
<Play className="h-4 w-4"/>
|
||||
Analyze
|
||||
</Button>)}
|
||||
{isBatchMode && (<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" disabled={isBatchRunning || isExportingBatch || isExportingSelected}>
|
||||
<Upload className="h-4 w-4 mr-1"/>
|
||||
Add
|
||||
<ChevronDown className="ml-1 h-4 w-4"/>
|
||||
</Button>
|
||||
<Button onClick={handleAnalyzeAnother} variant="outline" size="sm">
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-[180px]">
|
||||
<DropdownMenuItem onClick={handleSelectFiles} className="cursor-pointer">
|
||||
<Upload className="h-4 w-4"/>
|
||||
Add Files
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleSelectFolder} className="cursor-pointer">
|
||||
<FolderOpen className="h-4 w-4"/>
|
||||
Add Folder
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>)}
|
||||
{showSingleModeActions && (<Button onClick={handleExportSelected} variant="outline" size="sm" disabled={isExportingSelected || spectrumLoading}>
|
||||
<Download className="h-4 w-4 mr-1"/>
|
||||
{isExportingSelected ? "Exporting..." : "Export PNG"}
|
||||
</Button>)}
|
||||
{isBatchMode && (<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" disabled={successItems.length === 0 || isExportingBatch || isExportingSelected || isBatchRunning || spectrumLoading}>
|
||||
<Download className="h-4 w-4 mr-1"/>
|
||||
{isExportingBatch ? "Exporting..." : isExportingSelected ? "Exporting..." : "Export"}
|
||||
<ChevronDown className="ml-1 h-4 w-4"/>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-[200px]">
|
||||
<DropdownMenuItem onClick={handleExportSelected} className="cursor-pointer" disabled={!activeItem?.result?.spectrum}>
|
||||
<Download className="h-4 w-4"/>
|
||||
Export Selected PNG
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleBatchExport} className="cursor-pointer" disabled={successItems.length === 0}>
|
||||
<Download className="h-4 w-4"/>
|
||||
Export All PNG
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>)}
|
||||
{showSingleModeActions && (<Button onClick={handleClearAll} variant="outline" size="sm" disabled={isExportingSelected}>
|
||||
<Trash2 className="h-4 w-4 mr-1"/>
|
||||
Clear
|
||||
</Button>
|
||||
</div>)}
|
||||
</Button>)}
|
||||
{isBatchMode && (<Button onClick={handleClearAll} variant="outline" size="sm" disabled={isExportingBatch || isExportingSelected}>
|
||||
<Trash2 className="h-4 w-4 mr-1"/>
|
||||
Clear
|
||||
</Button>)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!result && !analyzing && (<div className={`flex flex-col items-center justify-center h-[400px] border-2 border-dashed rounded-lg transition-colors ${isDragging
|
||||
? "border-primary bg-primary/10"
|
||||
: "border-muted-foreground/30"}`} onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
{items.length === 0 && (<div className={`flex h-[400px] flex-col items-center justify-center rounded-lg border-2 border-dashed transition-all ${isDragging ? "border-primary bg-primary/10" : "border-muted-foreground/30"}`} onDragOver={(event) => {
|
||||
event.preventDefault();
|
||||
setIsDragging(true);
|
||||
}} onDragLeave={(e) => {
|
||||
e.preventDefault();
|
||||
}} onDragLeave={(event) => {
|
||||
event.preventDefault();
|
||||
setIsDragging(false);
|
||||
}} onDrop={handleHtmlDrop} style={{ "--wails-drop-target": "drop" } as CSSProperties}>
|
||||
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
|
||||
@@ -195,32 +769,116 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-4 text-center">
|
||||
{isDragging
|
||||
? "Drop your audio file here"
|
||||
: "Drag and drop an audio file here, or click the button below to select"}
|
||||
? "Drop your audio files here"
|
||||
: "Drag and drop audio files here, or click the button below to select"}
|
||||
</p>
|
||||
<Button onClick={handleSelectFile} size="lg">
|
||||
<div className="flex gap-3">
|
||||
<Button onClick={handleSelectFiles} size="lg">
|
||||
<Upload className="h-5 w-5"/>
|
||||
Select Audio File
|
||||
Select Files
|
||||
</Button>
|
||||
<Button onClick={handleSelectFolder} size="lg" variant="outline">
|
||||
<Upload className="h-5 w-5"/>
|
||||
Select Folder
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-4 text-center">
|
||||
Supported formats: FLAC, MP3, M4A, AAC
|
||||
</p>
|
||||
</div>)}
|
||||
|
||||
{analyzing && !result && (<div className="flex h-[400px] items-center justify-center">
|
||||
<div className="w-full max-w-md space-y-2">
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<span>Processing...</span>
|
||||
<span className="tabular-nums">{analysisProgress.percent}%</span>
|
||||
</div>
|
||||
<Progress value={analysisProgress.percent} className="h-2 w-full"/>
|
||||
</div>
|
||||
{isSingleMode && (<div className="space-y-4">
|
||||
{singleModeContent}
|
||||
</div>)}
|
||||
|
||||
{result && (<div className="space-y-4">
|
||||
<AudioAnalysis result={result} analyzing={analyzing} showAnalyzeButton={false} filePath={selectedFilePath}/>
|
||||
{isBatchMode && (<div className="grid gap-4 xl:grid-cols-[360px,minmax(0,1fr)]">
|
||||
<div className="space-y-3">
|
||||
{(isBatchRunning || isExportingBatch) && (<Card className="gap-2 py-4">
|
||||
<CardHeader className="px-4 pb-0">
|
||||
<CardTitle className="text-sm">
|
||||
{isExportingBatch ? "Batch PNG Export" : "Batch Analysis"}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 px-4">
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span className="truncate pr-3">
|
||||
{isExportingBatch
|
||||
? exportProgress.fileName || "Preparing export..."
|
||||
: batchProgress.fileName || analysisProgress.message}
|
||||
</span>
|
||||
<span className="tabular-nums">
|
||||
{isExportingBatch
|
||||
? `${exportProgress.completed}/${exportProgress.total}`
|
||||
: `${Math.min(batchProgress.completed + (isBatchRunning ? 1 : 0), batchProgress.total)}/${batchProgress.total}`}
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={isExportingBatch ? exportPercent : batchPercent} className="h-1.5 w-full"/>
|
||||
{!isExportingBatch && (<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>{analysisProgress.message}</span>
|
||||
<span className="tabular-nums">{analysisProgress.percent}%</span>
|
||||
</div>)}
|
||||
</CardContent>
|
||||
</Card>)}
|
||||
|
||||
<SpectrumVisualization ref={spectrumRef} sampleRate={result.sample_rate} duration={result.duration} spectrumData={result.spectrum} fileName={fileName} onReAnalyze={reAnalyzeSpectrum} isAnalyzingSpectrum={spectrumLoading} spectrumProgress={spectrumProgress}/>
|
||||
<Card className="gap-2 overflow-hidden py-4">
|
||||
<CardHeader className="px-4 pb-0">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<CardTitle className="text-sm">Batch Queue</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{items.length} queued • {successItems.length} ready
|
||||
</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4">
|
||||
<div className="max-h-[232px] space-y-2 overflow-y-auto pr-1">
|
||||
{items.map((item) => {
|
||||
const isActive = item.id === activeItemId;
|
||||
const isSelectable = item.status !== "pending";
|
||||
return (<div key={item.id} role={isSelectable ? "button" : undefined} tabIndex={isSelectable ? 0 : -1} className={`flex w-full items-start gap-2.5 rounded-lg border px-3 py-2.5 text-left transition-colors ${isActive
|
||||
? "border-primary bg-primary/5"
|
||||
: isSelectable
|
||||
? "border-border hover:border-primary/40"
|
||||
: "border-border"}`} onClick={() => {
|
||||
if (!isSelectable) {
|
||||
return;
|
||||
}
|
||||
handleSelectItem(item.id);
|
||||
}} onKeyDown={(event) => {
|
||||
if (!isSelectable) {
|
||||
return;
|
||||
}
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
handleSelectItem(item.id);
|
||||
}
|
||||
}}>
|
||||
<div className="mt-0.5 shrink-0">{statusIcon(item.status)}</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{item.name}</p>
|
||||
<p className={`truncate text-xs ${item.status === "error" ? "text-destructive" : "text-muted-foreground"}`}>
|
||||
{itemMetaLine(item)}
|
||||
</p>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2 text-[11px] text-muted-foreground">
|
||||
<span>{formatFileSize(item.size)}</span>
|
||||
<span>{fileNameFromPath(item.path).split(".").pop()?.toUpperCase() || "AUDIO"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="button" variant="ghost" size="icon" className="h-8 w-8 shrink-0" onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
handleRemoveItem(item.id);
|
||||
}} disabled={isBatchRunning || isExportingBatch || isExportingSelected || spectrumLoading}>
|
||||
<X className="h-4 w-4"/>
|
||||
</Button>
|
||||
</div>);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{batchDetailContent}
|
||||
</div>
|
||||
</div>)}
|
||||
</div>);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,77 @@
|
||||
export const TidalIcon = ({ className = "w-4 h-4" }: {
|
||||
import amazonMusicIcon from "../assets/icons/amazon-music.png";
|
||||
import qobuzIcon from "../assets/icons/qobuz.png";
|
||||
import tidalIcon from "../assets/icons/tidal.png";
|
||||
const PLATFORM_ICON_URLS = {
|
||||
tidal: tidalIcon,
|
||||
qobuz: qobuzIcon,
|
||||
amazon: amazonMusicIcon,
|
||||
} as const;
|
||||
type PlatformIconProps = {
|
||||
className?: string;
|
||||
}) => (<svg viewBox="0 0 24 24" className={`${className} fill-current`}>
|
||||
};
|
||||
function sanitizeClassName(className: string): string {
|
||||
return className
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.filter((part) => part !== "fill-current" && part !== "fill-muted-foreground" && !part.startsWith("text-"))
|
||||
.join(" ");
|
||||
}
|
||||
function hasRoundedClass(className: string): boolean {
|
||||
return className
|
||||
.split(/\s+/)
|
||||
.some((part) => part.startsWith("rounded"));
|
||||
}
|
||||
function getStatusClasses(className: string): string {
|
||||
if (className.includes("text-green-500")) {
|
||||
return "ring-2 ring-green-500 rounded-sm";
|
||||
}
|
||||
if (className.includes("text-red-500")) {
|
||||
return "ring-2 ring-red-500 rounded-sm opacity-70";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
function PlatformIcon({ src, alt, className = "w-4 h-4", defaultClassName = "" }: {
|
||||
src: string;
|
||||
alt: string;
|
||||
className?: string;
|
||||
defaultClassName?: string;
|
||||
}) {
|
||||
const cleanedClassName = sanitizeClassName(className);
|
||||
const statusClasses = getStatusClasses(className);
|
||||
const imageClassName = [
|
||||
cleanedClassName || "w-4 h-4",
|
||||
"inline-block shrink-0 object-contain",
|
||||
!hasRoundedClass(cleanedClassName) ? defaultClassName : "",
|
||||
statusClasses,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
return <img src={src} alt={alt} className={imageClassName} loading="lazy" referrerPolicy="no-referrer"/>;
|
||||
}
|
||||
export function TidalIcon({ className = "w-4 h-4" }: PlatformIconProps) {
|
||||
return <PlatformIcon src={PLATFORM_ICON_URLS.tidal} alt="Tidal" className={className} defaultClassName="rounded-[4px]"/>;
|
||||
}
|
||||
export function QobuzIcon({ className = "w-4 h-4" }: PlatformIconProps) {
|
||||
return <PlatformIcon src={PLATFORM_ICON_URLS.qobuz} alt="Qobuz" className={className}/>;
|
||||
}
|
||||
export function AmazonIcon({ className = "w-4 h-4" }: PlatformIconProps) {
|
||||
return <PlatformIcon src={PLATFORM_ICON_URLS.amazon} alt="Amazon Music" className={className} defaultClassName="rounded-[4px]"/>;
|
||||
}
|
||||
export function TidalAvailabilityIcon({ className = "w-4 h-4" }: PlatformIconProps) {
|
||||
return <svg viewBox="0 0 24 24" className={`${className} fill-current`}>
|
||||
<path d="M4.022 4.5 0 8.516l3.997 3.99 3.997-3.984L4.022 4.5Zm7.956 0L7.994 8.522l4.003 3.984L16 8.484 11.978 4.5Zm8.007 0L24 8.528l-4.003 3.978L16 8.484 19.985 4.5Z"></path>
|
||||
<path d="m8.012 16.534 3.991 3.966L16 16.49l-4.003-3.984-3.985 4.028Z"></path>
|
||||
</svg>);
|
||||
export const QobuzIcon = ({ className = "w-4 h-4" }: {
|
||||
className?: string;
|
||||
}) => (<svg viewBox="0 0 24 24" className={`${className} fill-current`}>
|
||||
</svg>;
|
||||
}
|
||||
export function QobuzAvailabilityIcon({ className = "w-4 h-4" }: PlatformIconProps) {
|
||||
return <svg viewBox="0 0 24 24" className={`${className} fill-current`}>
|
||||
<path d="M21.744 9.815C19.836 1.261 8.393-1 3.55 6.64-.618 13.214 4 22 11.988 22c2.387 0 4.63-.83 6.394-2.304l2.252 2.252 1.224-1.224-2.252-2.253c1.983-2.407 2.823-5.586 2.138-8.656Zm-3.508 7.297L16.4 15.275c-.786-.787-2.017.432-1.224 1.225L17 18.326C10.29 23.656.5 16 5.16 7.667c3.502-6.264 13.172-4.348 14.707 2.574.529 2.385-.06 4.987-1.63 6.87Z"></path>
|
||||
<path d="M13.4 8.684a3.59 3.59 0 0 0-4.712 1.9 3.59 3.59 0 0 0 1.9 4.712 3.594 3.594 0 0 0 4.711-1.89 3.598 3.598 0 0 0-1.9-4.722Zm-.737 3.591a.727.727 0 0 1-.965.384.727.727 0 0 1-.384-.965.727.727 0 0 1 .965-.384.73.73 0 0 1 .384.965Z"></path>
|
||||
</svg>);
|
||||
export const AmazonIcon = ({ className = "w-4 h-4" }: {
|
||||
className?: string;
|
||||
}) => (<svg viewBox="0 0 24 24" className={`${className} fill-current`}>
|
||||
</svg>;
|
||||
}
|
||||
export function AmazonAvailabilityIcon({ className = "w-4 h-4" }: PlatformIconProps) {
|
||||
return <svg viewBox="0 0 24 24" className={`${className} fill-current`}>
|
||||
<path fillRule="evenodd" d="M15.62 11.13c-.15.1-.37.18-.64.18-.42 0-.82-.05-1.21-.18l-.22-.04c-.08 0-.1.04-.1.14v.25c0 .08.02.12.05.17.02.03.07.08.15.1.4.18.84.25 1.33.25.52 0 .91-.12 1.24-.37.32-.25.47-.57.47-.99 0-.3-.08-.52-.23-.72-.15-.17-.4-.34-.74-.47l-.7-.27c-.26-.1-.46-.2-.53-.3a.47.47 0 0 1-.15-.36c0-.38.27-.57.84-.57.32 0 .64.05.94.15l.2.04c.07 0 .12-.04.12-.14v-.25c0-.08-.03-.12-.05-.17-.03-.05-.08-.08-.15-.1-.37-.13-.74-.2-1.11-.2-.47 0-.87.12-1.16.35-.3.22-.45.54-.45.91 0 .57.32.99.97 1.24l.74.27c.24.1.4.17.5.27.09.1.12.2.12.35 0 .2-.08.37-.23.46Zm-3.88-3.55v3.28c-.42.28-.84.42-1.26.42-.27 0-.47-.07-.6-.22-.11-.15-.16-.37-.16-.7V7.59c0-.13-.05-.18-.18-.18h-.52c-.12 0-.17.05-.17.18v3.06c0 .42.1.77.32.99.22.22.55.35.97.35.56 0 1.13-.2 1.68-.6l.05.3c0 .07.02.1.07.12.02.03.07.03.15.03h.37c.12 0 .17-.05.17-.18V7.58c0-.13-.05-.18-.17-.18h-.52c-.15 0-.2.08-.2.18Zm-4.69 4.27h.52c.12 0 .17-.05.17-.17v-3.1c0-.41-.1-.73-.32-.95a1.25 1.25 0 0 0-.94-.35c-.57 0-1.16.2-1.73.62-.2-.42-.57-.62-1.11-.62-.55 0-1.1.2-1.64.57l-.04-.27c0-.08-.03-.1-.08-.13-.02-.02-.07-.02-.12-.02h-.4c-.12 0-.17.05-.17.17v4.1c0 .13.05.18.17.18h.52c.12 0 .17-.05.17-.18V8.37c.42-.25.84-.4 1.29-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.17.17h.52c.13 0 .18-.05.18-.17V8.37c.44-.27.86-.4 1.28-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.18.17Zm13.47 3.29a21.8 21.8 0 0 1-8.3 1.7c-3.96 0-7.8-1.08-10.88-2.89a.35.35 0 0 0-.15-.05c-.17 0-.27.2-.1.37a16.11 16.11 0 0 0 10.87 4.16c3.02 0 6.5-.94 8.9-2.72.42-.3.08-.74-.34-.57Zm-.08-6.74c.22-.26.57-.38 1.06-.38.25 0 .5.03.72.1l.15.02c.07 0 .12-.04.12-.17v-.25c0-.07-.02-.14-.05-.17a.54.54 0 0 0-.12-.1c-.32-.07-.64-.15-.94-.15-.7 0-1.21.2-1.6.62-.38.4-.57 1-.57 1.73 0 .74.17 1.31.54 1.7.37.4.89.6 1.58.6.37 0 .72-.05.99-.17.07-.03.12-.05.15-.1.02-.03.02-.1.02-.17v-.25c0-.13-.05-.17-.12-.17-.03 0-.07 0-.12.02-.28.07-.55.12-.8.12-.46 0-.81-.12-1.03-.37-.23-.24-.32-.64-.32-1.16v-.12c.02-.55.12-.94.34-1.19Z" clipRule="evenodd"></path>
|
||||
<path fillRule="evenodd" d="M21.55 17.46c1.29-1.09 1.64-3.33 1.36-3.68-.12-.15-.71-.3-1.45-.3-.8 0-1.73.18-2.45.67-.22.15-.17.35.05.32.76-.1 2.5-.3 2.82.1.3.4-.35 2.03-.65 2.74-.07.23.1.3.32.15ZM18.12 7.4h-.52c-.12 0-.17.05-.17.18v4.1c0 .12.05.17.17.17h.52c.12 0 .17-.05.17-.17v-4.1c0-.1-.05-.18-.17-.18Zm.15-1.68a.58.58 0 0 0-.42-.15c-.18 0-.3.05-.4.15a.5.5 0 0 0-.15.37c0 .15.05.3.15.37.1.1.22.15.4.15.17 0 .3-.05.4-.15a.5.5 0 0 0 .14-.37c0-.15-.02-.3-.12-.37Z" clipRule="evenodd"></path>
|
||||
</svg>);
|
||||
</svg>;
|
||||
}
|
||||
|
||||
@@ -216,10 +216,16 @@ export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, sel
|
||||
<p>Download All Separate Covers</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>)}
|
||||
{downloadedTracks.size > 0 && (<Button onClick={onOpenFolder} variant="outline">
|
||||
{downloadedTracks.size > 0 && (<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button onClick={onOpenFolder} variant="outline" size="icon">
|
||||
<FolderOpen className="h-4 w-4"/>
|
||||
Open Folder
|
||||
</Button>)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Open Folder</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>)}
|
||||
</div>
|
||||
{isDownloading && (<DownloadProgress progress={downloadProgress} currentTrack={currentDownloadInfo} onStop={onStopDownload}/>)}
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { SearchSpotify, SearchSpotifyByType } from "../../wailsjs/go/main/App";
|
||||
import { backend } from "../../wailsjs/go/models";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTypingEffect } from "@/hooks/useTypingEffect";
|
||||
import { getSettings, type Settings } from "@/lib/settings";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
|
||||
const FETCH_PLACEHOLDERS = [
|
||||
@@ -245,6 +246,7 @@ interface SearchBarProps {
|
||||
export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, history, onHistorySelect, onHistoryRemove, hasResult, searchMode, onSearchModeChange, region, onRegionChange, }: SearchBarProps) {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [searchResults, setSearchResults] = useState<backend.SearchResponse | null>(null);
|
||||
const [showRegionSelector, setShowRegionSelector] = useState(() => getSettings().linkResolver === "songlink");
|
||||
const [resultFilter, setResultFilter] = useState("");
|
||||
const [sortOrders, setSortOrders] = useState<Record<ResultTab, string>>({
|
||||
tracks: "default",
|
||||
@@ -279,6 +281,18 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist
|
||||
console.error("Failed to load recent searches:", error);
|
||||
}
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const syncRegionVisibility = (settings?: Partial<Settings>) => {
|
||||
const resolver = settings?.linkResolver ?? getSettings().linkResolver;
|
||||
setShowRegionSelector(resolver === "songlink");
|
||||
};
|
||||
syncRegionVisibility();
|
||||
const handleSettingsUpdate = (event: Event) => {
|
||||
syncRegionVisibility((event as CustomEvent<Partial<Settings>>).detail);
|
||||
};
|
||||
window.addEventListener("settingsUpdated", handleSettingsUpdate);
|
||||
return () => window.removeEventListener("settingsUpdated", handleSettingsUpdate);
|
||||
}, []);
|
||||
const saveRecentSearch = (query: string) => {
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed)
|
||||
@@ -589,7 +603,7 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist
|
||||
</div>
|
||||
|
||||
{!searchMode && (<>
|
||||
<Select value={region} onValueChange={onRegionChange}>
|
||||
{showRegionSelector && (<Select value={region} onValueChange={onRegionChange}>
|
||||
<SelectTrigger className="w-[70px] shrink-0">
|
||||
<SelectValue placeholder="Region"/>
|
||||
</SelectTrigger>
|
||||
@@ -601,7 +615,7 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist
|
||||
</span>
|
||||
</SelectItem>))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Select>)}
|
||||
<Button onClick={handleFetchWithValidation} disabled={loading}>
|
||||
{loading ? (<>
|
||||
<Spinner />
|
||||
|
||||
@@ -13,24 +13,9 @@ import { themes, applyTheme } from "@/lib/themes";
|
||||
import { SelectFolder, OpenConfigFolder } from "../../wailsjs/go/main/App";
|
||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||
import { ApiStatusTab } from "./ApiStatusTab";
|
||||
const TidalIcon = ({ className }: {
|
||||
className?: string;
|
||||
}) => (<svg viewBox="0 0 24 24" className={`inline-block w-[1.1em] h-[1.1em] mr-2 ${className || "fill-muted-foreground"}`}>
|
||||
<path d="M4.022 4.5 0 8.516l3.997 3.99 3.997-3.984L4.022 4.5Zm7.956 0L7.994 8.522l4.003 3.984L16 8.484 11.978 4.5Zm8.007 0L24 8.528l-4.003 3.978L16 8.484 19.985 4.5Z"></path>
|
||||
<path d="m8.012 16.534 3.991 3.966L16 16.49l-4.003-3.984-3.985 4.028Z"></path>
|
||||
</svg>);
|
||||
const QobuzIcon = ({ className }: {
|
||||
className?: string;
|
||||
}) => (<svg viewBox="0 0 24 24" className={`inline-block w-[1.1em] h-[1.1em] mr-2 ${className || "fill-muted-foreground"}`}>
|
||||
<path d="M21.744 9.815C19.836 1.261 8.393-1 3.55 6.64-.618 13.214 4 22 11.988 22c2.387 0 4.63-.83 6.394-2.304l2.252 2.252 1.224-1.224-2.252-2.253c1.983-2.407 2.823-5.586 2.138-8.656Zm-3.508 7.297L16.4 15.275c-.786-.787-2.017.432-1.224 1.225L17 18.326C10.29 23.656.5 16 5.16 7.667c3.502-6.264 13.172-4.348 14.707 2.574.529 2.385-.06 4.987-1.63 6.87Z"></path>
|
||||
<path d="M13.4 8.684a3.59 3.59 0 0 0-4.712 1.9 3.59 3.59 0 0 0 1.9 4.712 3.594 3.594 0 0 0 4.711-1.89 3.598 3.598 0 0 0-1.9-4.722Zm-.737 3.591a.727.727 0 0 1-.965.384.727.727 0 0 1-.384-.965.727.727 0 0 1 .965-.384.73.73 0 0 1 .384.965Z"></path>
|
||||
</svg>);
|
||||
const AmazonIcon = ({ className }: {
|
||||
className?: string;
|
||||
}) => (<svg viewBox="0 0 24 24" className={`inline-block w-[1.1em] h-[1.1em] mr-2 ${className || "fill-muted-foreground"}`}>
|
||||
<path fillRule="evenodd" d="M15.62 11.13c-.15.1-.37.18-.64.18-.42 0-.82-.05-1.21-.18l-.22-.04c-.08 0-.1.04-.1.14v.25c0 .08.02.12.05.17.02.03.07.08.15.1.4.18.84.25 1.33.25.52 0 .91-.12 1.24-.37.32-.25.47-.57.47-.99 0-.3-.08-.52-.23-.72-.15-.17-.4-.34-.74-.47l-.7-.27c-.26-.1-.46-.2-.53-.3a.47.47 0 0 1-.15-.36c0-.38.27-.57.84-.57.32 0 .64.05.94.15l.2.04c.07 0 .12-.04.12-.14v-.25c0-.08-.03-.12-.05-.17-.03-.05-.08-.08-.15-.1-.37-.13-.74-.2-1.11-.2-.47 0-.87.12-1.16.35-.3.22-.45.54-.45.91 0 .57.32.99.97 1.24l.74.27c.24.1.4.17.5.27.09.1.12.2.12.35 0 .2-.08.37-.23.46Zm-3.88-3.55v3.28c-.42.28-.84.42-1.26.42-.27 0-.47-.07-.6-.22-.11-.15-.16-.37-.16-.7V7.59c0-.13-.05-.18-.18-.18h-.52c-.12 0-.17.05-.17.18v3.06c0 .42.1.77.32.99.22.22.55.35.97.35.56 0 1.13-.2 1.68-.6l.05.3c0 .07.02.1.07.12.02.03.07.03.15.03h.37c.12 0 .17-.05.17-.18V7.58c0-.13-.05-.18-.17-.18h-.52c-.15 0-.2.08-.2.18Zm-4.69 4.27h.52c.12 0 .17-.05.17-.17v-3.1c0-.41-.1-.73-.32-.95a1.25 1.25 0 0 0-.94-.35c-.57 0-1.16.2-1.73.62-.2-.42-.57-.62-1.11-.62-.55 0-1.1.2-1.64.57l-.04-.27c0-.08-.03-.1-.08-.13-.02-.02-.07-.02-.12-.02h-.4c-.12 0-.17.05-.17.17v4.1c0 .13.05.18.17.18h.52c.12 0 .17-.05.17-.18V8.37c.42-.25.84-.4 1.29-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.17.17h.52c.13 0 .18-.05.18-.17V8.37c.44-.27.86-.4 1.28-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.18.17Zm13.47 3.29a21.8 21.8 0 0 1-8.3 1.7c-3.96 0-7.8-1.08-10.88-2.89a.35.35 0 0 0-.15-.05c-.17 0-.27.2-.1.37a16.11 16.11 0 0 0 10.87 4.16c3.02 0 6.5-.94 8.9-2.72.42-.3.08-.74-.34-.57Zm-.08-6.74c.22-.26.57-.38 1.06-.38.25 0 .5.03.72.1l.15.02c.07 0 .12-.04.12-.17v-.25c0-.07-.02-.14-.05-.17a.54.54 0 0 0-.12-.1c-.32-.07-.64-.15-.94-.15-.7 0-1.21.2-1.6.62-.38.4-.57 1-.57 1.73 0 .74.17 1.31.54 1.7.37.4.89.6 1.58.6.37 0 .72-.05.99-.17.07-.03.12-.05.15-.1.02-.03.02-.1.02-.17v-.25c0-.13-.05-.17-.12-.17-.03 0-.07 0-.12.02-.28.07-.55.12-.8.12-.46 0-.81-.12-1.03-.37-.23-.24-.32-.64-.32-1.16v-.12c.02-.55.12-.94.34-1.19Z" clipRule="evenodd"></path>
|
||||
<path fillRule="evenodd" d="M21.55 17.46c1.29-1.09 1.64-3.33 1.36-3.68-.12-.15-.71-.3-1.45-.3-.8 0-1.73.18-2.45.67-.22.15-.17.35.05.32.76-.1 2.5-.3 2.82.1.3.4-.35 2.03-.65 2.74-.07.23.1.3.32.15ZM18.12 7.4h-.52c-.12 0-.17.05-.17.18v4.1c0 .12.05.17.17.17h.52c.12 0 .17-.05.17-.17v-4.1c0-.1-.05-.18-.17-.18Zm.15-1.68a.58.58 0 0 0-.42-.15c-.18 0-.3.05-.4.15a.5.5 0 0 0-.15.37c0 .15.05.3.15.37.1.1.22.15.4.15.17 0 .3-.05.4-.15a.5.5 0 0 0 .14-.37c0-.15-.02-.3-.12-.37Z" clipRule="evenodd"></path>
|
||||
</svg>);
|
||||
import { AmazonIcon, QobuzIcon, TidalIcon } from "./PlatformIcons";
|
||||
import songlinkIcon from "@/assets/icons/songlink.ico";
|
||||
import songstatsIcon from "@/assets/icons/songstats.png";
|
||||
interface SettingsPageProps {
|
||||
onUnsavedChangesChange?: (hasUnsavedChanges: boolean) => void;
|
||||
onResetRequest?: (resetFn: () => void) => void;
|
||||
@@ -247,6 +232,44 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="link-resolver">Link Resolver</Label>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<Select value={tempSettings.linkResolver} onValueChange={(value: "songstats" | "songlink") => setTempSettings((prev) => ({
|
||||
...prev,
|
||||
linkResolver: value,
|
||||
}))}>
|
||||
<SelectTrigger id="link-resolver" className="h-9 w-fit min-w-[140px]">
|
||||
<SelectValue placeholder="Select a link resolver"/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="songlink">
|
||||
<span className="flex items-center gap-2">
|
||||
<img src={songlinkIcon} alt="Songlink" className="h-4 w-4 shrink-0 rounded-[3px] object-contain" loading="lazy"/>
|
||||
Songlink
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="songstats">
|
||||
<span className="flex items-center gap-2">
|
||||
<img src={songstatsIcon} alt="Songstats" className="h-4 w-4 shrink-0 rounded-[3px] object-contain" loading="lazy"/>
|
||||
Songstats
|
||||
</span>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch id="allow-link-resolver-fallback" checked={tempSettings.allowResolverFallback} onCheckedChange={(checked) => setTempSettings((prev) => ({
|
||||
...prev,
|
||||
allowResolverFallback: checked,
|
||||
}))}/>
|
||||
<Label htmlFor="allow-link-resolver-fallback" className="text-sm font-normal cursor-pointer">
|
||||
Allow Fallback
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="downloader">Source</Label>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
@@ -260,19 +283,19 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">Auto</SelectItem>
|
||||
<SelectItem value="tidal">
|
||||
<span className="flex items-center">
|
||||
<span className="flex items-center gap-2">
|
||||
<TidalIcon />
|
||||
Tidal
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="qobuz">
|
||||
<span className="flex items-center">
|
||||
<span className="flex items-center gap-2">
|
||||
<QobuzIcon />
|
||||
Qobuz
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="amazon">
|
||||
<span className="flex items-center">
|
||||
<span className="flex items-center gap-2">
|
||||
<AmazonIcon />
|
||||
Amazon Music
|
||||
</span>
|
||||
|
||||
@@ -7,6 +7,18 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "
|
||||
export interface SpectrumVisualizationHandle {
|
||||
getCanvasDataURL: () => string | null;
|
||||
}
|
||||
type ColorScheme = AnalyzerColorScheme;
|
||||
type FreqScale = AnalyzerFreqScale;
|
||||
type WindowFunction = AnalyzerWindowFunction;
|
||||
export interface SpectrogramRenderOptions {
|
||||
spectrumData: SpectrumData;
|
||||
sampleRate: number;
|
||||
duration: number;
|
||||
freqScale: FreqScale;
|
||||
colorScheme: ColorScheme;
|
||||
fileName?: string;
|
||||
shouldCancel?: () => boolean;
|
||||
}
|
||||
interface SpectrumVisualizationProps {
|
||||
sampleRate: number;
|
||||
duration: number;
|
||||
@@ -19,9 +31,6 @@ interface SpectrumVisualizationProps {
|
||||
message: string;
|
||||
};
|
||||
}
|
||||
type ColorScheme = AnalyzerColorScheme;
|
||||
type FreqScale = AnalyzerFreqScale;
|
||||
type WindowFunction = AnalyzerWindowFunction;
|
||||
const MARGIN = { top: 50, right: 120, bottom: 70, left: 90 };
|
||||
const CANVAS_W = 1100;
|
||||
const CANVAS_H = 600;
|
||||
@@ -420,6 +429,20 @@ async function renderSpectrogram(ctx: CanvasRenderingContext2D, spectrum: Spectr
|
||||
addAxisLabels(ctx, plotWidth, plotHeight, sampleRate, duration, freqScale, fileName);
|
||||
drawColorBar(ctx, plotHeight, colorScheme);
|
||||
}
|
||||
export async function renderSpectrogramToCanvas(canvas: HTMLCanvasElement, options: SpectrogramRenderOptions): Promise<void> {
|
||||
canvas.width = CANVAS_W;
|
||||
canvas.height = CANVAS_H;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) {
|
||||
throw new Error("Cannot get 2D canvas context");
|
||||
}
|
||||
await renderSpectrogram(ctx, options.spectrumData, options.sampleRate, options.duration, options.freqScale, options.colorScheme, options.fileName, options.shouldCancel ?? (() => false));
|
||||
}
|
||||
export async function createSpectrogramDataURL(options: SpectrogramRenderOptions): Promise<string> {
|
||||
const canvas = document.createElement("canvas");
|
||||
await renderSpectrogramToCanvas(canvas, options);
|
||||
return canvas.toDataURL("image/png");
|
||||
}
|
||||
const COLOR_SCHEMES: {
|
||||
value: ColorScheme;
|
||||
label: string;
|
||||
@@ -468,7 +491,15 @@ export const SpectrumVisualization = forwardRef<SpectrumVisualizationHandle, Spe
|
||||
let canceled = false;
|
||||
const shouldCancel = () => canceled;
|
||||
if (spectrumData) {
|
||||
void renderSpectrogram(ctx, spectrumData, sampleRate, duration, freqScale, colorScheme, fileName, shouldCancel);
|
||||
void renderSpectrogramToCanvas(canvas, {
|
||||
spectrumData,
|
||||
sampleRate,
|
||||
duration,
|
||||
freqScale,
|
||||
colorScheme,
|
||||
fileName,
|
||||
shouldCancel,
|
||||
});
|
||||
}
|
||||
else {
|
||||
ctx.fillStyle = "#000000";
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { X, Minus, Maximize, SlidersHorizontal, Info } from "lucide-react";
|
||||
import { X, Minus, Maximize, SlidersHorizontal, Info, Globe } from "lucide-react";
|
||||
import { WindowMinimise, WindowToggleMaximise, Quit } from "../../wailsjs/runtime/runtime";
|
||||
import { Menubar, MenubarContent, MenubarMenu, MenubarItem, MenubarTrigger, MenubarLabel, MenubarSeparator } from "@/components/ui/menubar";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { getSettings, updateSettings } from "@/lib/settings";
|
||||
import { openExternal } from "@/lib/utils";
|
||||
import { useState, useEffect } from "react";
|
||||
export function TitleBar() {
|
||||
const [useSpotFetchAPI, setUseSpotFetchAPI] = useState(false);
|
||||
@@ -65,6 +66,11 @@ export function TitleBar() {
|
||||
<span>Use SpotFetch API</span>
|
||||
<span className="ml-4">{useSpotFetchAPI ? "✓" : ""}</span>
|
||||
</MenubarItem>
|
||||
<MenubarSeparator />
|
||||
<MenubarItem onClick={() => openExternal("https://afkarxyz.qzz.io")} className="gap-2">
|
||||
<Globe className="w-4 h-4 opacity-70"/>
|
||||
<span>Website</span>
|
||||
</MenubarItem>
|
||||
</MenubarContent>
|
||||
</MenubarMenu>
|
||||
</Menubar>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Download, FolderOpen, CheckCircle, XCircle, FileText, FileCheck, Globe,
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
|
||||
import type { TrackMetadata, TrackAvailability } from "@/types/api";
|
||||
import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons";
|
||||
import { TidalAvailabilityIcon, QobuzAvailabilityIcon, AmazonAvailabilityIcon } from "./PlatformIcons";
|
||||
import { usePreview } from "@/hooks/usePreview";
|
||||
interface TrackInfoProps {
|
||||
track: TrackMetadata & {
|
||||
@@ -140,16 +140,22 @@ export function TrackInfo({ track, isDownloading, downloadingTrack, isDownloaded
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{availability ? (<div className="flex items-center gap-2">
|
||||
<TidalIcon className={`w-4 h-4 ${availability.tidal ? "text-green-500" : "text-red-500"}`}/>
|
||||
<QobuzIcon className={`w-4 h-4 ${availability.qobuz ? "text-green-500" : "text-red-500"}`}/>
|
||||
<AmazonIcon className={`w-4 h-4 ${availability.amazon ? "text-green-500" : "text-red-500"}`}/>
|
||||
<TidalAvailabilityIcon className={`w-4 h-4 ${availability.tidal ? "text-green-500" : "text-red-500"}`}/>
|
||||
<QobuzAvailabilityIcon className={`w-4 h-4 ${availability.qobuz ? "text-green-500" : "text-red-500"}`}/>
|
||||
<AmazonAvailabilityIcon className={`w-4 h-4 ${availability.amazon ? "text-green-500" : "text-red-500"}`}/>
|
||||
</div>) : (<p>Check Availability</p>)}
|
||||
</TooltipContent>
|
||||
</Tooltip>)}
|
||||
{isDownloaded && (<Button onClick={onOpenFolder} variant="outline">
|
||||
{isDownloaded && (<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button onClick={onOpenFolder} variant="outline" size="icon">
|
||||
<FolderOpen className="h-4 w-4"/>
|
||||
Open Folder
|
||||
</Button>)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Open Folder</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>)}
|
||||
</div>)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
|
||||
import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, } from "@/components/ui/pagination";
|
||||
import type { TrackMetadata, TrackAvailability } from "@/types/api";
|
||||
import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons";
|
||||
import { TidalAvailabilityIcon, QobuzAvailabilityIcon, AmazonAvailabilityIcon } from "./PlatformIcons";
|
||||
import { usePreview } from "@/hooks/usePreview";
|
||||
interface TrackListProps {
|
||||
tracks: TrackMetadata[];
|
||||
@@ -328,9 +328,9 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{availabilityMap?.has(track.spotify_id) ? (<div className="flex items-center gap-2">
|
||||
<TidalIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.tidal ? "text-green-500" : "text-red-500"}`}/>
|
||||
<QobuzIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.qobuz ? "text-green-500" : "text-red-500"}`}/>
|
||||
<AmazonIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.amazon ? "text-green-500" : "text-red-500"}`}/>
|
||||
<TidalAvailabilityIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.tidal ? "text-green-500" : "text-red-500"}`}/>
|
||||
<QobuzAvailabilityIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.qobuz ? "text-green-500" : "text-red-500"}`}/>
|
||||
<AmazonAvailabilityIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.amazon ? "text-green-500" : "text-red-500"}`}/>
|
||||
</div>) : (<p>Check Availability</p>)}
|
||||
</TooltipContent>
|
||||
</Tooltip>)}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { API_SOURCES, checkAllApiStatuses, ensureApiStatusCheckStarted, getApiStatusState, subscribeApiStatus, } from "@/lib/api-status";
|
||||
export function useApiStatus() {
|
||||
const [state, setState] = useState(getApiStatusState);
|
||||
useEffect(() => {
|
||||
ensureApiStatusCheckStarted();
|
||||
return subscribeApiStatus(() => {
|
||||
setState(getApiStatusState());
|
||||
});
|
||||
}, []);
|
||||
return {
|
||||
...state,
|
||||
sources: API_SOURCES,
|
||||
refreshAll: checkAllApiStatuses,
|
||||
};
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { useState, useCallback, useRef, useEffect, type MutableRefObject } from
|
||||
import type { AnalysisResult } from "@/types/api";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||
import { analyzeAudioArrayBuffer, analyzeAudioFile, analyzeSpectrumFromSamples, type AnalysisProgress, } from "@/lib/flac-analysis";
|
||||
import { analyzeAudioArrayBuffer, analyzeAudioFile, analyzeDecodedSamples, analyzeSpectrumFromSamples, parseAudioMetadataFromInput, pcm16MonoArrayBufferToFloat32Samples, type AnalysisProgress, type FrontendAnalysisPayload, type ParsedAudioMetadata, } from "@/lib/flac-analysis";
|
||||
import { loadAudioAnalysisPreferences } from "@/lib/audio-analysis-preferences";
|
||||
type WindowFunction = "hann" | "hamming" | "blackman" | "rectangular";
|
||||
function toWindowFunction(value: string): WindowFunction {
|
||||
@@ -49,6 +49,8 @@ let sessionResult: AnalysisResult | null = null;
|
||||
let sessionSelectedFilePath = "";
|
||||
let sessionError: string | null = null;
|
||||
let sessionSamples: Float32Array | null = null;
|
||||
let sessionCurrentAnalysisKey = "";
|
||||
const sessionSamplesByKey = new Map<string, Float32Array>();
|
||||
interface ProgressState {
|
||||
percent: number;
|
||||
message: string;
|
||||
@@ -60,6 +62,35 @@ const DEFAULT_PROGRESS_STATE: ProgressState = {
|
||||
interface CancelToken {
|
||||
cancelled: boolean;
|
||||
}
|
||||
interface AnalyzeExecutionOptions {
|
||||
analysisKey?: string;
|
||||
displayPath?: string;
|
||||
suppressToast?: boolean;
|
||||
}
|
||||
export interface AnalyzeExecutionOutcome {
|
||||
result: AnalysisResult | null;
|
||||
error: string | null;
|
||||
cancelled: boolean;
|
||||
}
|
||||
interface WailsWindow extends Window {
|
||||
go?: {
|
||||
main?: {
|
||||
App?: {
|
||||
ReadFileAsBase64?: (path: string) => Promise<string>;
|
||||
DecodeAudioForAnalysis?: (path: string) => Promise<BackendAnalysisDecodeResponse>;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
interface BackendAnalysisDecodeResponse {
|
||||
pcm_base64: string;
|
||||
sample_rate: number;
|
||||
channels: number;
|
||||
bits_per_sample: number;
|
||||
duration: number;
|
||||
bitrate_kbps?: number;
|
||||
bit_depth?: string;
|
||||
}
|
||||
function cancelToken(tokenRef: MutableRefObject<CancelToken | null>): void {
|
||||
if (tokenRef.current) {
|
||||
tokenRef.current.cancelled = true;
|
||||
@@ -81,6 +112,23 @@ function toProgressState(progress: AnalysisProgress): ProgressState {
|
||||
message: progress.message,
|
||||
};
|
||||
}
|
||||
function isDecodeFailure(error: unknown): boolean {
|
||||
return error instanceof Error && /decode/i.test(error.message);
|
||||
}
|
||||
function mergeBackendDecodedMetadata(parsed: ParsedAudioMetadata, decoded: BackendAnalysisDecodeResponse): ParsedAudioMetadata {
|
||||
const sampleRate = decoded.sample_rate > 0 ? decoded.sample_rate : parsed.sampleRate;
|
||||
const bitsPerSample = decoded.bits_per_sample > 0 ? decoded.bits_per_sample : parsed.bitsPerSample;
|
||||
const duration = decoded.duration > 0 ? decoded.duration : parsed.duration;
|
||||
return {
|
||||
...parsed,
|
||||
sampleRate,
|
||||
channels: decoded.channels > 0 ? decoded.channels : parsed.channels,
|
||||
bitsPerSample,
|
||||
totalSamples: duration > 0 && sampleRate > 0 ? Math.floor(duration * sampleRate) : parsed.totalSamples,
|
||||
duration,
|
||||
bitrateKbps: decoded.bitrate_kbps ?? parsed.bitrateKbps,
|
||||
};
|
||||
}
|
||||
export function useAudioAnalysis() {
|
||||
const [analyzing, setAnalyzing] = useState(false);
|
||||
const [analysisProgress, setAnalysisProgress] = useState<ProgressState>(DEFAULT_PROGRESS_STATE);
|
||||
@@ -90,6 +138,7 @@ export function useAudioAnalysis() {
|
||||
const [spectrumLoading, setSpectrumLoading] = useState(false);
|
||||
const [spectrumProgress, setSpectrumProgress] = useState<ProgressState>(DEFAULT_PROGRESS_STATE);
|
||||
const samplesRef = useRef<Float32Array | null>(sessionSamples);
|
||||
const currentAnalysisKeyRef = useRef(sessionCurrentAnalysisKey);
|
||||
const analysisTokenRef = useRef<CancelToken | null>(null);
|
||||
const spectrumTokenRef = useRef<CancelToken | null>(null);
|
||||
useEffect(() => {
|
||||
@@ -110,12 +159,32 @@ export function useAudioAnalysis() {
|
||||
sessionError = next;
|
||||
setError(next);
|
||||
}, []);
|
||||
const analyzeFile = useCallback(async (file: File) => {
|
||||
const setCurrentAnalysisKey = useCallback((analysisKey: string) => {
|
||||
currentAnalysisKeyRef.current = analysisKey;
|
||||
sessionCurrentAnalysisKey = analysisKey;
|
||||
}, []);
|
||||
const storeSuccessfulAnalysis = useCallback((analysisKey: string, displayPath: string, payload: FrontendAnalysisPayload) => {
|
||||
sessionSamplesByKey.set(analysisKey, payload.samples);
|
||||
samplesRef.current = payload.samples;
|
||||
sessionSamples = payload.samples;
|
||||
setCurrentAnalysisKey(analysisKey);
|
||||
setResultWithSession(payload.result);
|
||||
setSelectedFilePathWithSession(displayPath);
|
||||
setErrorWithSession(null);
|
||||
}, [setCurrentAnalysisKey, setErrorWithSession, setResultWithSession, setSelectedFilePathWithSession]);
|
||||
const analyzeFile = useCallback(async (file: File, options?: AnalyzeExecutionOptions): Promise<AnalyzeExecutionOutcome> => {
|
||||
if (!file) {
|
||||
setErrorWithSession("No file provided");
|
||||
return null;
|
||||
const errorMessage = "No file provided";
|
||||
setErrorWithSession(errorMessage);
|
||||
return {
|
||||
result: null,
|
||||
error: errorMessage,
|
||||
cancelled: false,
|
||||
};
|
||||
}
|
||||
const token = createToken(analysisTokenRef);
|
||||
const analysisKey = options?.analysisKey || file.name;
|
||||
const displayPath = options?.displayPath || file.name;
|
||||
cancelToken(spectrumTokenRef);
|
||||
setAnalyzing(true);
|
||||
setAnalysisProgress({
|
||||
@@ -124,32 +193,44 @@ export function useAudioAnalysis() {
|
||||
});
|
||||
setErrorWithSession(null);
|
||||
setResultWithSession(null);
|
||||
setSelectedFilePathWithSession(file.name);
|
||||
setSelectedFilePathWithSession(displayPath);
|
||||
setCurrentAnalysisKey(analysisKey);
|
||||
try {
|
||||
logger.info(`Analyzing audio file (frontend): ${file.name}`);
|
||||
logger.info(`Analyzing audio file (frontend): ${displayPath}`);
|
||||
const start = Date.now();
|
||||
const prefs = loadAudioAnalysisPreferences();
|
||||
const payload = await analyzeAudioFile(file, {
|
||||
fftSize: prefs.fftSize,
|
||||
windowFunction: prefs.windowFunction,
|
||||
}, (progress) => {
|
||||
if (token.cancelled)
|
||||
if (token.cancelled) {
|
||||
return;
|
||||
}
|
||||
setAnalysisProgress(toProgressState(progress));
|
||||
}, () => token.cancelled);
|
||||
if (token.cancelled) {
|
||||
return null;
|
||||
return {
|
||||
result: null,
|
||||
error: null,
|
||||
cancelled: true,
|
||||
};
|
||||
}
|
||||
samplesRef.current = payload.samples;
|
||||
sessionSamples = payload.samples;
|
||||
setResultWithSession(payload.result);
|
||||
storeSuccessfulAnalysis(analysisKey, displayPath, payload);
|
||||
const elapsed = ((Date.now() - start) / 1000).toFixed(2);
|
||||
logger.success(`Audio analysis completed in ${elapsed}s`);
|
||||
return payload.result;
|
||||
return {
|
||||
result: payload.result,
|
||||
error: null,
|
||||
cancelled: false,
|
||||
};
|
||||
}
|
||||
catch (err) {
|
||||
if (isCancelledError(err)) {
|
||||
return null;
|
||||
return {
|
||||
result: null,
|
||||
error: null,
|
||||
cancelled: true,
|
||||
};
|
||||
}
|
||||
const errorMessage = err instanceof Error ? err.message : "Failed to analyze audio file";
|
||||
logger.error(`Analysis error: ${errorMessage}`);
|
||||
@@ -158,10 +239,16 @@ export function useAudioAnalysis() {
|
||||
percent: 0,
|
||||
message: "Analysis failed",
|
||||
});
|
||||
if (!options?.suppressToast) {
|
||||
toast.error("Audio Analysis Failed", {
|
||||
description: errorMessage,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
result: null,
|
||||
error: errorMessage,
|
||||
cancelled: false,
|
||||
};
|
||||
}
|
||||
finally {
|
||||
if (analysisTokenRef.current === token) {
|
||||
@@ -169,13 +256,20 @@ export function useAudioAnalysis() {
|
||||
setAnalyzing(false);
|
||||
}
|
||||
}
|
||||
}, [setErrorWithSession, setResultWithSession, setSelectedFilePathWithSession]);
|
||||
const analyzeFilePath = useCallback(async (filePath: string) => {
|
||||
}, [setCurrentAnalysisKey, setErrorWithSession, setResultWithSession, setSelectedFilePathWithSession, storeSuccessfulAnalysis]);
|
||||
const analyzeFilePath = useCallback(async (filePath: string, options?: AnalyzeExecutionOptions): Promise<AnalyzeExecutionOutcome> => {
|
||||
if (!filePath) {
|
||||
setErrorWithSession("No file path provided");
|
||||
return null;
|
||||
const errorMessage = "No file path provided";
|
||||
setErrorWithSession(errorMessage);
|
||||
return {
|
||||
result: null,
|
||||
error: errorMessage,
|
||||
cancelled: false,
|
||||
};
|
||||
}
|
||||
const token = createToken(analysisTokenRef);
|
||||
const analysisKey = options?.analysisKey || filePath;
|
||||
const displayPath = options?.displayPath || filePath;
|
||||
cancelToken(spectrumTokenRef);
|
||||
setAnalyzing(true);
|
||||
setAnalysisProgress({
|
||||
@@ -184,18 +278,23 @@ export function useAudioAnalysis() {
|
||||
});
|
||||
setErrorWithSession(null);
|
||||
setResultWithSession(null);
|
||||
setSelectedFilePathWithSession(filePath);
|
||||
setSelectedFilePathWithSession(displayPath);
|
||||
setCurrentAnalysisKey(analysisKey);
|
||||
try {
|
||||
logger.info(`Analyzing audio file (frontend from path): ${filePath}`);
|
||||
const start = Date.now();
|
||||
const prefs = loadAudioAnalysisPreferences();
|
||||
const readFileAsBase64 = (window as any)?.go?.main?.App?.ReadFileAsBase64 as ((path: string) => Promise<string>) | undefined;
|
||||
const readFileAsBase64 = (window as WailsWindow).go?.main?.App?.ReadFileAsBase64;
|
||||
if (!readFileAsBase64) {
|
||||
throw new Error("ReadFileAsBase64 backend method is unavailable");
|
||||
}
|
||||
let base64Data = await readFileAsBase64(filePath);
|
||||
if (token.cancelled) {
|
||||
return null;
|
||||
return {
|
||||
result: null,
|
||||
error: null,
|
||||
cancelled: true,
|
||||
};
|
||||
}
|
||||
setAnalysisProgress({
|
||||
percent: 10,
|
||||
@@ -204,42 +303,105 @@ export function useAudioAnalysis() {
|
||||
const arrayBuffer = await base64ToArrayBuffer(base64Data, () => token.cancelled);
|
||||
base64Data = "";
|
||||
if (token.cancelled) {
|
||||
return null;
|
||||
return {
|
||||
result: null,
|
||||
error: null,
|
||||
cancelled: true,
|
||||
};
|
||||
}
|
||||
setAnalysisProgress({
|
||||
percent: 15,
|
||||
message: "Preparing audio buffer...",
|
||||
});
|
||||
const fileName = fileNameFromPath(filePath);
|
||||
const payload = await analyzeAudioArrayBuffer({
|
||||
const input = {
|
||||
fileName,
|
||||
fileSize: arrayBuffer.byteLength,
|
||||
arrayBuffer,
|
||||
}, {
|
||||
};
|
||||
const analysisParams = {
|
||||
fftSize: prefs.fftSize,
|
||||
windowFunction: prefs.windowFunction,
|
||||
}, (progress) => {
|
||||
if (token.cancelled)
|
||||
} as const;
|
||||
const updateProgress = (progress: AnalysisProgress) => {
|
||||
if (token.cancelled) {
|
||||
return;
|
||||
}
|
||||
const mappedPercent = 10 + (progress.percent * 0.9);
|
||||
setAnalysisProgress({
|
||||
percent: Math.round(Math.max(0, Math.min(100, mappedPercent))),
|
||||
message: progress.message,
|
||||
});
|
||||
}, () => token.cancelled);
|
||||
if (token.cancelled) {
|
||||
return null;
|
||||
};
|
||||
let payload: FrontendAnalysisPayload;
|
||||
try {
|
||||
payload = await analyzeAudioArrayBuffer(input, analysisParams, updateProgress, () => token.cancelled);
|
||||
}
|
||||
samplesRef.current = payload.samples;
|
||||
sessionSamples = payload.samples;
|
||||
setResultWithSession(payload.result);
|
||||
catch (err) {
|
||||
if (!isDecodeFailure(err)) {
|
||||
throw err;
|
||||
}
|
||||
const decodeAudioForAnalysis = (window as WailsWindow).go?.main?.App?.DecodeAudioForAnalysis;
|
||||
if (!decodeAudioForAnalysis) {
|
||||
throw err;
|
||||
}
|
||||
logger.warning(`Browser decoder failed for ${fileName}; trying FFmpeg fallback`);
|
||||
setAnalysisProgress({
|
||||
percent: 18,
|
||||
message: "Browser decoder failed, trying FFmpeg fallback...",
|
||||
});
|
||||
const decoded = await decodeAudioForAnalysis(filePath);
|
||||
if (token.cancelled) {
|
||||
return {
|
||||
result: null,
|
||||
error: null,
|
||||
cancelled: true,
|
||||
};
|
||||
}
|
||||
setAnalysisProgress({
|
||||
percent: 24,
|
||||
message: "Decoding audio with FFmpeg...",
|
||||
});
|
||||
const pcmBase64 = decoded.pcm_base64 || "";
|
||||
if (!pcmBase64) {
|
||||
throw new Error("FFmpeg analysis decode returned no PCM data");
|
||||
}
|
||||
const pcmBuffer = await base64ToArrayBuffer(pcmBase64, () => token.cancelled);
|
||||
if (token.cancelled) {
|
||||
return {
|
||||
result: null,
|
||||
error: null,
|
||||
cancelled: true,
|
||||
};
|
||||
}
|
||||
const parsedMetadata = parseAudioMetadataFromInput(input);
|
||||
const mergedMetadata = mergeBackendDecodedMetadata(parsedMetadata, decoded);
|
||||
const samples = pcm16MonoArrayBufferToFloat32Samples(pcmBuffer);
|
||||
payload = await analyzeDecodedSamples(input, mergedMetadata, samples, analysisParams, updateProgress, () => token.cancelled, mergedMetadata.duration);
|
||||
}
|
||||
if (token.cancelled) {
|
||||
return {
|
||||
result: null,
|
||||
error: null,
|
||||
cancelled: true,
|
||||
};
|
||||
}
|
||||
storeSuccessfulAnalysis(analysisKey, displayPath, payload);
|
||||
const elapsed = ((Date.now() - start) / 1000).toFixed(2);
|
||||
logger.success(`Audio analysis completed in ${elapsed}s`);
|
||||
return payload.result;
|
||||
return {
|
||||
result: payload.result,
|
||||
error: null,
|
||||
cancelled: false,
|
||||
};
|
||||
}
|
||||
catch (err) {
|
||||
if (isCancelledError(err)) {
|
||||
return null;
|
||||
return {
|
||||
result: null,
|
||||
error: null,
|
||||
cancelled: true,
|
||||
};
|
||||
}
|
||||
const errorMessage = err instanceof Error ? err.message : "Failed to analyze audio file";
|
||||
logger.error(`Analysis error: ${errorMessage}`);
|
||||
@@ -248,10 +410,16 @@ export function useAudioAnalysis() {
|
||||
percent: 0,
|
||||
message: "Analysis failed",
|
||||
});
|
||||
if (!options?.suppressToast) {
|
||||
toast.error("Audio Analysis Failed", {
|
||||
description: errorMessage,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
result: null,
|
||||
error: errorMessage,
|
||||
cancelled: false,
|
||||
};
|
||||
}
|
||||
finally {
|
||||
if (analysisTokenRef.current === token) {
|
||||
@@ -259,10 +427,46 @@ export function useAudioAnalysis() {
|
||||
setAnalyzing(false);
|
||||
}
|
||||
}
|
||||
}, [setErrorWithSession, setResultWithSession, setSelectedFilePathWithSession]);
|
||||
const reAnalyzeSpectrum = useCallback(async (fftSize: number, windowFunction: string) => {
|
||||
if (!result || !samplesRef.current)
|
||||
}, [setCurrentAnalysisKey, setErrorWithSession, setResultWithSession, setSelectedFilePathWithSession, storeSuccessfulAnalysis]);
|
||||
const loadStoredAnalysis = useCallback((analysisKey: string, nextResult: AnalysisResult, displayPath: string) => {
|
||||
setCurrentAnalysisKey(analysisKey);
|
||||
samplesRef.current = sessionSamplesByKey.get(analysisKey) ?? null;
|
||||
sessionSamples = samplesRef.current;
|
||||
setResultWithSession(nextResult);
|
||||
setSelectedFilePathWithSession(displayPath);
|
||||
setErrorWithSession(null);
|
||||
}, [setCurrentAnalysisKey, setErrorWithSession, setResultWithSession, setSelectedFilePathWithSession]);
|
||||
const clearStoredAnalysis = useCallback((analysisKey?: string) => {
|
||||
if (analysisKey) {
|
||||
sessionSamplesByKey.delete(analysisKey);
|
||||
if (currentAnalysisKeyRef.current === analysisKey) {
|
||||
currentAnalysisKeyRef.current = "";
|
||||
sessionCurrentAnalysisKey = "";
|
||||
samplesRef.current = null;
|
||||
sessionSamples = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
sessionSamplesByKey.clear();
|
||||
currentAnalysisKeyRef.current = "";
|
||||
sessionCurrentAnalysisKey = "";
|
||||
samplesRef.current = null;
|
||||
sessionSamples = null;
|
||||
}, []);
|
||||
const cancelAnalysis = useCallback(() => {
|
||||
cancelToken(analysisTokenRef);
|
||||
setAnalyzing(false);
|
||||
setAnalysisProgress((prev) => prev.percent > 0
|
||||
? {
|
||||
percent: prev.percent,
|
||||
message: "Analysis stopped",
|
||||
}
|
||||
: DEFAULT_PROGRESS_STATE);
|
||||
}, []);
|
||||
const reAnalyzeSpectrum = useCallback(async (fftSize: number, windowFunction: string) => {
|
||||
if (!result || !samplesRef.current) {
|
||||
return null;
|
||||
}
|
||||
const token = createToken(spectrumTokenRef);
|
||||
setSpectrumLoading(true);
|
||||
setSpectrumProgress({
|
||||
@@ -275,22 +479,24 @@ export function useAudioAnalysis() {
|
||||
fftSize,
|
||||
windowFunction: toWindowFunction(windowFunction),
|
||||
}, (progress) => {
|
||||
if (token.cancelled)
|
||||
return;
|
||||
setSpectrumProgress(toProgressState(progress));
|
||||
}, () => token.cancelled);
|
||||
if (token.cancelled) {
|
||||
return;
|
||||
}
|
||||
setResult((prev) => {
|
||||
const next = prev ? { ...prev, spectrum } : prev;
|
||||
sessionResult = next;
|
||||
return next;
|
||||
});
|
||||
setSpectrumProgress(toProgressState(progress));
|
||||
}, () => token.cancelled);
|
||||
if (token.cancelled) {
|
||||
return null;
|
||||
}
|
||||
const nextResult = {
|
||||
...result,
|
||||
spectrum,
|
||||
};
|
||||
setResultWithSession(nextResult);
|
||||
return nextResult;
|
||||
}
|
||||
catch (err) {
|
||||
if (isCancelledError(err)) {
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
const errorMessage = err instanceof Error ? err.message : "Failed to re-analyze spectrum";
|
||||
logger.error(`Spectrum re-analysis error: ${errorMessage}`);
|
||||
@@ -301,6 +507,7 @@ export function useAudioAnalysis() {
|
||||
toast.error("Spectrum Analysis Failed", {
|
||||
description: errorMessage,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
finally {
|
||||
if (spectrumTokenRef.current === token) {
|
||||
@@ -308,7 +515,7 @@ export function useAudioAnalysis() {
|
||||
setSpectrumLoading(false);
|
||||
}
|
||||
}
|
||||
}, [result]);
|
||||
}, [result, setResultWithSession]);
|
||||
const clearResult = useCallback(() => {
|
||||
cancelToken(analysisTokenRef);
|
||||
cancelToken(spectrumTokenRef);
|
||||
@@ -319,6 +526,8 @@ export function useAudioAnalysis() {
|
||||
setSpectrumLoading(false);
|
||||
setAnalysisProgress(DEFAULT_PROGRESS_STATE);
|
||||
setSpectrumProgress(DEFAULT_PROGRESS_STATE);
|
||||
currentAnalysisKeyRef.current = "";
|
||||
sessionCurrentAnalysisKey = "";
|
||||
samplesRef.current = null;
|
||||
sessionSamples = null;
|
||||
}, [setErrorWithSession, setResultWithSession, setSelectedFilePathWithSession]);
|
||||
@@ -332,6 +541,9 @@ export function useAudioAnalysis() {
|
||||
spectrumProgress,
|
||||
analyzeFile,
|
||||
analyzeFilePath,
|
||||
cancelAnalysis,
|
||||
loadStoredAnalysis,
|
||||
clearStoredAnalysis,
|
||||
reAnalyzeSpectrum,
|
||||
clearResult,
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useCallback } from "react";
|
||||
import { CheckTrackAvailability } from "../../wailsjs/go/main/App";
|
||||
import type { TrackAvailability } from "@/types/api";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { CHECK_TIMEOUT_MS, withTimeout } from "@/lib/async-timeout";
|
||||
export function useAvailability() {
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [checkingTrackId, setCheckingTrackId] = useState<string | null>(null);
|
||||
@@ -20,7 +21,7 @@ export function useAvailability() {
|
||||
setError(null);
|
||||
try {
|
||||
logger.info(`Checking availability for track: ${spotifyId}`);
|
||||
const response = await CheckTrackAvailability(spotifyId);
|
||||
const response = await withTimeout(CheckTrackAvailability(spotifyId), CHECK_TIMEOUT_MS, `Availability check timed out after 10 seconds for ${spotifyId}`);
|
||||
const availability: TrackAvailability = JSON.parse(response);
|
||||
setAvailabilityMap((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
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 {
|
||||
id: string;
|
||||
type: string;
|
||||
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" },
|
||||
];
|
||||
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>();
|
||||
function emitApiStatusChange() {
|
||||
for (const listener of listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
function setApiStatusState(updater: (current: ApiStatusState) => ApiStatusState) {
|
||||
apiStatusState = updater(apiStatusState);
|
||||
emitApiStatusChange();
|
||||
}
|
||||
async function checkSingleApiStatus(source: ApiSource): Promise<void> {
|
||||
setApiStatusState((current) => ({
|
||||
...current,
|
||||
statuses: {
|
||||
...current.statuses,
|
||||
[source.id]: "checking",
|
||||
},
|
||||
}));
|
||||
try {
|
||||
const isOnline = await withTimeout(CheckAPIStatus(source.type, source.url), CHECK_TIMEOUT_MS, `API status check timed out after 10 seconds for ${source.url}`);
|
||||
setApiStatusState((current) => ({
|
||||
...current,
|
||||
statuses: {
|
||||
...current.statuses,
|
||||
[source.id]: isOnline ? "online" : "offline",
|
||||
},
|
||||
}));
|
||||
}
|
||||
catch {
|
||||
setApiStatusState((current) => ({
|
||||
...current,
|
||||
statuses: {
|
||||
...current.statuses,
|
||||
[source.id]: "offline",
|
||||
},
|
||||
}));
|
||||
}
|
||||
}
|
||||
export function getApiStatusState(): ApiStatusState {
|
||||
return apiStatusState;
|
||||
}
|
||||
export function subscribeApiStatus(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
export function hasApiStatusResults(): boolean {
|
||||
return API_SOURCES.some((source) => {
|
||||
const status = apiStatusState.statuses[source.id];
|
||||
return status === "online" || status === "offline";
|
||||
});
|
||||
}
|
||||
export function ensureApiStatusCheckStarted(): void {
|
||||
if (!activeCheckAll && !hasApiStatusResults()) {
|
||||
void checkAllApiStatuses();
|
||||
}
|
||||
}
|
||||
export async function checkAllApiStatuses(): Promise<void> {
|
||||
if (activeCheckAll) {
|
||||
return activeCheckAll;
|
||||
}
|
||||
activeCheckAll = (async () => {
|
||||
setApiStatusState((current) => ({
|
||||
...current,
|
||||
isCheckingAll: true,
|
||||
}));
|
||||
try {
|
||||
await Promise.allSettled(API_SOURCES.map((source) => checkSingleApiStatus(source)));
|
||||
}
|
||||
finally {
|
||||
setApiStatusState((current) => ({
|
||||
...current,
|
||||
isCheckingAll: false,
|
||||
}));
|
||||
activeCheckAll = null;
|
||||
}
|
||||
})();
|
||||
return activeCheckAll;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export const CHECK_TIMEOUT_MS = 10 * 1000;
|
||||
export function withTimeout<T>(promise: Promise<T>, timeoutMs: number = CHECK_TIMEOUT_MS, message: string = `Operation timed out after ${Math.round(timeoutMs / 1000)} seconds`): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timer = window.setTimeout(() => {
|
||||
reject(new Error(message));
|
||||
}, timeoutMs);
|
||||
promise
|
||||
.then((value) => {
|
||||
window.clearTimeout(timer);
|
||||
resolve(value);
|
||||
})
|
||||
.catch((error) => {
|
||||
window.clearTimeout(timer);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -17,8 +17,8 @@ const MP4_CONTAINER_TYPES = new Set([
|
||||
"moov", "trak", "mdia", "minf", "stbl", "edts", "dinf",
|
||||
"udta", "ilst", "meta", "stsd", "wave",
|
||||
]);
|
||||
type SupportedAudioFileType = "FLAC" | "MP3" | "M4A" | "AAC";
|
||||
interface ParsedAudioMetadata {
|
||||
export type SupportedAudioFileType = "FLAC" | "MP3" | "M4A" | "AAC";
|
||||
export interface ParsedAudioMetadata {
|
||||
fileType: SupportedAudioFileType;
|
||||
sampleRate: number;
|
||||
channels: number;
|
||||
@@ -417,7 +417,7 @@ function parseM4aMetadata(buffer: ArrayBuffer): ParsedAudioMetadata {
|
||||
}
|
||||
}
|
||||
}
|
||||
else if ((box.type === "mp4a" || box.type === "aac ") && box.offset + 36 <= boxEnd) {
|
||||
else if ((box.type === "mp4a" || box.type === "aac " || box.type === "alac") && box.offset + 36 <= boxEnd) {
|
||||
channels = view.getUint16(box.offset + 24, false) || channels;
|
||||
bitsPerSample = view.getUint16(box.offset + 26, false) || bitsPerSample;
|
||||
if (!sampleRate) {
|
||||
@@ -455,7 +455,7 @@ function parseM4aMetadata(buffer: ArrayBuffer): ParsedAudioMetadata {
|
||||
duration,
|
||||
};
|
||||
}
|
||||
function parseAudioMetadata(input: AudioArrayBufferInput): ParsedAudioMetadata {
|
||||
export function parseAudioMetadataFromInput(input: AudioArrayBufferInput): ParsedAudioMetadata {
|
||||
const fileType = detectAudioFileType(input.arrayBuffer, input.fileName);
|
||||
switch (fileType) {
|
||||
case "FLAC": return parseFlacMetadata(input.arrayBuffer);
|
||||
@@ -465,6 +465,15 @@ function parseAudioMetadata(input: AudioArrayBufferInput): ParsedAudioMetadata {
|
||||
default: throw new Error(`Unsupported audio format: ${input.fileName || "unknown"}`);
|
||||
}
|
||||
}
|
||||
export function pcm16MonoArrayBufferToFloat32Samples(buffer: ArrayBuffer): Float32Array {
|
||||
const sampleCount = Math.floor(buffer.byteLength / 2);
|
||||
const samples = new Float32Array(sampleCount);
|
||||
const view = new DataView(buffer);
|
||||
for (let i = 0; i < sampleCount; i++) {
|
||||
samples[i] = view.getInt16(i * 2, true) / 32768;
|
||||
}
|
||||
return samples;
|
||||
}
|
||||
function buildWindowCoefficients(size: number, windowFunction: SpectrumParams["windowFunction"]): Float32Array {
|
||||
const coeffs = new Float32Array(size);
|
||||
if (size <= 1) {
|
||||
@@ -649,7 +658,7 @@ export async function analyzeAudioFile(file: File, params: SpectrumParams = DEFA
|
||||
export async function analyzeAudioArrayBuffer(input: AudioArrayBufferInput, params: SpectrumParams = DEFAULT_PARAMS, onProgress?: AnalysisProgressCallback, shouldCancel?: AnalysisCancelCheck): Promise<FrontendAnalysisPayload> {
|
||||
throwIfCancelled(shouldCancel);
|
||||
reportProgress(onProgress, "parse", 5, "Parsing audio metadata...");
|
||||
const metadata = parseAudioMetadata(input);
|
||||
const metadata = parseAudioMetadataFromInput(input);
|
||||
throwIfCancelled(shouldCancel);
|
||||
reportProgress(onProgress, "decode", 15, "Decoding audio stream...");
|
||||
const audioContext = createAnalysisAudioContext(metadata.sampleRate);
|
||||
@@ -658,6 +667,17 @@ export async function analyzeAudioArrayBuffer(input: AudioArrayBufferInput, para
|
||||
throwIfCancelled(shouldCancel);
|
||||
reportProgress(onProgress, "decode", 35, "Audio decoded");
|
||||
const samples = audioBuffer.getChannelData(0);
|
||||
return analyzeDecodedSamples(input, metadata, samples, params, onProgress, shouldCancel, audioBuffer.duration);
|
||||
}
|
||||
finally {
|
||||
await audioContext.close();
|
||||
}
|
||||
}
|
||||
export async function analyzeDecodedSamples(input: AudioArrayBufferInput, metadata: ParsedAudioMetadata, samples: Float32Array, params: SpectrumParams = DEFAULT_PARAMS, onProgress?: AnalysisProgressCallback, shouldCancel?: AnalysisCancelCheck, durationOverride?: number): Promise<FrontendAnalysisPayload> {
|
||||
throwIfCancelled(shouldCancel);
|
||||
const analysisSampleRate = metadata.sampleRate > 0 ? metadata.sampleRate : 44100;
|
||||
const analysisChannels = metadata.channels > 0 ? metadata.channels : 1;
|
||||
const bitDepthLabel = metadata.bitsPerSample > 0 ? `${metadata.bitsPerSample}-bit` : "Unknown";
|
||||
reportProgress(onProgress, "metrics", 40, "Calculating peak/RMS...");
|
||||
let peak = 0;
|
||||
let sumSquares = 0;
|
||||
@@ -670,7 +690,7 @@ export async function analyzeAudioArrayBuffer(input: AudioArrayBufferInput, para
|
||||
peak = absSample;
|
||||
sumSquares += sample * sample;
|
||||
if ((i + 1) % METRICS_CHUNK_SIZE === 0 || i === samples.length - 1) {
|
||||
const metricsProgress = 40 + (((i + 1) / samples.length) * 10);
|
||||
const metricsProgress = 40 + (((i + 1) / Math.max(1, samples.length)) * 10);
|
||||
reportProgress(onProgress, "metrics", metricsProgress, "Calculating peak/RMS...");
|
||||
const now = nowMs();
|
||||
if (now - lastMetricsYieldAt >= 16) {
|
||||
@@ -684,12 +704,16 @@ export async function analyzeAudioArrayBuffer(input: AudioArrayBufferInput, para
|
||||
const rms = samples.length > 0 ? Math.sqrt(sumSquares / samples.length) : 0;
|
||||
const rmsDB = rms > 0 ? 20 * Math.log10(rms) : -120;
|
||||
const dynamicRange = peakDB - rmsDB;
|
||||
const duration = audioBuffer.duration > 0 ? audioBuffer.duration : metadata.duration;
|
||||
const duration = durationOverride && durationOverride > 0
|
||||
? durationOverride
|
||||
: (metadata.duration > 0
|
||||
? metadata.duration
|
||||
: (analysisSampleRate > 0 ? samples.length / analysisSampleRate : 0));
|
||||
const totalSamples = metadata.totalSamples > 0
|
||||
? metadata.totalSamples
|
||||
: Math.floor(duration * metadata.sampleRate);
|
||||
: (duration > 0 ? Math.floor(duration * analysisSampleRate) : samples.length);
|
||||
reportProgress(onProgress, "metrics", 50, "Signal metrics complete");
|
||||
const spectrum = await analyzeSpectrumFromSamples(samples, metadata.sampleRate, params, (progress) => {
|
||||
const spectrum = await analyzeSpectrumFromSamples(samples, analysisSampleRate, params, (progress) => {
|
||||
const mappedPercent = 50 + (progress.percent * 0.45);
|
||||
reportProgress(onProgress, "spectrum", mappedPercent, progress.message);
|
||||
}, shouldCancel);
|
||||
@@ -699,12 +723,12 @@ export async function analyzeAudioArrayBuffer(input: AudioArrayBufferInput, para
|
||||
file_path: input.fileName,
|
||||
file_size: input.fileSize,
|
||||
file_type: metadata.fileType,
|
||||
sample_rate: metadata.sampleRate,
|
||||
channels: metadata.channels || audioBuffer.numberOfChannels,
|
||||
sample_rate: analysisSampleRate,
|
||||
channels: analysisChannels,
|
||||
bits_per_sample: metadata.bitsPerSample,
|
||||
total_samples: totalSamples,
|
||||
duration,
|
||||
bit_depth: `${metadata.bitsPerSample}-bit`,
|
||||
bit_depth: bitDepthLabel,
|
||||
dynamic_range: dynamicRange,
|
||||
peak_amplitude: peakDB,
|
||||
rms_level: rmsDB,
|
||||
@@ -719,9 +743,5 @@ export async function analyzeAudioArrayBuffer(input: AudioArrayBufferInput, para
|
||||
reportProgress(onProgress, "finalize", 100, "Analysis complete");
|
||||
return payload;
|
||||
}
|
||||
finally {
|
||||
await audioContext.close();
|
||||
}
|
||||
}
|
||||
export const analyzeFlacFile = analyzeAudioFile;
|
||||
export const analyzeFlacArrayBuffer = analyzeAudioArrayBuffer;
|
||||
|
||||
@@ -5,6 +5,8 @@ export type FilenamePreset = "title" | "title-artist" | "artist-title" | "track-
|
||||
export interface Settings {
|
||||
downloadPath: string;
|
||||
downloader: "auto" | "tidal" | "qobuz" | "amazon";
|
||||
linkResolver: "songstats" | "songlink";
|
||||
allowResolverFallback: boolean;
|
||||
theme: string;
|
||||
themeMode: "auto" | "light" | "dark";
|
||||
fontFamily: FontFamily;
|
||||
@@ -93,6 +95,8 @@ function detectOS(): "Windows" | "linux/MacOS" {
|
||||
export const DEFAULT_SETTINGS: Settings = {
|
||||
downloadPath: "",
|
||||
downloader: "auto",
|
||||
linkResolver: "songlink",
|
||||
allowResolverFallback: true,
|
||||
theme: "yellow",
|
||||
themeMode: "auto",
|
||||
fontFamily: "google-sans",
|
||||
@@ -225,6 +229,12 @@ function getSettingsFromLocalStorage(): Settings {
|
||||
if (!('allowFallback' in parsed)) {
|
||||
parsed.allowFallback = true;
|
||||
}
|
||||
if (!('linkResolver' in parsed)) {
|
||||
parsed.linkResolver = "songlink";
|
||||
}
|
||||
if (!('allowResolverFallback' in parsed)) {
|
||||
parsed.allowResolverFallback = true;
|
||||
}
|
||||
if (!('separator' in parsed)) {
|
||||
parsed.separator = "semicolon";
|
||||
}
|
||||
@@ -304,6 +314,12 @@ export async function loadSettings(): Promise<Settings> {
|
||||
if (!('allowFallback' in parsed)) {
|
||||
parsed.allowFallback = true;
|
||||
}
|
||||
if (!('linkResolver' in parsed)) {
|
||||
parsed.linkResolver = "songlink";
|
||||
}
|
||||
if (!('allowResolverFallback' in parsed)) {
|
||||
parsed.allowResolverFallback = true;
|
||||
}
|
||||
if (!('createPlaylistFolder' in parsed)) {
|
||||
parsed.createPlaylistFolder = true;
|
||||
}
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@
|
||||
},
|
||||
"info": {
|
||||
"productName": "SpotiFLAC",
|
||||
"productVersion": "7.1.2",
|
||||
"productVersion": "7.1.3",
|
||||
"copyright": "© 2026 afkarxyz"
|
||||
},
|
||||
"wailsjsdir": "./frontend",
|
||||
|
||||
Reference in New Issue
Block a user